UNPKG

@payfit/unity-components

Version:

344 lines (268 loc) 10.9 kB
--- 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`.