Create Form Control
Apply this skill any time the user is writing a new input component intended to live inside a <Form> or <NativeForm>. Every input in this library is a thin UI wrapper around useFormControl.
The contract
useFormControl(props, options?) registers the input with the surrounding form (via FormContext) and returns a stable hook object:
{ id: string; // generated or echoed back from props.id name: string; // echoed back, dot-notation normalized (`__proto__` / `constructor` / `prototype` segments are refused) type: string; // echoed back (default "text") value: any; // current value changeValue: (value, options?) => void; // call from onChange / onChangeText error: ReactNode; // current validation error (or null) errorId: string; // `${id}-error` — wire to the error node + aria-describedby errorsList: { [ruleName]: ReactNode }; // per-rule errors when validateAll is on setError: (error) => void; checked: boolean; // for checkbox / radio setChecked: (checked: boolean) => void; inputRef: RefObject; // attach to the host input — enables focus()/blur() visibleElementRef: RefObject; // attach to the wrapper — enables isVisible() / validateVisible() formControl: FormControl; // the underlying registration object — escape hatch disabled: boolean; disable: () => void; enable: () => void; isInvalid: boolean; // true once touched AND validation has failed isValidating: boolean; // (v4) true while an async rule is in-flight for this control onBlur: () => void; // (v4) blur handler — marks touched + runs validateOn="blur" otherProps: object; // every prop NOT consumed by the hook or by rules getInputProps: (overrides?) => object; // (v4) a11y-complete prop bag for the host input getErrorProps: () => { id; role; "aria-live" }; // (v4) prop bag for the error node}Pattern 1 — Text input (Web)
import { useFormControl, type FormControlProps } from "@mongez/react-form";
export default function TextInput(props: FormControlProps) { const { value, changeValue, id, error, inputRef, otherProps } = useFormControl(props);
return ( <> <input id={id} ref={inputRef} value={value} onChange={(e) => changeValue(e.target.value)} {...otherProps} /> {error && <span className="error">{error}</span>} </> );}Critical points:
- Spread
otherProps(NOT rawprops) onto the host<input>.otherPropsexcludes hook-internal props (name,rules,errors,onChange,value,defaultValue,validateOn,schema, etc.) and any props the active rules declared aspreservedProps(e.g.minLength,pattern). In v4 thevalidateOnandschemaprops are destructured out, so they never leak onto the DOM element. - Always wire
inputRefif you wantformControl.focus()/formControl.blur()to work. - The hook is controlled internally —
valuefrom the hook is always the source of truth, regardless of whether the user passed avalueprop or not.
Pattern 1b — Accessible text input via getInputProps / getErrorProps (v4, recommended)
In v4 the hook returns two prop bags that wire id/value/onChange/onBlur/ref and the ARIA attributes for you. Prefer these over hand-spreading — you get aria-invalid, aria-required, and aria-describedby (linking the input to its error node) automatically.
import { useFormControl, type FormControlProps } from "@mongez/react-form";
export default function TextInput(props: FormControlProps) { const { error, getInputProps, getErrorProps } = useFormControl(props);
return ( <> <input {...getInputProps()} /> {error && ( <span className="error" {...getErrorProps()}> {error} </span> )} </> );}What getInputProps(overrides?) returns:
id,name,ref(theinputRef),disabled- For text-like inputs:
value(coerced to""when nullish) and anonChangethat unwrapse.target.value. - For
type="checkbox"/type="radio":checkedand anonChangethat unwrapse.target.checkedinstead. onBlur(marks touched + runsvalidateOn="blur").aria-invalid(when touched and failing),aria-required(from therequiredprop), andaria-describedby(set toerrorIdonly while an error is showing).- Then spreads
otherProps, then youroverrideslast — so{...getInputProps({ className: "my-input", placeholder: "Email" })}wins over the defaults.
getErrorProps() returns { id: errorId, role: "alert", "aria-live": "polite" }. Putting it on the error node makes screen readers announce the message and matches the aria-describedby the input points at.
Pattern 2 — Checkbox
import { useFormControl, type FormControlProps } from "@mongez/react-form";
export default function Checkbox(props: FormControlProps) { const { checked, setChecked, id, error } = useFormControl({ ...props, type: "checkbox", // MUST be explicit });
return ( <> <input id={id} type="checkbox" checked={checked} onChange={(e) => setChecked(e.target.checked)} /> {error && <span className="error">{error}</span>} </> );}For checkboxes, use checked / setChecked, not value / changeValue. The type: "checkbox" setting changes how the value is collected:
- Checked →
valueprop is emitted (ortrueif novalueprop). - Unchecked → emits
uncheckedValue(from the second argument ofuseFormControl) ifcollectUncheckedis true, otherwise the field is omitted entirely.
const { checked, setChecked } = useFormControl(props, { uncheckedValue: 0, // emit 0 when unchecked collectUnchecked: true,});Pattern 3 — Multi-value control (multi-select, tag input)
Pass multiple: true in the options to declare a multi-value control:
const { value, changeValue } = useFormControl(props, { multiple: true });// value is always an arrayMulti-value controls are collected as arrays in form.values() even when they hold a single item.
Pattern 4 — Radio (use useRadioInput instead)
For radios, don’t use useFormControl per radio button. Build a RadioGroup (one useFormControl call) that provides RadioGroupContext, and have each RadioInput consume it via useRadioInput(value). See the react-native-usage and validation-rules skills for the full pattern.
Pattern 5 — Custom per-instance validation
Pass a validate callback in props (NOT in rules) for one-off validation that should only apply to this instance:
<TextInput name="username" validate={({ value }) => { if (!/^[a-zA-Z0-9]+$/.test(value)) return "Username must be alphanumeric"; }}/>The callback receives the full InputRuleOptions object and may return a ReactNode (error) or a Promise<ReactNode> (async — blocks other rules until resolved). Returning nothing means valid.
Pattern 5b — Showing an async-validation spinner (isValidating, v4)
When a rule (or the per-instance validate) returns a Promise, the hook flips isValidating to true until it resolves, and stale results from superseded runs are discarded. Use it to render a pending indicator without tracking state yourself:
export default function UsernameInput(props: FormControlProps) { const { error, isValidating, getInputProps, getErrorProps } = useFormControl(props);
return ( <> <input {...getInputProps()} /> {isValidating && <span className="spinner" aria-hidden />} {error && <span {...getErrorProps()}>{error}</span>} </> );}isValidating is also exposed on the underlying formControl.isValidating. Sync rules never flip it — the hook keeps a synchronous fast path and only goes async when a rule actually returns a Promise.
Pattern 5c — Per-control validateOn (v4)
A control can declare when it validates via the validateOn prop: "change" (default), "blur", or "submit". Per-control beats the <Form validateOn> prop, which beats the global setFormConfigurations({ validateOn }), which falls back to "change".
<EmailInput name="email" type="email" required validateOn="blur" />The hook returns an onBlur handler that you must wire for "blur" mode (already included in getInputProps()):
const { value, changeValue, onBlur, error } = useFormControl(props);<input value={value} onChange={(e) => changeValue(e.target.value)} onBlur={onBlur} />;For "blur" and "submit" modes, once a field has errored (or the form was submitted), it revalidates on every change so a cleared error disappears live. validateOn is destructured out of otherProps, so it never reaches the DOM.
Pattern 5d — Per-field Standard Schema (v4)
Validate a single control with a Standard Schema (zod / valibot / @warlock.js/seal) via the schema prop or the schema option. It’s wrapped as a rule and appended last in the pipeline:
import { v } from "@warlock.js/seal";
<TextInput name="email" schema={v.string().email()} />;// or bake it into a reusable wrapper through the options bag:const { value } = useFormControl(props, { schema: v.string().min(3) });The first schema issue’s message becomes the rendered error. See the standard-schema-validation skill for whole-form schemas and type inference. Like validateOn, the schema prop is destructured out of otherProps.
Pattern 6 — Showing per-rule errors
When useFormControl(props, { validateAll: true }) is set, error becomes an array of all failing-rule messages and errorsList[ruleName] exposes each individually:
const { errorsList } = useFormControl({ ...props, rules: [requiredRule, minLengthRule],}, { validateAll: true });
return ( <> {errorsList.required && <p>{errorsList.required}</p>} {errorsList.minLength && <p>{errorsList.minLength}</p>} </>);Pattern 7 — Hidden input (no UI)
Use the built-in HiddenInput component for values that should be collected but not rendered:
import { HiddenInput } from "@mongez/react-form";
<HiddenInput name="csrfToken" value={token} />It calls useFormControl and returns null. The value still appears in form.values().
Default id generation
If no id prop is provided, the hook derives one from name: input-<sanitized-name> (dots become dashes, non-alphanumerics stripped). This is deliberate — it makes the input’s id predictable for <label htmlFor> association. The derivation is the exported useControlId({ id?, name }) hook — call it yourself if you need the same id outside useFormControl. Renamed from useId in v4 so importing it no longer shadows React 18’s own useId.
Recap checklist before committing a new input component
-
useFormControlcalled with props (andtypeexplicitly set when not “text”). - Prefer
getInputProps()+getErrorProps()(v4) for a free a11y wiring; otherwise spreadotherProps(not rawprops) and wireinputRef,onBlur, and the ARIA attributes yourself. -
errorrendered conditionally; the error node carriesgetErrorProps()(or at leastid={errorId}) soaria-describedbyresolves. - For checkbox/radio:
checked/setCheckedused, notvalue/changeValue. -
nameprop is required and must be provided by the consumer.