Skip to content

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 raw props) onto the host <input>. otherProps excludes hook-internal props (name, rules, errors, onChange, value, defaultValue, validateOn, schema, etc.) and any props the active rules declared as preservedProps (e.g. minLength, pattern). In v4 the validateOn and schema props are destructured out, so they never leak onto the DOM element.
  • Always wire inputRef if you want formControl.focus() / formControl.blur() to work.
  • The hook is controlled internally — value from the hook is always the source of truth, regardless of whether the user passed a value prop or not.

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 (the inputRef), disabled
  • For text-like inputs: value (coerced to "" when nullish) and an onChange that unwraps e.target.value.
  • For type="checkbox" / type="radio": checked and an onChange that unwraps e.target.checked instead.
  • onBlur (marks touched + runs validateOn="blur").
  • aria-invalid (when touched and failing), aria-required (from the required prop), and aria-describedby (set to errorId only while an error is showing).
  • Then spreads otherProps, then your overrides last — 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 → value prop is emitted (or true if no value prop).
  • Unchecked → emits uncheckedValue (from the second argument of useFormControl) if collectUnchecked is 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 array

Multi-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

  • useFormControl called with props (and type explicitly set when not “text”).
  • Prefer getInputProps() + getErrorProps() (v4) for a free a11y wiring; otherwise spread otherProps (not raw props) and wire inputRef, onBlur, and the ARIA attributes yourself.
  • error rendered conditionally; the error node carries getErrorProps() (or at least id={errorId}) so aria-describedby resolves.
  • For checkbox/radio: checked / setChecked used, not value / changeValue.
  • name prop is required and must be provided by the consumer.