| name | tanstack-form-composition |
| description | Migrate a React @tanstack/react-form codebase from the prop-drilled `useForm` + erased-form-type pattern to the official `createFormHook` composition API (`useAppForm` / `withForm` / `field.X`). Use when a project threads a `form` object (often cast to an `any`-erased type like `ReactFormExtendedApi<any,...>`) through field-wrapper components that take `form`+`name` props, and you want typed field names/values, no casts, and reusable bound field components. Triggers: "migrate forms to createFormHook", "adopt useAppForm/withForm", "remove AnyReactForm cast", "type-safe tanstack form fields". |
TanStack Form ā composition API migration
Convert a React project using @tanstack/react-form from raw useForm +
prop-drilled field wrappers + an any-erased form type to the createFormHook
composition API. The payoff: typed field names/values, deletion of the erased
form type and every as unknown as <ErasedForm> cast, and bound field components
consumed as field.TextField instead of <FieldWrapper form={form} name=⦠/>.
This is for the React adapter. Solid/Vue/Angular have the same API shape with
different hook names ā the concepts below port directly.
When this applies
The codebase has most of these symptoms:
- An erased form type alias, e.g.
type AnyForm = ReactFormExtendedApi<any,any,ā¦>
(12 any generics), used as a prop type and reached via form as unknown as AnyForm.
- Field components that accept
form + name props and internally render
<form.Field name={name}>ā¦</form.Field>.
- Shared "section" components that receive the whole
form as a prop and are
reused across multiple parent forms (e.g. a settings form and an onboarding wizard).
- Helper functions that take the
form and call getFieldValue/setFieldValue.
If the project does not yet use @tanstack/react-form, stop ā this skill
migrates an existing TanStack Form codebase; it does not introduce the library.
Method
Work in four phases. Do recon fully before editing; build the shared infra once;
then migrate call sites; then delete the erased type and verify. On a large repo,
migrate one simple form end-to-end first as a reference, then fan out.
Phase 0 ā Recon
Confirm the version and map the blast radius. Run (adapt paths):
- Dependency: grep
package.json for @tanstack/react-form (need v1+; createFormHook,
createFormHookContexts, withForm are stable in v1).
- Form-creation sites: search
useForm(.
- The erased type + casts: search the alias name and
as unknown as.
- Existing field wrappers: the directory of components taking
form/name props.
- Shared sections: components whose props include the erased form type.
- Form-consuming helpers: functions whose parameter is the erased form type.
Record every hit; these are your edit targets. Note which field wrappers are
actually used (unused ones still get converted for library consistency, but have
no call sites to update).
Phase 1 ā Build the composition infra (once per form tree)
Create these alongside the existing field components (e.g. components/ui/form/).
form-context.ts ā shared contexts, isolated to avoid a circular import with
the hook:
import { createFormHookContexts } from "@tanstack/react-form";
export const { fieldContext, formContext, useFieldContext, useFormContext } =
createFormHookContexts();
error-message.ts ā one extractor (field meta.errors may hold Zod issue
objects, strings, or thrown values):
export function firstErrorMessage(errors: ReadonlyArray<unknown>): string | undefined {
const first = errors[0];
if (!first) return undefined;
if (typeof first === "string") return first;
return (first as { message?: string }).message ?? String(first);
}
Bound field components ā each reads useFieldContext<T>() instead of taking
form/name. useFieldContext<T>() is a local cast: it does not enforce that
T matches the actual field, so pick the widest value the component handles.
export function TextField(props: TextFieldProps): ReactElement {
const field = useFieldContext<string | number | null | undefined>();
return (
<BaseTextField
value={field.state.value ?? ""}
onChange={(e) => field.handleChange(/* coerce per type */ e.target.value)}
onBlur={field.handleBlur}
errorText={firstErrorMessage(field.state.meta.errors)}
{...props}
/>
);
}
Form-level SubmitButton ā subscribes to submit state:
export function SubmitButton(props: SubmitButtonProps): ReactElement {
const { children, disabled, ...rest } = props;
const form = useFormContext();
return (
<form.Subscribe selector={(s) => [s.canSubmit, s.isSubmitting] as const}>
{([canSubmit, isSubmitting]) => (
<Button type="submit" disabled={disabled || !canSubmit || isSubmitting} {...rest}>
{children}
</Button>
)}
</form.Subscribe>
);
}
Keep type="submit" and let the enclosing <form onSubmit> drive submission;
expose disabled so callers fold in external state (e.g. a pending mutation that
the form's own isSubmitting doesn't observe).
index.ts ā wire the hook. Name the bound components so the registry keys are
shorthand:
import { createFormHook } from "@tanstack/react-form";
import { fieldContext, formContext } from "./form-context";
export const { useAppForm, withForm } = createFormHook({
fieldContext,
formContext,
fieldComponents: { TextField, Select, Phone, Switch },
formComponents: { SubmitButton },
});
Phase 2 ā Migrate call sites
Form creation: useForm({ ⦠}) ā useAppForm({ ⦠}). Same options object
(defaultValues, validators, onSubmit). Delete the const formApi = form as unknown as AnyForm line.
Fields: prop-drilled ā composition render prop.
<FormTextField form={formApi} name="email" label="Email" type="email" />
<form.AppField name="email">
{(field) => <field.TextField label="Email" type="email" />}
</form.AppField>
form.AppField type-checks name against the form's data shape and types
field.state.value / field.handleChange. One-off custom controls also work
inside AppField via field.state / field.handleChange (the field arg carries
both the API and the bound components).
Submit: wrap in form.AppForm and use form.SubmitButton where it fits
(simple dialogs). Forms with bespoke submit UX (steppers, mode-dependent labels)
keep a custom button but still benefit from the typed form.
Shared sections ā withForm. This is usually the biggest cleanup: it removes
the erased-type threading while keeping types across the boundary.
export const PersonalSection = withForm({
defaultValues: DEFAULTS,
props: { title: "" },
render: function PersonalSection({ form, title }) {
return (
<form.AppField name="firstName">
{(field) => <field.TextField label="First name" />}
</form.AppField>
);
},
});
<PersonalSection form={form} title="ā¦" />
defaultValues is for type inference only (not runtime). It must match the
consuming useAppForm's data type ā reuse the project's shared DEFAULTS constant.
withForm calls render during its own component's render, so hooks inside
render are safe (state, queries, effects). Name the render function
PascalCase so lint treats it as a component.
- A child that only reads state (a validation summary, a derived banner) is also a
clean
withForm.
Helper functions that take the form: do not reintroduce any. Define a
minimal structural interface with the literal field-name union it touches ā the
typed form is assignable to it:
type ProfileTextField = "firstName" | "lastName" | "email" | ;
interface ProfileFieldWriter {
getFieldValue: (name: ProfileTextField) => unknown;
setFieldValue: (name: ProfileTextField, value: string) => void;
}
export function applyToForm(form: ProfileFieldWriter, data: ā¦): void { }
Array fields: mode="array" plus bracket paths (type-check fine ā a template
literal with a number variable widens to `items[${number}].field`):
<form.AppField name="items" mode="array">
{(field) => field.state.value.map((_, i) => (
<form.AppField key={i} name={`items[${i}].label`}>
{(sub) => <sub.TextField label="Label" />}
</form.AppField>
))}
</form.AppField>
Phase 3 ā Cleanup + verify
- Delete the erased form type file/alias; confirm zero references remain.
- Optionally drop redundant prefixes on bound components (they're consumed as
field.TextField, so a Form prefix is noise) and rename files to match.
- Run the project's typecheck, then lint, then formatter, and fix
fallout. The most common new errors are real bugs the
any erasure hid ā
especially number text fields whose handleChange(null)/Number(x) now meets a
typed field. Resolve by widening the bound component's useFieldContext union
(string | number | null | undefined) rather than re-adding any.
Key decisions & gotchas
- Presentational base vs. thin wrapper ā how deep? Two layers (a controlled,
form-agnostic base component + a thin context wrapper) is cleanest, and lets the
base be reused outside forms. But the base layer earns its keep mainly when the
UI library needs heavy per-instance wrapping (e.g. react-native-paper:
outline radii, helper-text layout, labeled rows). When components are already
"complete" and themed globally (e.g. MUI), a base for trivial controls
(checkbox/switch/toggle/select) is just indirection ā split only the substantive
ones (text/currency/multiselect) and keep the rest as direct thin bindings.
- Naming collisions. Bound components named
TextField / Select / Switch
shadow the UI library's exports. Alias the import in that file
(import { TextField as MuiTextField } from "ā¦", or the base as BaseTextField).
useFieldContext<T>() is a cast, not a constraint. It won't error if T
disagrees with the actual field; choose the widest value the component renders.
- Don't force every form into
SubmitButton. Steppers and multi-action footers
legitimately keep custom buttons; the typed form is the real win.
- Migrate incrementally. The new infra can coexist with old prop-drilled
wrappers, so you can convert one form at a time and keep the project compiling.
Definition of done
Typecheck, lint, and formatter all pass; the erased form type is gone; every
useForm is now useAppForm; field components are consumed via form.AppField /
field.X; shared sections and form-consuming helpers are typed (via withForm and
structural accessors) with no any.