Recipes
Short, self-contained patterns for the situations every real form eventually hits. Each one is a condensed version of a working demo in the repo's examples/ app, so run npm run examples to see them live.
Server errors on submit
Map a failed request onto fields with setError; the server channel keeps the message alive through background validation and releases it when the user edits the field.
const onSubmit = form.handleSubmit(async (data) => {
const res = await api.createUser(data);
if (!res.ok) {
// e.g. { username: "already taken" } — addErrors takes the server's
// runtime-keyed map as-is, no per-path casts
form.addErrors(
Object.fromEntries(
Object.entries(res.fieldErrors).map(([path, message]) => [
path,
[message],
]),
),
);
// On a multi-form page, pass your <form> element (e.g. via a ref) so the
// search, including the root-error fallback, stays inside this form:
// focusFirstError(form.getState().errors, formRef.current ?? undefined)
focusFirstError(form.getState().errors);
}
});Autosave a draft
Persist values on a debounce with watchValues; restore them as initialValues on mount so dirtiness is measured against the draft.
useEffect(() => {
const timer: { current: ReturnType<typeof setTimeout> | null } = {
current: null,
};
const unsub = form.watchValues((next) => {
if (timer.current !== null) clearTimeout(timer.current);
timer.current = setTimeout(
() => localStorage.setItem(KEY, JSON.stringify(next)),
800,
);
});
return () => {
if (timer.current !== null) clearTimeout(timer.current);
unsub();
};
}, [form]);form.dirtyFields() tells you what changed since the restored draft, and form.diff() is the matching PATCH payload.
Multi-step wizard
useFormSteps is this recipe as a hook — per-step gating, touched-marking on a refused advance, land-on-first-invalid jumps, live per-step status. See Multi-step forms. The hand-rolled core, when you want to own the navigation state yourself:
const STEP_FIELDS = [
["name", "email"],
["address.street", "address.city"],
["plan", "terms"],
] as const;
const next = async () => {
const result = await form.validateFieldsAsync(STEP_FIELDS[step]);
if (result.kind === "valid") setStep((s) => s + 1);
};Masked and formatted inputs
Phone numbers, currency, percentages — anything with a display format — hit the same wall number inputs do: a naive controlled input re-renders the canonical format on every keystroke and eats the separators mid-entry. useMaskedInput is useNumberInput's raw-text pattern for any parsed/formatted value: partial entries stay visible without touching the form, complete entries push immediately, blur snaps to the canonical format, and an external write (reset, adoptValues) wins over local text.
import { useMaskedInput, type MaskedParse } from "formstand";
const parsePhone = (text: string): MaskedParse<string> => {
const digits = text.replace(/\D/g, "");
if (text.trim() === "") return { kind: "empty" };
if (digits.length === 10) return { kind: "value", value: digits };
return { kind: "invalid" }; // partial — kept as local text
};
const formatPhone = (d: string) => `(${d.slice(0, 3)}) ${d.slice(3, 6)}-${d.slice(6)}`;
const phone = useField(form, "phone");
const input = useMaskedInput(phone, { parse: parsePhone, format: formatPhone });
<input type="tel" inputMode="tel" {...input} />The form always holds the PARSED value (the ten digits, the number of cents), never the display text, so the schema validates real data. "empty" writes the field's emptyValue, the same schema-aware blank every clearing binding uses.
Optimistic update with rollback
snapshot() before the request, restore() on failure, server errors and all.
const save = async () => {
const snap = form.snapshot();
render(optimisticallyFrom(form.getState().values));
const res = await api.save(form.getState().values);
if (!res.ok) form.restore(snap);
};Dependent and derived fields
React to one field from another with watchValue, or compute a derived value in a selector so it's never stored at all.
// Clear the state field whenever the country changes:
useEffect(
() =>
form.watchValue("country", () => form.setValue("state", "")),
[form],
);
// Derived value, always consistent, nothing to sync:
const total = useFormSelector(form, (s) =>
s.values.items.reduce((sum, i) => sum + i.qty * i.price, 0),
);Cross-field rules that blame one field
A cross-field rule reads several fields but should surface on just one of them. Give an object-level .superRefine (or .refine) a path and the message lands on that field's error channel, where useField picks it up like any schema error:
const MAX_GROSS_WEIGHT = { C172: 2450, PA28: 2550 } as const;
const schema = z
.object({
aircraftType: z.enum(["C172", "PA28"]),
grossWeight: z.number(),
})
.superRefine((data, ctx) => {
const max = MAX_GROSS_WEIGHT[data.aircraftType];
if (data.grossWeight > max) {
ctx.addIssue({
code: "custom",
path: ["grossWeight"], // blame this field, not the form root
message: `max gross weight for a ${data.aircraftType} is ${max} lb`,
});
}
});
const grossWeight = useField(form, "grossWeight");
// grossWeight.error → ["max gross weight for a C172 is 2450 lb"]Field-scoped validation keeps the rule live on that field: blur or change on grossWeight re-runs it, because a refinement on a traversed level makes validateField fall back to a full parse and scope the resulting errors to the field, so the cross-field message appears and clears exactly like a single-field one. (A .refine without a path lands at the root "" key instead; see Root errors.)
Sharing a form without prop drilling
createFormContext gives you a typed provider and hook pair, and paths stay schema-checked through the context.
const { Provider, useFormContext } = createFormContext<typeof schema>();
const Parent = () => {
const form = useForm(schema, { initialValues });
return (
<Provider form={form}>
<DeeplyNestedField />
</Provider>
);
};
const DeeplyNestedField = () => {
const form = useFormContext();
const email = useField(form, "email"); // still typed
return <input {...textInputProps(email)} />;
};One store, many forms
The pattern that motivated the pathDepth option: one module-level store backs several forms, each editing a namespaced slice, so leaf paths pick up the namespace segments and can sit past the default 9-segment typed-path budget. Widen the budget once, at createForm, and export per-slice hooks with createFormHooks:
// appStore.ts: one schema, namespaced per feature
const appSchema = z.object({
settings: z.object({
profile: z.object({
contact: z.object({
address: z.object({
geo: z.object({
coords: z.object({
lat: z.object({ value: z.number(), precision: z.number() }),
}),
}),
}),
}),
}),
}),
billing: z.object({ plan: z.enum(["free", "pro"]) }),
});
// "settings.profile.contact.address.geo.coords.lat.value" is 8 segments
// here: one more wrapper and the default budget of 9 runs out. Widen once:
export const appForm = createForm(appSchema, {
initialValues,
pathDepth: 12, // one literal in 0–25; `number` variables and unions error
});
// Per-slice hook APIs, all sharing the one store. D rides along, so the
// deep paths stay fully typed in every bound hook.
export const { useSettingsField, useSettingsIsDirty } =
createFormHooks(appForm, "settings");
export const { useBillingField } = createFormHooks(appForm, "billing");// A slice component binds through its own hooks, with no form prop anywhere.
const LatitudeField = () => {
const lat = useSettingsField(
"settings.profile.contact.address.geo.coords.lat.value",
);
return <input {...numberInputProps(lat)} />;
};Two wrinkles to know about:
- The budget is part of the form's type.
Form<typeof appSchema, 12>is deliberately not assignable toForm<typeof appSchema>(or vice versa), so any prop or helper that takes this form must sayForm<typeof appSchema, 12>. - Context can't infer it.
createFormContexttakes no value argument, so a widened form's context names the budget explicitly:createFormContext<typeof appSchema, 12>(). Forgetting it produces aForm<S, 12> is not assignable to Form<S, 9>error at the<Provider form={...}>site, so the mismatch is caught, never silently widened. The explicitDposition enforces the same 0–25 constraint as the option:createFormContext<S, 26>()or a widenednumberargument is a compile error.
Focus a field imperatively
focusField(path, root?) is focusFirstError's path-keyed sibling (and the equivalent of react-hook-form's setFocus): it focuses the first control in DOM order whose name is the path or a descendant of it, with the same focusability rules. The classic uses are landing focus after appending an array row, or when a dialog opens:
import { focusField } from "formstand";
const addUser = () => {
const index = users.length;
users.push({ email: "" });
// The new row's input doesn't exist until React commits, so focus after paint.
requestAnimationFrame(() => focusField(`users.${index}.email`));
};A container path works too: focusField("address") lands on the first rendered address.* control. A path that matches no named control falls back to the element whose id is exactly the path, which is how name-less composite widgets like Ant Design's Select with id={path} stay reachable (exact id match only). The root "" path means whole-form scope, so focusField("", formEl) focuses the form's first focusable control. Pass your <form> element as root on multi-form pages, exactly like focusFirstError; with the default document scope and several forms, focusField("") refuses to guess and returns false.
Rebase after save
After a successful save, the just-saved values become the new baseline, and adoptValues swaps values and initialValues without wiping interaction state, so the form reads clean while touched and submitCount survive.
await api.save(form.getState().values);
form.adoptValues(form.getState().values);
// useIsDirty() is now false; touched and submitCount are preserved.Use reset() instead when you want a full wipe.