Receber por email
← Voltar para o catálogo

tanstack-form-composition

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

34estrelas
Atualizado há 3 meses

Ver no GitHub ↗ Licença: MIT

Como adicionar

/plugin marketplace add suxrobGM/jobpilot

O comando exato pode variar conforme o repositório. Confira o README no GitHub.

Para o autor da skill

Cole no README do seu repo

Mostra que sua skill está catalogada na Skillteca, gera backlink e tráfego rastreável.

[![Skillteca](https://www.skillteca.com.br/api/badge/tanstack-form-composition/svg)](https://www.skillteca.com.br/skills/tanstack-form-composition)

Alerta por categoria

Receba novas skills de Design e Frontend toda segunda

1 email curto, só com as skills novas de Design e Frontend. 4 minutos de leitura, sem spam, cancela com 1 clique.

Você confirma o email no primeiro envio. Sem spam. Cancela com 1 clique.

Ler descrição completa

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.

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

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";

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

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

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:

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} 
Compartilhar X LinkedIn