Type-safe, composable form handling built on TanStack 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.
- Architecture
- Quick Start
- Usage Patterns
- Available Field Components
- Validation
- Listeners (Side Effects)
- Form Recipes
- Production Utilities
- Adding a New Field Type
- Type Safety Reference
- Exports Reference
- Dashboard Examples
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 |
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:
// 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 (viaOmit & { ... }) when the form's runtime value shape genuinely differs from the schema output (e.g., aFile[]field stored asstringafter upload).
Form component imports the schema:
// 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:
// 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
...
}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.
'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>
);
}Type-safe field names with autocomplete. Concise. Supports validators, listeners, mode, defaultValue. Use for most forms.
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 errorProps 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. |
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.
<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 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 |
import { FormTextField } from '@/components/forms/fields';
<FormTextField name='name' label='Name' />; // name is `string` — no type check| 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 |
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 |
TextField supports type prop: 'text', 'email', 'password', 'tel', 'url', 'number'. Shows a spinner during async validation.
┌─────────────────────────────────────────────────────┐
│ onBlur (field-level) → instant feedback on tab │
│ onChangeAsync (field) → server checks (debounced) │
│ onSubmit (form-level) → catch-all safety net │
└─────────────────────────────────────────────────────┘
| 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 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,
}}<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.
Use onChangeListenTo to re-run validation when another field changes:
<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>For validation that spans multiple fields, use form-level validators:
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 />.
Errors are shown when either condition is met:
- Field is touched — user has interacted with the field (blur/change)
- 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 run side effects without affecting validation. Use them to reset dependent fields, sync values, trigger analytics, etc.
<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).
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>;Use the HTML form attribute to connect an external submit button:
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.
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.
Use dot-notation for nested paths. DeepKeys<T> provides autocomplete for team.name, team.size, etc.
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" />Use form.AppField with mode="array" for full control over add/remove:
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.
Combine listeners with reactive useStore:
// 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'}
/><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>For selecting multiple values from a list, use form.AppField with mode="array":
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>;For date selection, use form.AppField with a Calendar popover. Store as ISO string:
<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>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):
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):
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.
Renders errors from form-level validators (cross-field validation). Place at the top of the form.
import { FormErrors } from '@/components/ui/tanstack-form';
<form.AppForm>
<form.Form>
<FormErrors />
{/* fields */}
</form.Form>
</form.AppForm>;Scrolls to and focuses the first field with a validation error. Wire it to onSubmitInvalid:
import { scrollToFirstError } from '@/components/ui/tanstack-form';
const form = useAppForm({
...
onSubmitInvalid: () => scrollToFirstError(),
});Creating a new field (e.g., DatePickerField) requires 2 touchpoints:
// 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);// src/components/forms/fields/index.tsx
export { DatePickerField } from './date-picker-field';
export { FormDatePickerField } from './date-picker-field';// 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 supportTo use field.DatePickerField inside form.AppField, add to fieldComponents in tanstack-form.tsx.
To include in useFormFields, add to its return object.
| 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 |
| 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 |
| Base (render prop) | Composed (flat) |
|---|---|
TextField |
FormTextField |
TextareaField |
FormTextareaField |
SelectField |
FormSelectField |
CheckboxField |
FormCheckboxField |
SwitchField |
FormSwitchField |
RadioGroupField |
FormRadioGroupField |
SliderField |
FormSliderField |
FileUploadField |
FormFileUploadField |
| 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 |
| 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 |