Skip to content
Back to skills

Tanstack Form Composition

ASecurity

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

  • 80 stars
  • 0 votes
  • 0 copies
  • 3 views
  • Added August 31, 2026
ai-agentsgoreactvueangulargitapi

Works with

  • api

Security analysis

A100/100

Scanned August 31, 2026

npx -y skills add suxrobGM/jobpilot --skill tanstack-form-composition --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Tanstack Form Composition?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Tanstack Form Composition
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/suxrobgm-tanstack-form-composition/badge)](https://www.skillsdirectory.com/skills/suxrobgm-tanstack-form-composition)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
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:

```ts
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):

```ts
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.

```tsx
// thin wrapper over a presentational base component
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:

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

```ts
import { createFormHook } from "@tanstack/react-form";
import { fieldContext, formContext } from "./form-context";

// import bound field components + SubmitButton

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.

```tsx
// before
<FormTextField form={formApi} name="email" label="Email" type="email" />
// after
<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.

```tsx
export const PersonalSection = withForm({
  defaultValues: DEFAULTS, // type-only; must match the parent form's data type
  props: { title: "" }, // optional extra props with defaults
  render: function PersonalSection({ form, title }) {
    return (
      <form.AppField name="firstName">
        {(field) => <field.TextField label="First name" />}
      </form.AppField>
    );
  },
});
// consumed by the parent, passing its typed useAppForm instance:
<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:

```ts
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` ``):

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

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.