Fair Supply LogoFair Supply - Docs

Forms Guide

Best practices for building forms in the platform using TanStack Form, Zod, and luz components.

Tools

ToolPurpose
TanStack FormForm state management, field handling, submission
ZodSchema validation (Standard Schema support)
luz Form componentsForm, FormField, FormSubmit wrappers

Core Components

ComponentPurpose
useFormTanStack Form hook for creating form instances
FormProvider that wraps form fields, accepts schema, handles submission
FormFieldSmart field wrapper with auto-detection and prop injection
FormSubmitSubmit button with automatic loading state

Form Props

PropTypeDefaultDescription
formFormApirequiredTanStack Form instance from useForm
schemaZodObject-Zod schema for automatic per-field validation
validationMode'onBlur' | 'onChange' | 'all''onBlur'When to run validation
showSuccessbooleantrueShow success state (green border, success message) when field is valid
classNamestring-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:

ComponentDetected TypeInjected Props
Inputtextvalue, onChange, onBlur, invalid, valid
Input type="number"textSame as above, auto-converts to number
Dropdownselectvalue, onValueChange, onBlur
Checkboxbooleanchecked, onCheckedChange, onBlur
Switchbooleanchecked, onCheckedChange, onBlur
RadioGroupradiovalue, 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

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

ModeWhen Validation Runs
onBlur (default)When field loses focus
onChangeOn every keystroke
allBoth 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:

  1. Field is dirty (value changed from default) AND has errors, OR
  2. 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

PropTypeDescription
descriptionstringHelp text shown before interaction
successMessagestringText shown when field is valid (replaces description)
errorIconReactElementIcon displayed with error messages
successIconReactElementIcon 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

  1. Before editing: Shows description (gray text)
  2. After editing with valid value: Shows successMessage with successIcon (green)
  3. After editing with invalid value: Shows validation error with errorIcon (red)
  4. 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:

PropertyPurpose
field.state.valueCurrent field value
field.state.meta.errorsArray of validation errors
field.state.meta.isDirtyWhether value changed from default
field.state.meta.isTouchedWhether field has been focused/blurred
field.handleChangeUpdate value
field.handleBlurHandle 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 CaseApproach
Data entryFull form with validation
Single toggleDirect state update, no form needed
Inline editingForm with single field
Multi-step wizardMultiple forms or single form with steps

Best Practices

  1. Pass schema to Form - Use <Form schema={schema}> for per-field validation
  2. Use onBlur validation mode (default) - Better UX than validating every keystroke
  3. Keep forms focused - One form per logical unit of data
  4. Use luz components - Auto-detection simplifies wiring
  5. Render function for complex fields - When auto-detection isn't sufficient
  6. 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:

StatePropDescription
Default-Gray border
Focus-Purple border with ring shadow
DisableddisabledGray background, muted text
ErrorinvalidRed border with ring shadow
SuccessvalidGreen 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:

StyleUsage
fieldStyles.labelField labels (12px semibold)
fieldStyles.descriptionHelp text (12px gray)
fieldStyles.errorError messages (12px red)
fieldStyles.successSuccess messages (12px green)
fieldStyles.requiredRequired indicator (*)
fieldStyles.helpWithIconContainer for icon + text
fieldStyles.helpIconIcon sizing (14x14px)
fieldStyles.helpIconErrorError icon color
fieldStyles.helpIconSuccessSuccess icon color

Checklist

  • Using useForm from @repo/luz
  • Schema defined with Zod
  • Schema passed to <Form schema={schema}>
  • All fields wrapped in FormField
  • Using FormSubmit for 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