---
name: unity-tanstack-form
description: >
  Load when building or migrating Unity forms. Use useTanstackUnityForm with
  schema validation and prefer Tanstack Form field APIs for composed fields,
  custom layouts, and validation behavior.
type: core
library: '@payfit/unity-components'
library_version: '2.x'
sources:
  - 'PayFit/hr-apps:libs/shared/unity/components/src/hooks/use-tanstack-form.tsx'
  - 'PayFit/hr-apps:libs/shared/unity/components/src/hooks/use-form.tsx'
  - 'PayFit/hr-apps:libs/shared/unity/components/src/components/form/TanstackForm.tsx'
  - 'PayFit/hr-apps:libs/shared/unity/components/src/components/form-field/TanstackFormField.tsx'
  - 'PayFit/hr-apps:libs/shared/unity/components/src/adapters/zodAdapter.ts'
  - 'PayFit/hr-apps:libs/shared/unity/components/src/utils/field-revalidate-logic.ts'
  - 'PayFit/hr-apps:libs/shared/unity/components/src/docs/concepts/forms/Form Architecture Overview.mdx'
---

Build a Unity form with `useTanstackUnityForm` + Zod. RHF (`useUnityForm`) is deprecated and will be removed.

## Setup

```tsx
import { Button, useTanstackUnityForm } from '@payfit/unity-components'
import { z } from 'zod'

const schema = z.object({
  email: z.email({ message: 'Invalid email' }),
  password: z.string().min(8, { message: 'Password too short' }),
})

export function SignInForm() {
  const form = useTanstackUnityForm({
    defaultValues: { email: '', password: '' },
    validators: { onBlur: schema },
    onSubmit: async ({ value }) => {
      await signIn(value)
    },
  })

  return (
    <form.AppForm>
      <form.Form className="uy:space-y-200">
        <form.AppField name="email">
          {field => <field.TextField label="Email" type="email" />}
        </form.AppField>
        <form.AppField name="password">
          {field => <field.PasswordField label="Password" />}
        </form.AppField>
        <Button variant="primary" type="submit">
          Sign in
        </Button>
      </form.Form>
    </form.AppForm>
  )
}
```

Wrapping order is load-bearing: `form.AppForm` provides the form context, `form.Form` renders the `<form>` element and wires `handleSubmit`, `form.AppField` provides field context, and the render-prop `field` carries every bound field component (TextField, PasswordField, SelectField, …) plus the Atomic parts (Field, FieldLabel, TextInput, FieldFeedbackText, FieldHelperText, FieldRawContextualLink).

## Core Patterns

### Composed API (default)

Drop a single bound field component inside `<form.AppField>`. It bundles label, input, helper text, feedback, and a11y wiring.

```tsx
<form.AppField name="firstName">
  {field => (
    <field.TextField
      label="First name"
      helperText="As it appears on your ID"
      isRequired
    />
  )}
</form.AppField>

<form.AppField name="country">
  {field => (
    <field.SelectField
      label="Country"
      options={[
        { value: 'fr', label: 'France' },
        { value: 'es', label: 'Spain' },
      ]}
    />
  )}
</form.AppField>
```

### Atomic API (only when customizing layout/parts)

Reach for Atomic when you need to interleave custom content between the label and the input, or swap an input for a non-standard control. Wraps every part in `field.Field`.

```tsx
<form.AppField name="password">
  {field => (
    <field.Field>
      <field.FieldLabel isRequired>Password</field.FieldLabel>
      <field.FieldHelperText>Enter a strong password</field.FieldHelperText>
      <field.TextInput type="password" />
      <form.Subscribe selector={s => s.values.password}>
        {password => (
          <Text variant="bodySmallStrong">Length: {password.length}</Text>
        )}
      </form.Subscribe>
      <field.FieldFeedbackText />
    </field.Field>
  )}
</form.AppField>
```

### Validation timing

`validators.onBlur` is the default; use `onChange` only for fields that need live feedback (password strength meter, search-as-you-type). `fieldRevalidateLogic` gives "blur until first error, then change" UX without polluting form-level validators.

```tsx
import { fieldRevalidateLogic, useTanstackUnityForm } from '@payfit/unity-components'

const form = useTanstackUnityForm({
  defaultValues: { email: '', password: '' },
  validators: { onBlur: z.object({ email: z.email() }) },
  validationLogic: fieldRevalidateLogic({
    whenPristine: 'blur',
    whenDirty: 'change',
    fields: ['password'],
  }),
})

<form.AppField
  name="password"
  validators={{
    onDynamic: ({ value }) =>
      value.length < 8 ? 'Password too short' : undefined,
  }}
>
  {field => <field.PasswordField label="Password" />}
</form.AppField>
```

Fields listed in `fieldRevalidateLogic.fields` MUST use `onDynamic`/`onDynamicAsync` as their sole validator and MUST NOT also appear in a form-level schema, or stale errors will linger.

### Optimal subscription with form.Subscribe + selector

Always pass a `selector` to `form.Subscribe`. A bare children-only subscription re-renders on every keystroke anywhere in the form.

```tsx
<form.Subscribe selector={s => s.values.password}>
  {password => <Text>Length: {password.length}</Text>}
</form.Subscribe>

<form.Subscribe selector={s => [s.canSubmit, s.isSubmitting] as const}>
  {([canSubmit, isSubmitting]) => (
    <Button type="submit" isDisabled={!canSubmit} isLoading={isSubmitting}>
      Submit
    </Button>
  )}
</form.Subscribe>
```

## Common Mistakes

### CRITICAL Import useForm (legacy RHF) instead of useTanstackUnityForm

Wrong:

```tsx
import { useUnityForm } from '@payfit/unity-components'

const { methods, Form, FormField } = useUnityForm(schema)
```

Correct:

```tsx
import { useTanstackUnityForm } from '@payfit/unity-components'

const form = useTanstackUnityForm({ validators: { onBlur: schema } })
```

The legacy `use-form` hook is `@deprecated` but still exported; agents trained on older code reach for it and end up mixing RHF Controller with Tanstack field components, which breaks at runtime.

Fixed-but-legacy-risk: the legacy hook is still exported but will be removed in the next few weeks (after or alongside the rebrand). Never author new code with it.

Source: libs/shared/unity/components/src/hooks/use-form.tsx:79 (@deprecated JSDoc); maintainer interview

### CRITICAL Omit form.AppForm or form.AppField wrapping

Wrong:

```tsx
<form.Form>
  <form.AppField name="email">
    {field => <field.TextField label="Email" />}
  </form.AppField>
</form.Form>
```

Correct:

```tsx
<form.AppForm>
  <form.Form>
    <form.AppField name="email">
      {field => <field.TextField label="Email" />}
    </form.AppField>
  </form.Form>
</form.AppForm>
```

`useFormContext()` and `useFieldContext()` throw without their providers; Tanstack field components silently break (cannot read property of undefined).

Source: TanstackForm.tsx:36; TanstackFormField.tsx:63 (useFormContext/useFieldContext)

### CRITICAL Mix Tanstack field with react-hook-form Controller

Wrong:

```tsx
const { control } = useForm()
<Controller control={control} name="email" render={() => (
  <TanstackTextField label="Email" />
)} />
```

Correct:

```tsx
const form = useTanstackUnityForm({ validators: { onBlur: schema } })
<form.AppForm><form.AppField name="email">
  {field => <field.TextField label="Email" />}
</form.AppField></form.AppForm>
```

RHF and Tanstack Form contexts do not interoperate; wrapping a Tanstack field in a Controller produces unmounted state and no validation.

Source: conceptual; RHF and Tanstack contexts do not interoperate

### HIGH Subscribe to whole form state instead of a selector

Wrong:

```tsx
<form.Subscribe>
  {state => <div>Length: {state.values.password.length}</div>}
</form.Subscribe>
```

Correct:

```tsx
<form.Subscribe selector={s => s.values.password}>
  {password => <div>Length: {password.length}</div>}
</form.Subscribe>
```

A selector-less subscription re-renders the children on every keystroke anywhere in the form; pass a narrowing selector to scope to the slice you need.

Source: use-tanstack-form.stories.tsx:251 (StateIntegration story)

### MEDIUM Pick the wrong validation timing

Wrong:

```tsx
useTanstackUnityForm({ validators: { onChange: schema } })
```

Correct:

```tsx
useTanstackUnityForm({ validators: { onBlur: schema } })
// or with revalidation:
// validationLogic: fieldRevalidateLogic({ fields: ['password'], whenDirty: 'change' })
```

`onChange` fires on every keystroke (jarring) and `onSubmit` waits until submit (errors arrive too late); `onBlur` is the usual default, with `fieldRevalidateLogic` reserved for "blur until first error, then change".

Source: use-tanstack-form.stories.tsx:37-40; utils/field-revalidate-logic.ts

### HIGH Mix Composed and Atomic APIs in one field (or reach for Atomic by default)

Wrong:

```tsx
// Double-wrapping:
<form.AppField name="email">
  {field => (
    <field.Field>
      <field.FieldLabel>Email</field.FieldLabel>
      <field.TextField label="Email" />
    </field.Field>
  )}
</form.AppField>
// Or reaching for Atomic with no customization reason:
<form.AppField name="name">
  {field => (
    <field.Field>
      <field.FieldLabel>Name</field.FieldLabel>
      <field.TextInput />
      <field.FieldFeedbackText />
    </field.Field>
  )}
</form.AppField>
```

Correct:

```tsx
// Default (Composed):
<form.AppField name="name">
  {field => <field.TextField label="Name" />}
</form.AppField>
// Atomic only when customizing layout/parts:
<form.AppField name="email">
  {field => (
    <field.Field>
      <field.FieldLabel>Email</field.FieldLabel>
      <CustomInline>
        <field.TextInput />
        <ExtraSlot />
      </CustomInline>
      <field.FieldFeedbackText />
    </field.Field>
  )}
</form.AppField>
```

`field.TextField` is the Composed API and already includes label/input/feedback; wrapping it in `field.Field` + `field.FieldLabel` double-wraps and breaks layout + a11y. Default to Composed; reach for Atomic only when you must customize the field's layout or swap a part — never as the standard pattern.

Source: TanstackTextField.tsx vs TanstackFormField.tsx + parts; maintainer interview (Composed is default)

## References

- [Bound field components](references/bound-field-components.md) — full inventory of `field.*` Composed components and their underlying base components.
- [Schema adapters](references/schema-adapters.md) — StandardSchemaAdapter, ZodV3SchemaAdapter, ZodV4SchemaAdapter and how `isFieldRequired` consumes them.

## See also

- `unity-migrate-from-midnight` — forms migrated off Midnight typically came with React Hook Form; that skill explains the Tanstack-only replacement path.
- `unity-layout-and-styling` — form layouts use Flex/Grid and `uy:*` utilities for spacing and responsive behavior.
- `unity-navigation` — when a form posts via a route action or links to a sibling step, use the router-aware `Link` from `@payfit/unity-components/integrations/tanstack-router`.
