# 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; ``` > **Rule of thumb:** Use `z.infer` 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(); ``` **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; export default function MyForm() { const form = useAppForm({ defaultValues: { name: '', email: '' } as FormValues, validators: { onSubmit: schema }, onSubmit: ({ value }) => console.log(value) }); const { FormTextField } = useFormFields(); return ( ); } ``` --- ## 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(); // ✅ autocomplete // ❌ TypeScript error ``` **Props available on every `FormXxxField`:** | Prop | Type | Description | | ------------------ | ----------------------------------------------- | --------------------------------------------------------------------------- | | `name` | `DeepKeys` (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 {(field) => ( Framework * )} ``` **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` | `