Files
phaichayon 7a390cf0df init
2026-06-11 12:46:57 +07:00

1047 lines
41 KiB
Markdown

# Form System
Type-safe, composable form handling built on [TanStack Form](https://tanstack.com/form) + shadcn/ui. Supports simple CRUD forms, multi-step wizards, sheet/dialog forms, dynamic arrays, nested objects, async validation, linked fields, and cross-field validation.
---
## Table of Contents
- [Architecture](#architecture)
- [Quick Start](#quick-start)
- [Usage Patterns](#usage-patterns)
- [Pattern 1: useFormFields (recommended)](#pattern-1-useformfields--type-safe-flat-fields-recommended)
- [Pattern 2: form.AppField render prop](#pattern-2-formappfield-render-prop--full-control)
- [Pattern 3: Direct import](#pattern-3-direct-import--no-type-safety-zero-boilerplate)
- [When to use which](#when-to-use-which)
- [Available Field Components](#available-field-components)
- [Validation](#validation)
- [Recommended strategy](#recommended-strategy-field-level--form-level)
- [Validator timing](#validator-timing)
- [Zod schemas vs functions](#zod-schemas-vs-functions)
- [Async validation](#async-validation)
- [Linked / dependent field validation](#linked--dependent-field-validation)
- [Cross-field (form-level) validation](#cross-field-form-level-validation)
- [Error visibility](#error-visibility)
- [Listeners (Side Effects)](#listeners-side-effects)
- [Form Recipes](#form-recipes)
- [Simple CRUD form](#simple-crud-form)
- [Form in a Sheet or Dialog](#form-in-a-sheet-or-dialog)
- [Multi-step wizard](#multi-step-wizard)
- [Nested object fields](#nested-object-fields)
- [Dynamic array rows](#dynamic-array-rows)
- [Dependent dropdowns (country → state)](#dependent-dropdowns-country--state)
- [Password confirmation (linked fields)](#password-confirmation-linked-fields)
- [Production Utilities](#production-utilities)
- [FormErrors — form-level error display](#formerrors--form-level-error-display)
- [scrollToFirstError — auto-scroll on failed submit](#scrolltofirsterror--auto-scroll-on-failed-submit)
- [Adding a New Field Type](#adding-a-new-field-type)
- [Type Safety Reference](#type-safety-reference)
- [Exports Reference](#exports-reference)
- [Dashboard Examples](#dashboard-examples)
---
## Architecture
```
form-context.tsx fields/*.tsx
(contexts, structural (TextField, FormTextField,
components, createFormField, SelectField, FormSelectField,
FieldConfig types, ... base + composed exports)
typedField, FormErrors, │
scrollToFirstError) │
▲ │
│ │
└─────── tanstack-form.tsx ───┘
(useAppForm, useFormFields,
Form, SubmitButton, StepButton,
withForm, withFieldGroup)
```
**Dependency rule:** `fields/*.tsx` imports from `form-context.tsx`. `tanstack-form.tsx` imports from both. Neither `form-context.tsx` nor `fields/*.tsx` imports from `tanstack-form.tsx` — no circular dependencies.
**Key files:**
| File | What it provides |
| --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `src/components/ui/form-context.tsx` | Shared primitives — contexts, `useFieldContext`, structural components (`FormFieldSet`, `FormField`, `FormFieldError`), `createFormField`, `FieldConfig` types, `typedField`, `FormErrors`, `scrollToFirstError` |
| `src/components/ui/tanstack-form.tsx` | Main entry point — `useAppForm`, `useFormFields`, `Form`, `SubmitButton`, `StepButton`, `withForm`, `withFieldGroup` |
| `src/components/forms/fields/*.tsx` | 8 field components, each exporting a base (`TextField`) and composed (`FormTextField`) variant |
| `src/components/forms/fields/index.tsx` | Barrel re-exports for all fields |
---
## File Structure (per feature)
Every form feature should split into **schema**, **constants**, and **component**:
```
src/features/products/
├── schemas/
│ └── product.ts ← Zod schema + inferred FormValues type
├── constants/
│ └── product-options.ts ← Select options, enums, static data
├── components/
│ ├── product-form.tsx ← Form UI (imports schema + options)
│ └── product-form-fields.tsx ← Optional: sections for large forms
```
**Why split?**
| Concern | File | Benefit |
| ----------- | ------------------------------ | ------------------------------------------------------------------------------- |
| **Schema** | `schemas/product.ts` | Reusable in API routes, server actions, data tables, tests — no `'use client'` |
| **Type** | `schemas/product.ts` | `ProductFormValues` used in form, API, list components — single source of truth |
| **Options** | `constants/product-options.ts` | Shared between form selects, table filters, search facets |
| **Form UI** | `components/product-form.tsx` | Pure UI — opens clean, no validation logic clutter |
**Schema file example:**
```ts
// src/features/products/schemas/product.ts
import * as z from 'zod';
export const productSchema = z.object({
name: z.string().min(2, 'Product name must be at least 2 characters.'),
category: z.string().min(1, 'Please select a category'),
price: z.number({ message: 'Price is required' }),
description: z.string().min(10, 'Description must be at least 10 characters.')
});
// Always prefer z.infer — guarantees the type matches the schema exactly.
// Manual types drift when the schema has unions, optionals, or refinements.
export type ProductFormValues = z.infer<typeof productSchema>;
```
> **Rule of thumb:** Use `z.infer<typeof schema>` as the form values type. Only override individual fields (via `Omit & { ... }`) when the form's runtime value shape genuinely differs from the schema output (e.g., a `File[]` field stored as `string` after upload).
**Form component imports the schema:**
```tsx
// src/features/products/components/product-form.tsx
import { productSchema, type ProductFormValues } from '@/features/products/schemas/product';
import { categoryOptions } from '@/features/products/constants/product-options';
const form = useAppForm({
defaultValues: { ... } as ProductFormValues,
validators: { onSubmit: productSchema },
...
});
const { FormTextField, FormSelectField } = useFormFields<ProductFormValues>();
```
**Same schema reused in API route:**
```ts
// src/app/api/products/route.ts
import { productSchema } from '@/features/products/schemas/product';
export async function POST(req: Request) {
const body = await req.json();
const data = productSchema.parse(body); // same validation, zero duplication
...
}
```
### When a form grows large
For forms with 15+ fields, split the UI into section components:
```
components/
├── product-form.tsx ← Main form (useAppForm, layout, submit)
├── product-basic-fields.tsx ← Section: name, category, price
├── product-media-fields.tsx ← Section: image upload
└── product-detail-fields.tsx ← Section: description, tags, metadata
```
Each section receives the typed fields from `useFormFields` via props or calls `useFormFields` itself.
---
## Quick Start
```tsx
'use client';
import { useAppForm, useFormFields } from '@/components/ui/tanstack-form';
import * as z from 'zod';
const schema = z.object({
name: z.string().min(2, 'Name is required'),
email: z.string().email('Invalid email')
});
type FormValues = z.infer<typeof schema>;
export default function MyForm() {
const form = useAppForm({
defaultValues: { name: '', email: '' } as FormValues,
validators: { onSubmit: schema },
onSubmit: ({ value }) => console.log(value)
});
const { FormTextField } = useFormFields<FormValues>();
return (
<form.AppForm>
<form.Form>
<FormTextField
name='name'
label='Name'
required
validators={{ onBlur: z.string().min(2, 'Name is required') }}
/>
<FormTextField
name='email'
label='Email'
required
type='email'
validators={{ onBlur: z.string().email('Invalid email') }}
/>
<form.SubmitButton label='Save' />
</form.Form>
</form.AppForm>
);
}
```
---
## Usage Patterns
### Pattern 1: `useFormFields` — Type-safe flat fields (recommended)
Type-safe field names with autocomplete. Concise. Supports validators, listeners, `mode`, `defaultValue`. **Use for most forms.**
```tsx
import { useAppForm, useFormFields } from '@/components/ui/tanstack-form';
type FormValues = { name: string; email: string; category: string };
const form = useAppForm({
defaultValues: { name: '', email: '', category: '' } as FormValues,
validators: { onSubmit: schema },
onSubmit: ({ value }) => { ... },
});
const { FormTextField, FormSelectField } = useFormFields<FormValues>();
<FormTextField name="email" label="Email" required /> // ✅ autocomplete
<FormTextField name="typo" label="Oops" /> // ❌ TypeScript error
```
**Props available on every `FormXxxField`:**
| Prop | Type | Description |
| ------------------ | ----------------------------------------------- | --------------------------------------------------------------------------- |
| `name` | `DeepKeys<T>` (via `useFormFields`) or `string` | Field path |
| `validators` | `FieldValidatorConfig` | `onBlur`, `onChange`, `onChangeAsync`, `onSubmit`, `onChangeListenTo`, etc. |
| `asyncDebounceMs` | `number` | Default debounce for all async validators |
| `listeners` | `FieldListenerConfig` | `onChange`, `onBlur`, `onMount`, `onSubmit` + debounce options |
| `mode` | `'value' \| 'array'` | Set to `'array'` for array fields |
| `defaultValue` | `unknown` | Initial value (for dynamically added fields) |
| ...component props | varies | `label`, `required`, `placeholder`, `options`, etc. |
### Pattern 2: `form.AppField` render prop — Full control
Type-safe names (native TanStack Form). Full field API access. **Use for custom fields, array fields, and any UI that doesn't fit a pre-built component.**
```tsx
<form.AppField
name='framework' // ✅ type-safe
validators={{ onBlur: z.string().min(1, 'Required') }}
>
{(field) => (
<field.FieldSet>
<field.Field>
<field.FieldLabel>Framework *</field.FieldLabel>
<MyCombobox
value={field.state.value}
onChange={field.handleChange}
onBlur={field.handleBlur}
/>
</field.Field>
<field.FieldError />
</field.FieldSet>
)}
</form.AppField>
```
**Components available inside the render prop (`field.XxxField`):**
| Component | Purpose |
| ------------------------ | ----------------------------------------------------------- |
| `field.FieldSet` | Wrapper — generates unique accessibility IDs |
| `field.Field` | Container — wires `aria-invalid`, `aria-describedby` |
| `field.FieldLabel` | `<label>` connected to the field |
| `field.FieldError` | Renders validation errors (shows after touch or submit) |
| `field.FieldContent` | Flex container for label + description (horizontal layouts) |
| `field.FieldDescription` | Helper text below the field |
| `field.TextField` | Pre-built text input |
| `field.TextareaField` | Pre-built textarea |
| `field.SelectField` | Pre-built select |
| `field.CheckboxField` | Pre-built checkbox |
| `field.SwitchField` | Pre-built switch |
| `field.RadioGroupField` | Pre-built radio group |
| `field.SliderField` | Pre-built slider |
| `field.FileUploadField` | Pre-built file uploader |
| `field.DatePickerField` | Pre-built calendar date picker |
**Field API (`field.state`, `field.handleChange`, etc.):**
| Property/Method | Description |
| ------------------------------------- | ------------------------------------------- |
| `field.state.value` | Current field value |
| `field.state.meta.isTouched` | User has interacted |
| `field.state.meta.isDirty` | Value differs from default |
| `field.state.meta.isValid` | No validation errors |
| `field.state.meta.isValidating` | Async validation in progress |
| `field.state.meta.errors` | Array of error messages |
| `field.handleChange(value)` | Update field value |
| `field.handleBlur()` | Mark as touched + trigger onBlur validation |
| `field.pushValue(item)` | Array mode: add item |
| `field.removeValue(index)` | Array mode: remove item |
| `field.swapValues(a, b)` | Array mode: swap items |
| `field.insertValue(index, item)` | Array mode: insert at index |
| `field.form.setFieldValue(name, val)` | Set another field's value |
| `field.form.getFieldValue(name)` | Read another field's value |
### Pattern 3: Direct import — No type safety, zero boilerplate
```tsx
import { FormTextField } from '@/components/forms/fields';
<FormTextField name='name' label='Name' />; // name is `string` — no type check
```
### When to use which
| Scenario | Pattern | Why |
| -------------------------------------------------- | ------------- | ------------------------------------------- |
| Standard fields (text, select, checkbox, etc.) | **Pattern 1** | Type-safe + concise |
| Custom one-off fields (date picker, OTP, combobox) | **Pattern 2** | Full field API access |
| Array fields with custom row layout | **Pattern 2** | Need `pushValue`, `removeValue`, sub-fields |
| Array fields with composed component | **Pattern 1** | Pass `mode="array"` |
| Multi-step form steps | **Pattern 2** | `group.AppField` + `field.TextField` |
| Linked field validation (`onChangeListenTo`) | **Pattern 2** | Need `fieldApi` in validator |
| Quick prototype / dynamic field names | **Pattern 3** | Fastest |
---
## Available Field Components
Each field has two variants:
| Base (for render props) | Composed (for flat use) | Input type |
| ----------------------- | ----------------------- | --------------------------------------------- |
| `TextField` | `FormTextField` | Text, email, password, tel, url, number |
| `TextareaField` | `FormTextareaField` | Multi-line text with optional character count |
| `SelectField` | `FormSelectField` | Single-value dropdown (`options` prop) |
| `CheckboxField` | `FormCheckboxField` | Boolean checkbox with label |
| `SwitchField` | `FormSwitchField` | Toggle switch with label + description |
| `RadioGroupField` | `FormRadioGroupField` | Radio button group (`options` prop) |
| `SliderField` | `FormSliderField` | Range slider with min/max display |
| `FileUploadField` | `FormFileUploadField` | Drag-and-drop file upload |
| `DatePickerField` | `FormDatePickerField` | Calendar popover date picker |
**TextField** supports `type` prop: `'text'`, `'email'`, `'password'`, `'tel'`, `'url'`, `'number'`. Shows a spinner during async validation.
---
## Validation
### Recommended strategy: Field-level + Form-level
```
┌─────────────────────────────────────────────────────┐
│ onBlur (field-level) → instant feedback on tab │
│ onChangeAsync (field) → server checks (debounced) │
│ onSubmit (form-level) → catch-all safety net │
└─────────────────────────────────────────────────────┘
```
### Validator timing
| Validator | When it runs | Use for |
| --------------- | --------------------------- | ---------------------------------- |
| `onChange` | Every keystroke | Instant feedback (use sparingly) |
| `onBlur` | When field loses focus | Required checks, format validation |
| `onChangeAsync` | After debounce on keystroke | Server-side uniqueness checks |
| `onBlurAsync` | After debounce on blur | Expensive server validation |
| `onSubmit` | On form submission | Final catch-all |
| `onMount` | When field mounts | Pre-validation |
### Zod schemas vs functions
```tsx
// Zod schema — StandardSchemaV1, no adapter needed (Zod v4)
validators={{ onBlur: z.string().email('Invalid email') }}
// Sync function — return error string or undefined
validators={{
onChange: ({ value }) => value.length < 3 ? 'Too short' : undefined,
}}
// Async function — supports AbortSignal for cancellation
validators={{
onChangeAsync: async ({ value, signal }) => {
const res = await fetch(`/api/check?q=${value}`, { signal });
const { ok } = await res.json();
return ok ? undefined : 'Already taken';
},
onChangeAsyncDebounceMs: 500,
}}
```
### Async validation
```tsx
<FormTextField
name='username'
label='Username'
validators={{
onBlur: z.string().min(3, 'Too short'),
onChangeAsync: async ({ value }: { value: string }) => {
if (!value || value.length < 3) return undefined;
await new Promise((r) => setTimeout(r, 500)); // simulated API
if (value === 'admin') return 'Username is taken';
return undefined;
},
onChangeAsyncDebounceMs: 500
}}
/>
```
`TextField` automatically shows a spinner when `isValidating` is true.
### Linked / dependent field validation
Use `onChangeListenTo` to re-run validation when another field changes:
```tsx
<form.AppField
name='confirmPassword'
validators={{
onChangeListenTo: ['password'],
onChange: ({ value, fieldApi }) => {
const password = fieldApi.form.getFieldValue('password');
return value !== password ? 'Passwords do not match' : undefined;
}
}}
>
{(field) => <field.TextField label='Confirm Password' type='password' />}
</form.AppField>
```
### Cross-field (form-level) validation
For validation that spans multiple fields, use form-level validators:
```tsx
const form = useAppForm({
defaultValues: { ... },
validators: {
onSubmit: fullZodSchema, // validates entire form shape
// or use a function:
onChange: ({ value }) => {
if (value.startDate > value.endDate) return 'End date must be after start';
return undefined;
},
},
});
```
Form-level errors are rendered by `<FormErrors />`.
### Error visibility
Errors are shown when either condition is met:
1. **Field is touched** — user has interacted with the field (blur/change)
2. **Form has been submitted** — at least one submit attempt, even if fields weren't touched
This prevents showing errors on a pristine form while ensuring all errors appear after submit.
---
## Listeners (Side Effects)
Listeners run side effects without affecting validation. Use them to reset dependent fields, sync values, trigger analytics, etc.
```tsx
<FormSelectField
name='country'
label='Country'
options={countries}
listeners={{
onChange: ({ value, fieldApi }) => {
fieldApi.form.setFieldValue('state', '');
fieldApi.form.setFieldValue('city', '');
}
}}
/>
```
| Listener | When it fires |
| ---------- | ------------------------- |
| `onChange` | After field value changes |
| `onBlur` | When field loses focus |
| `onMount` | When field mounts |
| `onSubmit` | On form submission |
Each has an optional `*DebounceMs` companion (e.g., `onChangeDebounceMs: 300`).
---
## Form Recipes
### Simple CRUD form
```tsx
const form = useAppForm({
defaultValues: { name: '', email: '' } as FormValues,
validators: { onSubmit: schema },
onSubmit: async ({ value }) => {
await saveToApi(value);
toast.success('Saved!');
}
});
const { FormTextField } = useFormFields<FormValues>();
<form.AppForm>
<form.Form>
<FormTextField name='name' label='Name' required validators={{ onBlur: z.string().min(2) }} />
<FormTextField
name='email'
label='Email'
required
type='email'
validators={{ onBlur: z.string().email() }}
/>
<form.SubmitButton label='Save' />
</form.Form>
</form.AppForm>;
```
### Form in a Sheet or Dialog
Use the HTML `form` attribute to connect an external submit button:
```tsx
const [open, setOpen] = useState(false);
const form = useAppForm({
defaultValues: { ... },
onSubmit: ({ value }) => {
toast.success('Created!');
setOpen(false);
form.reset();
},
});
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent>
<SheetHeader>...</SheetHeader>
<ScrollArea className="flex-1">
<form.AppForm>
<form.Form id="sheet-form" className="space-y-4 p-0 md:p-0">
{/* fields */}
</form.Form>
</form.AppForm>
</ScrollArea>
<SheetFooter>
<Button type="submit" form="sheet-form">Save</Button>
</SheetFooter>
</SheetContent>
</Sheet>
```
Key: `id="sheet-form"` on `form.Form` + `form="sheet-form"` on the external button.
### Multi-step wizard
```tsx
import { useAppForm, withFieldGroup } from '@/components/ui/tanstack-form';
import { revalidateLogic, useStore } from '@tanstack/react-form';
// 1. Define step groups
const Step1 = withFieldGroup({
defaultValues: { name: '', category: '' },
render: ({ group }) => (
<>
<group.AppField name="name">
{(field) => <field.TextField label="Name" required />}
</group.AppField>
<group.AppField name="category">
{(field) => <field.SelectField label="Category" options={opts} />}
</group.AppField>
</>
),
});
// 2. Define per-step schemas
const stepSchemas = [
fullSchema.pick({ name: true, category: true }),
fullSchema.pick({ description: true }),
z.object({}), // review step — no validation
];
// 3. Create form with dynamic validation
const { currentValidator, currentStep, ... } = useFormStepper(stepSchemas);
const form = useAppForm({
defaultValues: { ... },
validationLogic: revalidateLogic(),
validators: {
onDynamic: currentValidator,
onDynamicAsyncDebounceMs: 500,
},
onSubmit: ({ value }) => { ... },
});
// 4. Render current step
<Step1 form={form} fields={{ name: 'name', category: 'category' }} />
```
See `src/features/forms/components/multi-step-product-form.tsx`.
### Nested object fields
Use dot-notation for nested paths. `DeepKeys<T>` provides autocomplete for `team.name`, `team.size`, etc.
```tsx
type FormValues = {
team: { name: string; size: number };
};
const { FormTextField } = useFormFields<FormValues>();
<FormTextField name="team.name" label="Team Name" required />
<FormTextField name="team.size" label="Team Size" type="number" />
```
### Dynamic array rows
Use `form.AppField` with `mode="array"` for full control over add/remove:
```tsx
type FormValues = {
members: Array<{ name: string; role: string }>;
};
<form.AppField name='members' mode='array'>
{(field) => (
<div className='space-y-3'>
{field.state.value.map((_, i) => (
<div key={i} className='flex gap-2'>
<form.AppField
name={`members[${i}].name`}
validators={{ onBlur: z.string().min(1, 'Required') }}
>
{(sub) => (
<sub.FieldSet className='flex-1'>
<sub.Field>
<Input
placeholder='Name'
value={sub.state.value}
onChange={(e) => sub.handleChange(e.target.value)}
onBlur={sub.handleBlur}
/>
</sub.Field>
<sub.FieldError />
</sub.FieldSet>
)}
</form.AppField>
<Button variant='ghost' size='icon' onClick={() => field.removeValue(i)}>
<Icons.close className='h-4 w-4' />
</Button>
</div>
))}
<Button variant='outline' size='sm' onClick={() => field.pushValue({ name: '', role: '' })}>
Add Member
</Button>
</div>
)}
</form.AppField>;
```
Array methods: `pushValue`, `removeValue`, `insertValue`, `replaceValue`, `swapValues`, `moveValue`.
### Dependent dropdowns (country → state)
Combine `listeners` with reactive `useStore`:
```tsx
// Read country value reactively
const selectedCountry = useStore(form.store, (s) => s.values.country);
const stateOptions = countryStateMap[selectedCountry] ?? [];
<FormSelectField
name="country"
label="Country"
options={countryOptions}
listeners={{
onChange: ({ fieldApi }) => {
fieldApi.form.setFieldValue('state', ''); // reset dependent field
},
}}
/>
<FormSelectField
name="state"
label="State"
options={stateOptions}
placeholder={selectedCountry ? 'Select state' : 'Select a country first'}
/>
```
### Password confirmation (linked fields)
```tsx
<form.AppField
name='confirmPassword'
validators={{
onChangeListenTo: ['password'],
onChange: ({ value, fieldApi }) => {
const password = fieldApi.form.getFieldValue('password');
return value !== password ? 'Passwords do not match' : undefined;
},
onBlur: z.string().min(1, 'Required')
}}
>
{(field) => <field.TextField label='Confirm Password' required type='password' />}
</form.AppField>
```
### Checkbox group (multi-select array)
For selecting multiple values from a list, use `form.AppField` with `mode="array"`:
```tsx
const positionOptions = [
{ value: 'frontend', label: 'Frontend Developer' },
{ value: 'backend', label: 'Backend Developer' },
{ value: 'fullstack', label: 'Full Stack Developer' }
];
<form.AppField name='position' mode='array'>
{(field) => {
const values: string[] = field.state.value || [];
return (
<field.FieldSet>
<FieldLabel>Position(s) *</FieldLabel>
<div className='grid grid-cols-2 gap-3'>
{positionOptions.map((opt) => (
<div key={opt.value} className='flex items-center space-x-2'>
<Checkbox
id={`position-${opt.value}`}
checked={values.includes(opt.value)}
onCheckedChange={(checked) => {
if (checked) field.pushValue(opt.value);
else {
const idx = values.indexOf(opt.value);
if (idx > -1) field.removeValue(idx);
}
}}
/>
<Label htmlFor={`position-${opt.value}`}>{opt.label}</Label>
</div>
))}
</div>
<field.FieldError />
</field.FieldSet>
);
}}
</form.AppField>;
```
### Date picker field (Calendar popover)
For date selection, use `form.AppField` with a Calendar popover. Store as ISO string:
```tsx
<form.AppField
name='available-date'
validators={{ onBlur: z.string().min(1, 'Please select a date') }}
>
{(field) => (
<field.FieldSet>
<field.Field>
<field.FieldLabel>Available Date *</field.FieldLabel>
<Popover>
<PopoverTrigger asChild>
<Button
variant='outline'
className={cn(
'w-full justify-start text-left font-normal',
!field.state.value && 'text-muted-foreground'
)}
>
<Icons.calendar className='mr-2 h-4 w-4' />
{field.state.value ? format(new Date(field.state.value), 'PPP') : 'Pick a date'}
</Button>
</PopoverTrigger>
<PopoverContent className='w-auto p-0' align='start'>
<Calendar
mode='single'
selected={field.state.value ? new Date(field.state.value) : undefined}
onSelect={(date) => {
field.handleChange(date ? date.toISOString() : '');
field.handleBlur();
}}
disabled={(date) => date < new Date()}
/>
</PopoverContent>
</Popover>
</field.Field>
<field.FieldError />
</field.FieldSet>
)}
</form.AppField>
```
### Real-world example: Job Application form
A complete form combining flat fields, checkbox group, date picker, select, file upload, and production utilities. Follows the split-file pattern:
```
src/features/applications/
├── schemas/application.ts ← Zod schema + z.infer type
├── constants/application-options.ts ← Position & experience options
├── components/application-form.tsx ← Form UI
```
**Schema** (`schemas/application.ts`):
```ts
export const applicationSchema = z.object({
firstName: z.string({ error: 'This field is required' }),
lastName: z.string({ error: 'This field is required' }),
email: z.email({ error: 'Please enter a valid email' }),
'github-url': z.url({ error: 'Please enter a valid url' }).optional(),
'linkedin-url': z.url({ error: 'Please enter a valid url' }).optional(),
position: z.array(z.string()).min(1, 'Please select at least one item'),
experience: z.string().min(1, 'Please select an item'),
'available-date': z.string().min(1, 'Please select a date'),
'cover-letter': z.string().optional(),
'file-upload': z.union([z.file(), z.array(z.file()).nonempty(), ...]).optional(),
});
export type ApplicationFormValues = z.infer<typeof applicationSchema>;
```
**Form** (`components/application-form.tsx`):
```tsx
import { applicationSchema, type ApplicationFormValues } from '../schemas/application';
import { positionOptions, experienceOptions } from '../constants/application-options';
const form = useAppForm({
defaultValues: { ... } as ApplicationFormValues,
validators: { onSubmit: applicationSchema },
onSubmitInvalid: () => scrollToFirstError(),
onSubmit: ({ value }) => { ... },
});
const { FormTextField, FormSelectField, FormTextareaField, FormFileUploadField } =
useFormFields<ApplicationFormValues>();
// Flat fields for text/email/url/select/textarea/file
<FormTextField name="firstName" label="First Name" required ... />
// AppField for checkbox group (position) and date picker (available-date)
<form.AppField name="position" mode="array">...</form.AppField>
<form.AppField name="available-date">...</form.AppField>
```
See `/dashboard/forms/application` for the full working example.
---
## Production Utilities
### FormErrors — form-level error display
Renders errors from form-level validators (cross-field validation). Place at the top of the form.
```tsx
import { FormErrors } from '@/components/ui/tanstack-form';
<form.AppForm>
<form.Form>
<FormErrors />
{/* fields */}
</form.Form>
</form.AppForm>;
```
### scrollToFirstError — auto-scroll on failed submit
Scrolls to and focuses the first field with a validation error. Wire it to `onSubmitInvalid`:
```tsx
import { scrollToFirstError } from '@/components/ui/tanstack-form';
const form = useAppForm({
...
onSubmitInvalid: () => scrollToFirstError(),
});
```
---
## Adding a New Field Type
Creating a new field (e.g., `DatePickerField`) requires **2 touchpoints**:
### 1. Create the field file
```tsx
// src/components/forms/fields/date-picker-field.tsx
'use client';
import { useStore } from '@tanstack/react-form';
import { FieldLabel } from '@/components/ui/field';
import {
useFieldContext,
FormFieldSet,
FormField,
FormFieldError,
createFormField
} from '@/components/ui/form-context';
interface DatePickerFieldProps {
label: string;
required?: boolean;
}
export function DatePickerField({ label, required }: DatePickerFieldProps) {
const field = useFieldContext();
const value = useStore(field.store, (s) => s.value) as Date | undefined;
return (
<FormFieldSet>
<FormField>
<FieldLabel>
{label}
{required && ' *'}
</FieldLabel>
{/* Your date picker UI here — call field.handleChange and field.handleBlur */}
</FormField>
<FormFieldError />
</FormFieldSet>
);
}
export const FormDatePickerField = createFormField(DatePickerField);
```
### 2. Export from barrel
```tsx
// src/components/forms/fields/index.tsx
export { DatePickerField } from './date-picker-field';
export { FormDatePickerField } from './date-picker-field';
```
### 3. Use it
```tsx
// Direct import (Pattern 3)
import { FormDatePickerField } from '@/components/forms/fields';
<FormDatePickerField name='birthDate' label='Birth Date' />;
// Type-safe with typedField (Pattern 1 for custom fields)
import { typedField } from '@/components/ui/tanstack-form';
const narrow = typedField<FormValues>();
const TypedDatePicker = narrow(FormDatePickerField);
<TypedDatePicker name='birthDate' label='Birth Date' />;
// Or add to useFormFields in tanstack-form.tsx for built-in support
```
### Optional: Register for AppField render props
To use `field.DatePickerField` inside `form.AppField`, add to `fieldComponents` in `tanstack-form.tsx`.
To include in `useFormFields`, add to its return object.
---
## Type Safety Reference
| What | Type-safe? | How |
| --------------------------------------------- | :--------: | --------------------------------------------- |
| Field names via `useFormFields<T>()` | Yes | `DeepKeys<T>` narrows `name` |
| Field names via `form.AppField` | Yes | Native TanStack Form typing |
| Field names via `typedField<T>()(Component)` | Yes | `DeepKeys<T>` narrowing for custom fields |
| Field names via direct `FormTextField` import | No | `name` is `string` |
| Nested paths (`team.name`, `members[0].role`) | Yes | `DeepKeys<T>` resolves dot/bracket notation |
| Validator values (Zod schema) | Yes | StandardSchemaV1 pass-through |
| Validator functions | Partial | `value` typed as `unknown` — cast in function |
| Listener callbacks | Partial | `value` typed as `unknown` — cast in callback |
---
## Exports Reference
### From `@/components/ui/tanstack-form`
| Export | Type | Purpose |
| ---------------------- | --------- | ------------------------------------------------------------------------ |
| `useAppForm` | Hook | Create a form instance |
| `useFormFields<T>()` | Hook | Get type-safe composed field components |
| `withForm` | HOC | Wrap a component with form context |
| `withFieldGroup` | HOC | Create multi-step field groups |
| `useFormContext` | Hook | Access form instance from context |
| `useFieldContext` | Hook | Access field API from context |
| `createFormField` | Utility | Create a composed field from a base field |
| `typedField<T>()` | Utility | Narrow any composed field's name to `DeepKeys<T>` |
| `revalidateLogic` | Utility | Dynamic validation logic for multi-step |
| `scrollToFirstError` | Utility | Scroll + focus first invalid field |
| `FormFieldSet` | Component | Structural — accessibility ID wrapper |
| `FormField` | Component | Structural — aria-invalid, aria-describedby |
| `FormFieldError` | Component | Renders field-level errors |
| `FormErrors` | Component | Renders form-level errors |
| `FieldConfig` | Type | `validators` + `asyncDebounceMs` + `listeners` + `mode` + `defaultValue` |
| `FieldValidatorConfig` | Type | Validator timing options |
| `FieldListenerConfig` | Type | Listener options |
| `WithTypedName` | Type | Narrow component's `name` prop |
### From `@/components/forms/fields`
| Base (render prop) | Composed (flat) |
| ------------------ | --------------------- |
| `TextField` | `FormTextField` |
| `TextareaField` | `FormTextareaField` |
| `SelectField` | `FormSelectField` |
| `CheckboxField` | `FormCheckboxField` |
| `SwitchField` | `FormSwitchField` |
| `RadioGroupField` | `FormRadioGroupField` |
| `SliderField` | `FormSliderField` |
| `FileUploadField` | `FormFileUploadField` |
| `DatePickerField` | `FormDatePickerField` |
---
## Dashboard Examples
### Form Pages (`/dashboard/forms/...`)
| Page | Route | Patterns demonstrated |
| --------------------- | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Basic Form** | `/dashboard/forms/basic` | All 8 field types, `useFormFields`, `onBlur` + async validation, listeners, form data preview |
| **Multi-Step Form** | `/dashboard/forms/multi-step` | `withFieldGroup`, per-step Zod schemas, `revalidateLogic`, step navigation, review summary |
| **Sheet & Dialog** | `/dashboard/forms/sheet-form` | Form in Sheet with external submit button, form in Dialog, close + reset on success |
| **Advanced Patterns** | `/dashboard/forms/advanced` | Async validation (username check), linked fields (`onChangeListenTo` for password confirm), nested objects (`team.name`), dynamic array rows (members), dependent dropdowns (country → state with listener), `FormErrors`, `scrollToFirstError` |
### Other Forms
| Form | File | Patterns |
| ------------- | ------------------------------------------------------ | ------------------------------------------ |
| Product CRUD | `src/features/products/components/product-form.tsx` | Pattern 1, split schema, onBlur validators |
| Sheet Product | `src/features/forms/components/sheet-product-form.tsx` | Pattern 2 in Sheet |
| Auth | `src/features/auth/components/user-auth-form.tsx` | Pattern 2, minimal |