Capítulo 89 de 108

TanStack Form

Core Idea

Padrão de integração de TanStack Form + Zod com Field/FieldGroup/FieldSet, usando form.Field (render-prop / children) em vez do <Controller /> do React Hook Form. API headless, validação declarada via validators no useForm.

Key Concepts

  • useForm({ defaultValues, validators: { onSubmit: schema }, onSubmit }): cria a instância; onSubmit recebe { value } já validado.
  • <form.Field name="x" children={(field) => ...} />: equivalente ao <Controller /> do RHF; expõe field.state.value, field.state.meta.isTouched, field.state.meta.isValid, field.state.meta.errors, field.handleChange, field.handleBlur.
  • isInvalid = field.state.meta.isTouched && !field.state.meta.isValid: padrão consistente para decidir quando mostrar erro (diferente do RHF, que usa fieldState.invalid direto).
  • form.handleSubmit() dentro de onSubmit={(e) => { e.preventDefault(); form.handleSubmit() }}: TanStack Form não integra automaticamente com o evento nativo do <form>, é chamada manual.
  • mode="array": em form.Field, habilita array fields; expõe field.state.value (array), field.pushValue(item), field.removeValue(index).
  • Nested field path: acessa item de array com colchetes: name={emails[${index}].address} (diferente do RHF que usa ponto: emails.${index}.address).

Code Examples

const form = useForm({
  defaultValues: { title: "", description: "" },
  validators: { onSubmit: formSchema },
  onSubmit: async ({ value }) => { /* ... */ },
})

<form onSubmit={(e) => { e.preventDefault(); form.handleSubmit() }}>
  <form.Field
    name="title"
    children={(field) => {
      const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid
      return (
        <Field data-invalid={isInvalid}>
          <FieldLabel htmlFor={field.name}>Bug Title</FieldLabel>
          <Input
            id={field.name}
            name={field.name}
            value={field.state.value}
            onBlur={field.handleBlur}
            onChange={(e) => field.handleChange(e.target.value)}
            aria-invalid={isInvalid}
          />
          {isInvalid && <FieldError errors={field.state.meta.errors} />}
        </Field>
      )
    }}
  />
</form>
  • O que demonstra: Anatomia completa do padrão form.Field, incluindo o isInvalid calculado a partir de isTouched/isValid (não existe um .invalid pronto como no RHF).
<form.Field name="emails" mode="array">
  {(field) => (
    <FieldSet>
      {field.state.value.map((_, index) => (
        <form.Field
          key={index}
          name={`emails[${index}].address`}
          children={(subField) => (/* Field com subField.state.value/handleChange */)}
        />
      ))}
      <Button onClick={() => field.pushValue({ address: "" })} disabled={field.state.value.length >= 5}>
        Add Email Address
      </Button>
    </FieldSet>
  )}
</form.Field>
  • O que demonstra: mode="array" + pushValue/removeValue como equivalente do useFieldArray do RHF, sem hook separado.

Reference Tables

Validation ModeDescrição
onChangeValida a cada mudança
onBlurValida ao perder foco
onSubmitValida no submit

Configurado via validators: { onSubmit: schema, onChange: schema, onBlur: schema } (pode combinar múltiplos).

Anti-patterns

  • Usar index como key em vez de estabilizar por item: a doc usa key={index} aqui (diferente do RHF que exige field.id), pois TanStack Form não gera IDs estáveis — atenção redobrada se a lista permitir reordenação (index como key pode causar bugs de estado em reorder, mesmo que a doc oficial use assim para add/remove simples).
  • Esquecer e.preventDefault() + form.handleSubmit() manual: sem isso o form não submete (TanStack Form não se liga automaticamente ao evento submit nativo).

Key Takeaways

  1. form.Field com children/render-prop substitui o <Controller /> do RHF; a "forma" de pensar (campo controlado via field.state.value/handleChange) é a mesma ideia.
  2. Erro só deve aparecer depois de isTouched, ao contrário do padrão RHF que usa fieldState.invalid diretamente (RHF já considera modo de validação internamente).
  3. Array fields usam mode="array" + pushValue/removeValue na própria API do form.Field, sem hook adicional.
  4. Path de campo aninhado usa colchetes (emails[0].address), não ponto.

Connects To

  • forms (ch087): índice geral.
  • forms/react-hook-form (ch088): mesmo objetivo, API Controller-based em vez de render-prop.
  • forms/formisch (ch090): terceira alternativa, baseada em signals.