Form
A dependency-free form state engine + JSX wrappers. The point: turn the "I have 8 inputs and
want errors / submission / dirty tracking" problem into one <Form initialValues onSubmit>
without reaching for React Hook Form / Formik / your own useState soup.
Overview — validation, errors, and submit in one form
Three pieces, useful independently:
| Export | What it does |
|---|---|
useForm() | Headless engine. State + helpers + handleChange / handleBlur / handleSubmit. |
<Form> | <form noValidate> that owns a useForm and publishes the live FormApi on context. |
<FormField> | Name-bound adapter. Reads form state for name, wires the inner control automatically. |
You can use one, two, or all three — they compose cleanly with <Field>.
Why this (instead of RHF / Formik)?
Formik is unmaintained. React Hook Form is excellent but its API surface can be overkill for
"I have a settings page." This trio stays lightweight and pairs with the controls you already
have wired through <Field> for a11y.
It's not schema-first — no Yup / Zod / Joi adapter baked in. The validate prop is a plain
(values) => errors function; Zod is a 5-line safeParse wrapper at the call site (see the
example).
Quick start
import { Button, Form, FormField, Input } from 'apx-ds';
<Form
initialValues={{ email: '', password: '' }}
validate={(v) => {
const errors: Record<string, string> = {};
if (!v.email) errors.email = 'Email is required';
if (v.password.length < 8) errors.password = 'Min 8 characters';
return errors;
}}
onSubmit={async (values) => {
await api.signIn(values);
}}
>
<FormField name="email" label="Email" required>
<Input type="email" />
</FormField>
<FormField name="password" label="Password" required>
<Input type="password" />
</FormField>
<Button type="submit" variant="solid">Sign in</Button>
</Form>import { Button, Form, FormField, Input } from 'apx-ds';
<Form
initialValues={{ email: '', password: '' }}
validate={(v) => {
const errors: Record<string, string> = {};
if (!v.email) errors.email = 'Email is required';
if (v.password.length < 8) errors.password = 'Min 8 characters';
return errors;
}}
onSubmit={async (values) => {
await api.signIn(values);
}}
>
<FormField name="email" label="Email" required>
<Input type="email" />
</FormField>
<FormField name="password" label="Password" required>
<Input type="password" />
</FormField>
<Button type="submit" variant="solid">Sign in</Button>
</Form>That's the whole API. No register, no Controller, no withFormik.
Patterns
Basic — controlled values + submit
Sign-up — email / password / terms-checkbox with central validate()
Settings page — Input + Textarea + reset button
Async username check — debounced + abortable
Per-field validator instead of central validate
Server-rejected fields via helpers.setErrors()
“You have unsaved changes” via form.isDirty
Reset to initial vs reset to a fresh values object
Zod / Yup integration in 8 lines — no bundled adapter
enableReinitialize — switch between two profiles
Render-prop access to the live FormApi
useForm without <Form> — pair with a native <form>
Every DS form control auto-bound via <FormField binding="...">
API
useForm<Values>(options)
| Option | Type | Default | Notes |
|---|---|---|---|
initialValues | Values | — | Required. |
validate | (values) => errors | Promise<errors> | — | Central validator. Per-field validators override on collision. |
validateOn | 'submit' | 'blur' | 'change' | 'submit-and-blur' | 'submit-and-blur' | UX default: don't yell mid-typing. |
validateOnMount | boolean | false | Run validate once at mount (useful for "save-as-you-edit" UIs). |
enableReinitialize | boolean | false | Re-init when initialValues identity changes. Footgun: opt-in. |
onSubmit | (values, helpers) => void | Promise<void> | — | Returning a promise sets isSubmitting. |
onReset | (values) => void | — | Fired after resetForm(). |
focusOnError | boolean | true | After a failed submit, focuses the first invalid field's control. |
Returns a FormApi<Values> — full state + helpers + native handlers.
<Form initialValues onSubmit>
Renders <form noValidate onSubmit={form.handleSubmit}> and publishes the live FormApi on
FormContext. Accepts every option useForm accepts plus all <form> HTML attributes.
Children can be regular React nodes (recommended) or a (form) => ReactNode render-prop.
<FormField name binding label …>
The adapter. Reads form state for name, wires the inner control via cloneElement, registers
per-field validators + the control id for focusOnError.
| Prop | Type | Default | Notes |
|---|---|---|---|
name | string | — | Required. Must exist on initialValues. |
binding | 'native' | 'checkbox' | 'value' | 'native' | How the child receives state — see below. |
validate | (value, values) => string | null | — | Sync per-field validator. |
validateAsync | (value, { signal }) => Promise<string | null> | — | Debounced + abortable async validator. |
validateDebounceMs | number | 300 | Async debounce window. |
| (all Field props) | — | — | label / description / helperText / required / size / … |
Binding strategies
binding | Use with | Wires |
|---|---|---|
'native' | <Input>, <Textarea>, <NumberInput>, plain <input> / <select> / <textarea> | value, onChange(event), onBlur(event) |
'checkbox' | <Checkbox>, <Switch> | checked, onCheckedChange(boolean), onChange(event) (fallback) |
'value' | <Combobox>, <Select>, <Rating>, <TagsInput>, <Slider>, <RadioGroup> | value, onChange(value), onBlur(event) |
We don't auto-sniff displayName — it's brittle across forwardRef wrappers + minification. An
explicit one-word binding prop is honest and predictable.
FormApi<Values> shape
interface FormApi<Values> {
// State
values: Values;
initialValues: Values;
errors: Partial<Record<keyof Values, string>>;
touched: Partial<Record<keyof Values, boolean>>;
dirty: Partial<Record<keyof Values, boolean>>;
isSubmitting: boolean;
submitCount: number;
isValid: boolean;
isDirty: boolean;
// Helpers
setFieldValue(name, value): void;
setFieldError(name, error): void;
setFieldTouched(name, touched): void;
setErrors(errors): void;
setTouched(touched): void;
setValues(partial): void;
resetForm(next?: { values }): void;
validateForm(): Promise<errors>;
submitForm(): Promise<void>;
// Native handlers
handleChange(event): void;
handleBlur(event): void;
handleSubmit(event?): Promise<void>;
// Registration (used by <FormField>)
registerFieldValidator(name, validator): () => void;
registerFieldId(name, id): () => void;
}interface FormApi<Values> {
// State
values: Values;
initialValues: Values;
errors: Partial<Record<keyof Values, string>>;
touched: Partial<Record<keyof Values, boolean>>;
dirty: Partial<Record<keyof Values, boolean>>;
isSubmitting: boolean;
submitCount: number;
isValid: boolean;
isDirty: boolean;
// Helpers
setFieldValue(name, value): void;
setFieldError(name, error): void;
setFieldTouched(name, touched): void;
setErrors(errors): void;
setTouched(touched): void;
setValues(partial): void;
resetForm(next?: { values }): void;
validateForm(): Promise<errors>;
submitForm(): Promise<void>;
// Native handlers
handleChange(event): void;
handleBlur(event): void;
handleSubmit(event?): Promise<void>;
// Registration (used by <FormField>)
registerFieldValidator(name, validator): () => void;
registerFieldId(name, id): () => void;
}A11y
<Form noValidate>— we own validation, browser stays out of the way.- After a failed submit, all fields are marked
touched=trueso every error is announced. focusOnError(on by default) jumps focus to the first invalid control by id.- A single off-screen
aria-live="polite"region per<Form>announces error-count transitions. <FormField>inherits all<Field>a11y wiring (label association,aria-invalid,aria-describedby, required indicator, errorrole="alert").- axe-core: 0 violations across signup / settings / async / error-server / disabled / dirty states.
When to use what
| Want… | Use |
|---|---|
| Just JSX, no fuss | <Form> + <FormField> |
Full control over the <form> element / event flow | useForm() + your own <form> |
To pair with <Field> you've already wired manually | useForm() + spread form.getFieldProps (manual; see HeadlessHook example) |
| A Zod / Yup schema | Wrap it inside validate — no adapter needed (see ZodIntegration example) |
See also
<Field>— the wrapper<FormField>renders underneath.useFormFieldA11y— the shared hook every form control already calls; ensures Field + Form integration is 100% additive with zero source-code changes to existing controls.<TagsInput>/<Combobox>/<Select>/<Rating>— value-callback controls; bind via<FormField binding="value">.<Checkbox>/<Switch>— boolean toggles; bind via<FormField binding="checkbox">.