| name | tanstack-form |
| description | Create or modify forms using TanStack Form's composition API. Uses formOptions() as the form shape primitive, withForm HOC for decomposing large forms into sections, withFieldGroup for reusable field groups across forms, Zod for validation, and i18n-prefixed error messages. Use when creating or modifying forms in the web app. |
TanStack Form v2
For initial setup see setup.md. For withForm, withFieldGroup, and extendForm see composition.md. For array fields see arrays.md.
Rules
- Define form shape with
formOptions() — share it into useAppForm and any withForm sub-components via spread
- Schemas live in the form file (or a sibling
schema.ts), never inside field components
- Field components are dumb UI wrappers — they own no schema knowledge and no defaults
onSubmit only calls mutation.mutate — side effects go in useMutation callbacks
- Wrap
form.SubmitButton in form.AppForm
- Zod error messages: prefix with
"i18n:" if project uses i18n (check package.json for react-i18next)
- Forms with 3+ distinct sections → split with
withForm HOC
- Field groups reused across multiple forms →
withFieldGroup HOC
render in withForm/withFieldGroup must be a named function, not an arrow function (ESLint hooks rule)
Form Shape
formOptions() is the canonical primitive. Define it once; spread into useAppForm (and into withForm sub-components — see composition.md).
import { formOptions } from "@tanstack/react-form";
import { z } from "zod";
const schema = z.object({
name: z.string().min(1, "i18n:validation.required"),
email: z.string().email("i18n:validation.email"),
});
const opts = formOptions({
defaultValues: { name: "", email: "" },
});
const mutation = useMutation({
mutationFn: myServerFn,
onSuccess: () => { },
});
const form = useAppForm({
...opts,
validators: { onSubmit: schema },
onSubmit: ({ value }) => mutation.mutate({ data: value }),
});
<form onSubmit={(e) => { e.preventDefault(); e.stopPropagation(); form.handleSubmit(); }}>
<form.AppField name="name">{(field) => <field.InputField label="Name" />}</form.AppField>
<form.AppField name="email">{(field) => <field.InputField label="Email" type="email" />}</form.AppField>
{mutation.isError && <p className="text-sm text-destructive">Something went wrong.</p>}
<form.AppForm>
<form.SubmitButton isLoading={mutation.isPending}>Submit</form.SubmitButton>
</form.AppForm>
</form>
Adding a Field
Field components are pure UI. They read from context, render the input, show errors. They know nothing about schemas or defaults.
1. Create <name>-field.tsx — import useFieldContext from form-context, not form-setup
import { useFieldContext } from "../form-context";
import { useFormatError } from "src/lib/use-format-error";
export function MyField({ label, ...props }: { label: string } & Omit<ComponentProps<typeof UiComp>, "value" | "onChange" | "onBlur">) {
const field = useFieldContext<string>();
const formatError = useFormatError();
const error = field.state.meta.isValid ? undefined : formatError(field.state.meta.errors);
return (
<FormField label={label} error={error}>
<UiComp {...props} id={field.name} value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)} onBlur={field.handleBlur} aria-invalid={!!error || undefined} />
</FormField>
);
}
2. Register in form-setup.ts fieldComponents
3. Use at call site — all UI props go here, never inside the field component
<form.AppField name="x">{(field) => <field.MyField label="X" placeholder="…" />}</form.AppField>
Common Mistakes
| Mistake | Fix |
|---|
| Schema or defaults inside a field component | Move to the form file |
const defaultValues = {} outside useAppForm | Use formOptions() |
Duplicating defaultValues in parent and withForm | Spread opts in both |
Server call in onSubmit | useMutation, call mutation.mutate in onSubmit |
SubmitButton unwrapped | Wrap in form.AppForm |
Field imports useFieldContext from form-setup | Import from form-context (avoids circular dep) |
Arrow fn in withForm/withFieldGroup render | Named fn: render: function Render({…}) {} |
Missing stopPropagation | Add both preventDefault + stopPropagation |