# Forms Forms follow the **official shadcn TanStack Form conventions**, scaled with TanStack's own [`createFormHook`](https://tanstack.com/form/latest/docs/framework/react/guides/form-composition) pattern: the doc's `Field` anatomy is written **once per widget** as a reusable field component, and pages use them as one-liners inside `form.AppField`. - [shadcn: TanStack Form](https://ui.shadcn.com/docs/forms/tanstack) — the anatomy inside every field component - [TanStack Form docs](https://tanstack.com/form/latest) — validators, listeners, arrays, async validation ## Architecture | File | What it provides | | --- | --- | | `src/lib/form-context.ts` | `createFormHookContexts` — `fieldContext`, `formContext`, `useFieldContext`, `useFieldInvalid`, `BaseFieldProps` | | `src/components/forms/fields/*.tsx` | 16 field components, each the exact shadcn doc anatomy for its widget | | `src/components/forms/submit-button.tsx` | `SubmitButton` — disables while submitting (`form.Subscribe`) | | `src/lib/form.ts` | `createFormHook` — exports `useAppForm` / `withForm` with everything registered | ## The pattern One `useAppForm` per form, a Zod schema validated on submit, and one `form.AppField` per field rendering the matching component: ```tsx 'use client'; import { useAppForm } from '@/lib/form'; import { FieldGroup } from '@/components/ui/field'; import * as z from 'zod'; const formSchema = z.object({ title: z.string().min(5, 'Title must be at least 5 characters.'), severity: z.string().min(1, 'Select a severity.') }); export function BugReportForm() { const form = useAppForm({ defaultValues: { title: '', severity: '' }, validators: { onSubmit: formSchema }, onSubmit: ({ value }) => console.log(value) }); return (
{ e.preventDefault(); form.handleSubmit(); }} > ( )} /> ( )} /> Submit
); } ``` `form.AppField`'s `name` is fully typed against `defaultValues` — typos are compile errors. Field-level validators/listeners go on the `form.AppField` element (async checks, `onChangeListenTo` linked fields). ## Available field components All render inside `form.AppField` as `field.XxxField`; every one takes `label`, `description?`, `required?`. | Component | Value type | Notes | | --- | --- | --- | | `TextField` | `string` / `number` | Any input `type` (text, email, password, tel, url, time, number). Number inputs convert at the edge — clearing writes `undefined`. Shows a spinner while async validators run. | | `TextareaField` | `string` | `showCount` renders a character counter against `maxLength` | | `SelectField` | `string` | `options` array | | `CheckboxField` | `boolean` | Single checkbox (terms, consent) | | `SwitchField` | `boolean` | Label/description left, switch right | | `RadioGroupField` | `string` | `FieldSet` + `FieldLegend` semantics | | `SliderField` | `number` | `min`/`max`/`step` + value readout | | `ComboboxField` | `string` | Searchable select (Popover + Command) | | `DatePickerField` | `Date \| undefined` | Popover + Calendar, `disabledDates` | | `DateRangeField` | `DateRange \| undefined` | Two-month range calendar | | `OtpField` | `string` | 6-digit code (3 + 3) | | `ColorField` | `string` | Native picker + hex input | | `FileUploadField` | `File[]` | Wraps `FileUploader`, `maxSize`/`maxFiles` | | `CheckboxGroupField` | `string[]` | Needs `mode='array'` on the AppField | | `TagsField` | `string[]` | Needs `mode='array'`; Enter/Add pushes, badges remove | | `ToggleGroupField` | `string[]` | Needs `mode='array'`; pass `ToggleGroupItem`s as children | ## One-off custom fields — drop down to `form.Field` For anything the components don't cover (object-row arrays, bespoke UI), use the raw doc pattern directly — it composes freely with the components: ```tsx ( <> {field.state.value.map((_, i) => ( { const isInvalid = subField.state.meta.isTouched && !subField.state.meta.isValid; return ( subField.handleChange(e.target.value)} onBlur={subField.handleBlur} aria-invalid={isInvalid} /> {isInvalid && } ); }} /> ))} )} /> ``` The doc conventions inside any custom field: `data-invalid` on ``, `aria-invalid` on the control, `{isInvalid && }`, function validators return `{ message: '…' }` objects. ## Scaling to large forms — `withForm` sections Split a big form into reusable section components with `withForm` (also exported from `@/lib/form`). Sections receive the form instance as a prop and keep **fully typed field names** — a typo'd `name` inside a section is still a compile error: ```tsx import { useAppForm, withForm } from '@/lib/form'; const ShippingSection = withForm({ defaultValues: checkoutDefaults, // ties the section to the form's shape render: function ShippingRender({ form }) { return ( } /> } /> ); } }); // In the page: const form = useAppForm({ defaultValues: checkoutDefaults, ... }); ``` Deep paths (`org.billing.address.city`), array sub-paths (`admins[0].prefs.notify`), and union-typed leaves all stay typed, and typechecking stays fast at 40+ fields. ## Template-specific notes **Submitting with React Query.** `onSubmit` awaits the mutation; success/error handling lives on the mutation (see `features/products/components/product-form.tsx`): ```tsx onSubmit: async ({ value }) => { await createMutation.mutateAsync(value); }; ``` **Sheet / Dialog forms.** The submit button lives in the footer, outside the `
` element, connected via the HTML `form` attribute (`features/users/components/user-form-sheet.tsx`): ```tsx …
``` **Multi-step forms.** `useFormStepper(stepSchemas, { fullSchema })` from `@/hooks/use-stepper` gates step navigation: `Next` validates the current step's schema and paints its errors; the final submit re-validates the whole schema and never submits invalid data. Route every submit through the gate — see `features/forms/components/multi-step-product-form.tsx`. **Number inputs.** `TextField type='number'` already converts at the edge; give required numbers a human message: `z.number({ error: 'Price is required' })`. **Caveats to know** (verified by stress testing): - `field.XxxField` components assert their value type via `useFieldContext()` — the compiler checks the `name` path exists, but not that the widget matches the path's value type (a `SwitchField` on a string path compiles and renders wrong values). Match widgets to the table above. - Rendering a field component outside `form.AppField` throws a clear error (`fieldContext only works when within a fieldComponent…`) — it cannot fail silently. - Two forms with identical field names mounted at once (a sheet over a page) produce duplicate `id` attributes — the shadcn doc's `id={field.name}` convention. Form *state* stays fully isolated; only label-target ids collide. Rename fields or avoid simultaneous mounting if labels must stay clickable in both. - Forgetting `mode='array'` on an AppField using `CheckboxGroupField` / `TagsField` / `ToggleGroupField` still renders and updates — but keep the convention: array mode gives TanStack correct per-item meta tracking. ## Examples in the dashboard | Page | Route | Demonstrates | | --- | --- | --- | | Basic Form | `/dashboard/forms/basic` | All 16 field components incl. array-mode groups, pickers, OTP, tags, upload | | Advanced | `/dashboard/forms/advanced` | Async validation, linked fields (`onChangeListenTo`), nested paths, raw `form.Field` object-row arrays, listener side effects | | Multi-Step | `/dashboard/forms/multi-step` | Per-step schemas, validation gate, review step | | Sheet Form | `/dashboard/forms/sheet-form` | Sheet + Dialog forms with external submit buttons |