@mshafiqyajid/react-form
Headless useForm hook + styled Form component. Sync/async validation, validateOn strategies, controlled mode, full ARIA wiring, error animations, and zero runtime dependencies.
Playground #
Install #
npm install @mshafiqyajid/react-form Quick start β styled #
import { Form } from "@mshafiqyajid/react-form/styled";
import "@mshafiqyajid/react-form/styles.css";
<Form
defaultValues={{ email: "", password: "" }}
validate={(values) => {
const errors: Record<string, string> = {};
if (!values.email) errors.email = "Email is required";
if (String(values.password).length < 8)
errors.password = "At least 8 characters";
return errors;
}}
onSubmit={async (values) => {
await login(values.email as string, values.password as string);
}}
>
{(form) => (
<>
<Form.Field name="email" label="Email" hint="Work email preferred" required>
<input type="email" {...form.register("email", { required: true })} />
</Form.Field>
<Form.Field name="password" label="Password" required>
<input type="password" {...form.register("password", { required: true })} />
</Form.Field>
<Form.Submit>Sign in</Form.Submit>
</>
)}
</Form> Headless #
import { useForm } from "@mshafiqyajid/react-form";
const { register, handleSubmit, formState } = useForm({
defaultValues: { email: "", password: "" },
validate: (values) => {
const errors: Record<string, string> = {};
if (!values.email) errors.email = "Email is required";
if (!values.password) errors.password = "Password is required";
return errors;
},
onSubmit: async (values, helpers) => {
await login(values.email as string, values.password as string);
helpers.reset();
},
});
return (
<form onSubmit={handleSubmit} noValidate aria-busy={formState.isSubmitting}>
<div>
<label htmlFor="rfrm-field-email">Email</label>
<input type="email" {...register("email")} />
{formState.errors.email && (
<span role="alert">{formState.errors.email}</span>
)}
</div>
<div>
<label htmlFor="rfrm-field-password">Password</label>
<input type="password" {...register("password")} />
{formState.errors.password && (
<span role="alert">{formState.errors.password}</span>
)}
</div>
<button type="submit" disabled={formState.isSubmitting}>
{formState.isSubmitting ? "Signing inβ¦" : "Sign in"}
</button>
</form>
); Async validation #
<Form
defaultValues={{ username: "" }}
validate={async (values) => {
const taken = await checkUsernameTaken(values.username as string);
return taken ? { username: "Username is already taken" } : {};
}}
validateOn="blur"
revalidateOn="change"
onSubmit={handleSubmit}
>
{(form) => (
<>
<Form.Field name="username" label="Username" required>
<input {...form.register("username", { required: true })} />
</Form.Field>
<Form.Submit>Create account</Form.Submit>
</>
)}
</Form> Controlled mode #
const [formValues, setFormValues] = useState({ search: "" });
<Form
values={formValues}
onSubmit={(values) => console.log(values)}
>
{(form) => (
<>
<Form.Field name="search" label="Search">
<input
{...form.register("search")}
onChange={(e) => setFormValues({ search: e.target.value })}
/>
</Form.Field>
<Form.Submit>Search</Form.Submit>
</>
)}
</Form> Programmatic control #
const { setValue, setError, clearErrors, reset, watch, formState } = useForm({
defaultValues: { role: "viewer" },
});
// Read current value
const role = watch("role");
// Set a value programmatically
setValue("role", "admin");
// Set a server-side error
setError("email", "This email is already in use");
// Clear a specific error
clearErrors("email");
// Clear all errors
clearErrors();
// Reset to defaults
reset();
// Reset to different values
reset({ role: "editor" }); API #
Full prop and option reference for all react-form exports.
useForm options #
| Option | Type | Default | Description |
|---|---|---|---|
| defaultValues | Record<string, unknown> | {} | Initial uncontrolled values |
| values | Record<string, unknown> | β | Fully controlled values (external source of truth) |
| validate | (values) => Record<string, string> | Promise<β¦> | β | Returns a map of field name β error message |
| validateOn | "blur" | "change" | "submit" | "submit" | When to run validation for the first time |
| revalidateOn | "blur" | "change" | "change" | When to re-validate a field that already has an error |
| onSubmit | (values, helpers) => void | Promise<void> | β | Called only when validation passes. Receives current values and helpers. |
| onError | (errors) => void | β | Called when submit is blocked by validation errors |
formState #
| Field | Type | Description |
|---|---|---|
| isSubmitting | boolean | True while the onSubmit promise is pending |
| isValid | boolean | No validation errors currently present |
| isDirty | boolean | Any value differs from defaultValues |
| errors | Record<string, string> | Current field errors (field name β message) |
| touchedFields | Record<string, boolean> | Fields that have been blurred at least once |
| dirtyFields | Record<string, boolean> | Fields whose current value differs from their default |
Form.Field props #
| Prop | Type | Default | Description |
|---|---|---|---|
| name | string | β | Field name β must match the key in your values object |
| label | string | β | Rendered as a <label> wired to the field's id |
| hint | string | β | Helper text shown below the input (hidden when there is an error) |
| required | boolean | false | Appends a red * to the label |
| className | string | β | Extra class on the field wrapper |
| style | CSSProperties | β | Inline style on the field wrapper |
| children | ReactNode | β | The input element(s) |
Data attributes on the field wrapper: data-invalid="true" when there's an error, data-touched="true" after the first blur, data-dirty="true" when the value differs from the default. A shake animation plays when a field first becomes invalid on submit.
Form.Submit props #
| Prop | Type | Default | Description |
|---|---|---|---|
| children | ReactNode | "Submit" | Button label |
| disabled | boolean | false | Force disabled state |
| className | string | β | Extra class |
| style | CSSProperties | β | Inline style |
The submit button automatically sets disabled and aria-busy="true" while the form is submitting, and shows a spinner next to the label.
ARIA #
register(name) automatically wires aria-invalid, aria-describedby (pointing to the hint and error elements), and aria-required based on the required option. The form root gets aria-busy="true" while submitting. Error elements receive role="alert" so screen readers announce them immediately.
CSS variables #
| Variable | Default | Description |
|---|---|---|
| --rfrm-submit-bg | #6366f1 | Submit button background |
| --rfrm-submit-fg | #ffffff | Submit button text colour |
| --rfrm-submit-bg-hover | #4f46e5 | Submit button hover background |
| --rfrm-submit-bg-disabled | #a5b4fc | Submit button disabled background |
| --rfrm-submit-radius | 8px | Submit button border radius |
| --rfrm-label-fg | #3f3f46 | Label text colour |
| --rfrm-label-font-size | 0.875rem | Label font size |
| --rfrm-label-font-weight | 500 | Label font weight |
| --rfrm-hint-fg | #71717a | Hint text colour |
| --rfrm-hint-font-size | 0.75rem | Hint font size |
| --rfrm-error-fg | #dc2626 | Error text colour |
| --rfrm-error-border | #dc2626 | Error border colour (for custom inputs) |
| --rfrm-required-fg | #dc2626 | Required asterisk colour |
| --rfrm-form-gap | 1.25rem | Vertical gap between fields |
| --rfrm-field-gap | 0.3rem | Gap within a field (label β input β hint) |
| --rfrm-duration | 150ms | Animation duration |
Dark mode tokens are applied automatically when a [data-theme="dark"] ancestor is present. Never uses @media (prefers-color-scheme: dark).