Forms Guide
Best practices for building forms in the platform using TanStack Form, Zod, and luz components.
Tools
| Tool | Purpose |
|---|---|
| TanStack Form | Form state management, field handling, submission |
| Zod | Schema validation (Standard Schema support) |
| luz Form components | Form, FormField, FormSubmit wrappers |
Core Components
| Component | Purpose |
|---|---|
| useForm | TanStack Form hook for creating form instances |
| Form | Provider that wraps form fields, accepts schema, handles submission |
| FormField | Smart field wrapper with auto-detection and prop injection |
| FormSubmit | Submit button with automatic loading state |
Form Props
| Prop | Type | Default | Description |
|---|---|---|---|
form | FormApi | required | TanStack Form instance from useForm |
schema | ZodObject | - | Zod schema for automatic per-field validation |
validationMode | 'onBlur' | 'onChange' | 'all' | 'onBlur' | When to run validation |
showSuccess | boolean | true | Show success state (green border, success message) when field is valid |
className | string | - | CSS class for form element |
Basic Form Pattern
'use client';
import { useForm } from '@repo/luz';
import { Form, FormField, FormSubmit, Input } from '@repo/luz';
import { z } from 'zod';
const schema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email address'),
});
export function ContactForm() {
const form = useForm({
defaultValues: {
name: '',
email: '',
},
// NO validators here - pass schema to <Form> for per-field validation
onSubmit: async ({ value }) => {
await saveContact(value);
},
});
return (
<Form form={form} schema={schema} className="space-y-4">
<FormField name="name" label="Name" required>
<Input placeholder="Enter your name" />
</FormField>
<FormField name="email" label="Email" required>
<Input type="email" placeholder="you@example.com" />
</FormField>
<FormSubmit submittingText="Saving...">Submit</FormSubmit>
</Form>
);
}FormField Auto-Detection
FormField automatically detects the child component type and injects the correct props:
| Component | Detected Type | Injected Props |
|---|---|---|
| Input | text | value, onChange, onBlur, invalid, valid |
| Input type="number" | text | Same as above, auto-converts to number |
| Dropdown | select | value, onValueChange, onBlur |
| Checkbox | boolean | checked, onCheckedChange, onBlur |
| Switch | boolean | checked, onCheckedChange, onBlur |
| RadioGroup | radio | value, onValueChange, onBlur |
Supported Field Types
// Text input
<FormField name="name" label="Name" required>
<Input placeholder="John Doe" />
</FormField>
// Number input (auto-converts to number)
<FormField name="age" label="Age">
<Input type="number" min={0} />
</FormField>
// Dropdown
<FormField name="country" label="Country" required>
<Dropdown
options={[
{ value: 'au', label: 'Australia' },
{ value: 'nz', label: 'New Zealand' },
]}
placeholder="Select a country"
/>
</FormField>
// Checkbox (use render function for inline label)
<FormField name="newsletter">
{(field) => (
<label className="flex items-center gap-3 cursor-pointer">
<Checkbox checked={field.state.value} onCheckedChange={field.handleChange} />
<span>Subscribe to newsletter</span>
</label>
)}
</FormField>
// Switch
<FormField name="notifications">
{(field) => (
<label className="flex items-center gap-3 cursor-pointer">
<Switch checked={field.state.value} onCheckedChange={field.handleChange} />
<span>Enable notifications</span>
</label>
)}
</FormField>Validation Patterns
Schema-Based Validation (Recommended)
Pass the schema to <Form> for automatic per-field validation:
const schema = z.object({
email: z.string().email(),
name: z.string().min(2),
});
const form = useForm({
defaultValues: { email: '', name: '' },
// NO validators here!
onSubmit: async ({ value }) => { /* ... */ },
});
// Default: onBlur validation per field
<Form form={form} schema={schema}>
<FormField name="email" label="Email">
<Input />
</FormField>
</Form>Benefits:
- Only the edited field shows errors (not all fields at once)
- Errors appear when field is dirty (value changed), not just touched
- All errors show on form submission
Validation Modes
| Mode | When Validation Runs |
|---|---|
onBlur (default) | When field loses focus |
onChange | On every keystroke |
all | Both onBlur and onChange |
// Real-time validation
<Form form={form} schema={schema} validationMode="onChange">
...
</Form>
// Both blur and change
<Form form={form} schema={schema} validationMode="all">
...
</Form>Error-only validation (no success state)
To show only errors and never the green success state (border or success message), set showSuccess={false} on the form:
<Form form={form} schema={schema} showSuccess={false}>
<FormField name="name" label="Calculation Title" required>
<Input />
</FormField>
...
</Form>Valid fields will use the default (neutral) input style; only invalid fields show the error state.
When Errors Display
Validation errors only appear when:
- Field is dirty (value changed from default) AND has errors, OR
- Form has been submitted AND field has errors
This prevents errors from showing when just tabbing through fields.
Disable Submit on Invalid
<FormSubmit disableOnInvalid submittingText="Saving...">
Submit
</FormSubmit>Validation Icons and Messages
FormField supports validation icons and custom success messages for enhanced user feedback:
Props for Validation Feedback
| Prop | Type | Description |
|---|---|---|
description | string | Help text shown before interaction |
successMessage | string | Text shown when field is valid (replaces description) |
errorIcon | ReactElement | Icon displayed with error messages |
successIcon | ReactElement | Icon displayed with success messages |
Example with Icons
import { CheckCircleIcon, ExclamationTriangleIcon } from '@heroicons/react/24/outline';
<FormField
name="email"
label="Email"
required
description="We'll send a confirmation email"
successMessage="Email address is valid!"
errorIcon={<ExclamationTriangleIcon />}
successIcon={<CheckCircleIcon />}
>
<Input type="email" placeholder="you@example.com" />
</FormField>Message Priority
- Before editing: Shows
description(gray text) - After editing with valid value: Shows
successMessagewithsuccessIcon(green) - After editing with invalid value: Shows validation error with
errorIcon(red) - After form submission: Shows errors for all invalid fields
Icons are automatically styled with correct sizing (14x14px) and colors.
Render Function Pattern
Use the render function for full control over field props or custom components:
<FormField name="temperature" label="Temperature">
{(field) => (
<Input
type="number"
suffix="°C"
value={field.state.value ?? ''}
onChange={(e) => field.handleChange(Number(e.target.value) || 0)}
onBlur={field.handleBlur}
/>
)}
</FormField>The field object provides:
| Property | Purpose |
|---|---|
field.state.value | Current field value |
field.state.meta.errors | Array of validation errors |
field.state.meta.isDirty | Whether value changed from default |
field.state.meta.isTouched | Whether field has been focused/blurred |
field.handleChange | Update value |
field.handleBlur | Handle blur event |
Conditional Fields
Use form.Subscribe to conditionally render fields based on other field values:
<FormField name="contactMethod" label="Preferred Contact Method">
{(field) => (
<RadioGroup value={field.state.value} onValueChange={field.handleChange}>
<label className="flex items-center gap-2">
<Radio value="email" />
<span>Email</span>
</label>
<label className="flex items-center gap-2">
<Radio value="phone" />
<span>Phone</span>
</label>
</RadioGroup>
)}
</FormField>
<form.Subscribe selector={(state) => state.values.contactMethod}>
{(contactMethod) => (
<>
{contactMethod === 'email' && (
<FormField name="email" label="Email Address" required>
<Input type="email" />
</FormField>
)}
{contactMethod === 'phone' && (
<FormField name="phone" label="Phone Number" required>
<Input type="tel" />
</FormField>
)}
</>
)}
</form.Subscribe>Dynamic Array Fields
Use form.Subscribe with form.getFieldValue and form.setFieldValue for dynamic arrays:
type TodoEntry = { id: string; text: string };
const form = useForm({
defaultValues: {
todos: [{ id: '1', text: 'First task' }] as TodoEntry[],
},
// ...
});
const addTodo = () => {
const current = form.getFieldValue('todos') as TodoEntry[];
form.setFieldValue('todos', [...current, { id: crypto.randomUUID(), text: '' }]);
};
const removeTodo = (index: number) => {
const current = form.getFieldValue('todos') as TodoEntry[];
form.setFieldValue('todos', current.filter((_, i) => i !== index));
};
// In render:
<form.Subscribe selector={(state) => state.values.todos as TodoEntry[]}>
{(todos) => (
<Stack gap={2} direction="column">
{todos.map((todo, index) => (
<div key={todo.id} className="flex gap-2">
<FormField name={`todos[${index}].text`}>
<Input placeholder={`Todo ${index + 1}`} />
</FormField>
<Button type="button" variant="secondary" onClick={() => removeTodo(index)}>
Remove
</Button>
</div>
))}
</Stack>
)}
</form.Subscribe>
<Button type="button" variant="secondary" onClick={addTodo}>
Add Todo
</Button>FormSubmit Options
FormSubmit inherits all Button props and adds form-specific behaviour:
// Basic
<FormSubmit>Save</FormSubmit>
// With loading text
<FormSubmit submittingText="Saving...">Save</FormSubmit>
// Disable when form has validation errors
<FormSubmit disableOnInvalid submittingText="Saving...">Save</FormSubmit>
// With Button variants
<FormSubmit variant="primary" size="lg">Create Account</FormSubmit>
<FormSubmit variant="secondary" size="small">Cancel</FormSubmit>When to Use Forms
| Use Case | Approach |
|---|---|
| Data entry | Full form with validation |
| Single toggle | Direct state update, no form needed |
| Inline editing | Form with single field |
| Multi-step wizard | Multiple forms or single form with steps |
Best Practices
- Pass schema to Form - Use
<Form schema={schema}>for per-field validation - Use onBlur validation mode (default) - Better UX than validating every keystroke
- Keep forms focused - One form per logical unit of data
- Use luz components - Auto-detection simplifies wiring
- Render function for complex fields - When auto-detection isn't sufficient
- Type your schema - Zod provides runtime validation and TypeScript types
Anti-Patterns
Don't: Manual State Management
// BAD - managing form state manually
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [errors, setErrors] = useState({});
// GOOD - use TanStack Form with schema
const form = useForm({
defaultValues: { name: '', email: '' },
onSubmit: async ({ value }) => { /* ... */ },
});
<Form form={form} schema={schema}>
...
</Form>Don't: Use Form-Level Validators
// BAD - validates ALL fields when any field blurs
const form = useForm({
defaultValues: { name: '', email: '' },
validators: { onBlur: schema }, // Don't do this!
});
// GOOD - pass schema to Form for per-field validation
const form = useForm({
defaultValues: { name: '', email: '' },
});
<Form form={form} schema={schema}>
...
</Form>Don't: Validation in onSubmit Only
// BAD - no field-level feedback
onSubmit: async ({ value }) => {
const result = schema.safeParse(value);
if (!result.success) {
// User has to submit to see errors
}
}
// GOOD - pass schema to Form for per-field validation
<Form form={form} schema={schema}>
...
</Form>Don't: Override Auto-Detected Props
// BAD - fighting auto-detection
<FormField name="email">
<Input value={email} onChange={setEmail} /> // Props will be overwritten
</FormField>
// GOOD - let FormField inject props
<FormField name="email">
<Input />
</FormField>
// OR use render function for custom handling
<FormField name="email">
{(field) => (
<Input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
)}
</FormField>Input States
Input components support multiple visual states:
| State | Prop | Description |
|---|---|---|
| Default | - | Gray border |
| Focus | - | Purple border with ring shadow |
| Disabled | disabled | Gray background, muted text |
| Error | invalid | Red border with ring shadow |
| Success | valid | Green border with ring shadow |
When using FormField, the invalid and valid props are automatically set based on validation state.
Field Styles
For standalone inputs outside of FormField, use the shared fieldStyles for consistent styling:
import { fieldStyles } from '@repo/luz';
<div className="space-y-1.5">
<label htmlFor="email" className={fieldStyles.label}>
Email
</label>
<Input id="email" type="email" />
<p className={fieldStyles.description}>We'll never share your email</p>
</div>Available styles:
| Style | Usage |
|---|---|
fieldStyles.label | Field labels (12px semibold) |
fieldStyles.description | Help text (12px gray) |
fieldStyles.error | Error messages (12px red) |
fieldStyles.success | Success messages (12px green) |
fieldStyles.required | Required indicator (*) |
fieldStyles.helpWithIcon | Container for icon + text |
fieldStyles.helpIcon | Icon sizing (14x14px) |
fieldStyles.helpIconError | Error icon color |
fieldStyles.helpIconSuccess | Success icon color |
Checklist
- Using
useFormfrom@repo/luz - Schema defined with Zod
- Schema passed to
<Form schema={schema}> - All fields wrapped in
FormField - Using
FormSubmitfor submission - Form is a Client Component (
'use client') - Errors only show after editing field (dirty state)
- All errors show on form submission
- Success states display correctly (optional)
- Loading state shows during submission