Skip to content

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.

tsx
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.

tsx
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:

tsx
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.

tsx
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.

tsx
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.

tsx
// 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:

tsx
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.

tsx
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:

ts
// 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");
tsx
// 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 to Form<typeof appSchema> (or vice versa), so any prop or helper that takes this form must say Form<typeof appSchema, 12>.
  • Context can't infer it. createFormContext takes no value argument, so a widened form's context names the budget explicitly: createFormContext<typeof appSchema, 12>(). Forgetting it produces a Form<S, 12> is not assignable to Form<S, 9> error at the <Provider form={...}> site, so the mismatch is caught, never silently widened. The explicit D position enforces the same 0–25 constraint as the option: createFormContext<S, 26>() or a widened number argument 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:

tsx
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.

tsx
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.

Built on zod and zustand. Released under the MIT License.