@payfit/unity-components
Version:
257 lines (196 loc) • 8.23 kB
Markdown
---
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.
metadata:
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
- Prefer composed `field.*Field` components for ordinary fields.
- Use the atomic field parts only when custom layout or control composition is
required.
- Default validation to blur; use dynamic validation for fields that need
blur-then-change behavior.
- Narrow every `form.Subscribe` with a selector.
Read [references/patterns.md](references/patterns.md) for complete examples.
## 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.
The legacy hook remains exported for compatibility but is deprecated. Do not
author new code with it.
Source: libs/shared/unity/components/src/hooks/use-form.tsx:79 (@deprecated JSDoc)
### 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; TanstackFormField.tsx and its parts
## 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`.