Files
2026-04-10 16:50:03 +07:00

8.2 KiB

Forms Guide

Table of Contents

  1. Architecture
  2. Field Types
  3. Usage Patterns
  4. Validation Strategies
  5. Sheet/Dialog Forms
  6. Multi-Step Forms
  7. Advanced Patterns

Architecture

The form system is built on TanStack Form + Zod with a composable field layer.

Key files:

  • src/components/ui/tanstack-form.tsx — exports useAppForm, useFormFields<T>(), composed fields
  • src/components/ui/form-context.tsx — contexts, createFormField, structural components
  • src/components/forms/fields/*.tsx — 8 field type implementations

Key exports:

import { useAppForm, useFormFields } from '@/components/ui/tanstack-form';
  • useAppForm(config) — creates a form instance with defaultValues, validators, onSubmit
  • useFormFields<T>() — returns all 8 typed field components with name autocomplete from T
  • form.AppForm — context provider wrapper
  • form.Form<form> element that handles submit
  • form.SubmitButton — auto-disabled when form is invalid or submitting
  • form.AppField — low-level render prop for custom fields

Field Types

All fields accept: name, label, description, required, disabled, validators, listeners, className.

Component Props Notes
FormTextField type (text/email/number/password/tel/url), placeholder, min, max, step, maxLength For numbers use type='number'
FormTextareaField placeholder, rows, maxLength Multiline text
FormSelectField options: {value, label}[], placeholder Single select dropdown
FormCheckboxField options?: {value, label}[] Single checkbox or multi-checkbox group
FormSwitchField Toggle switch
FormRadioGroupField options: {value, label}[], orientation Radio button group
FormSliderField min, max, step Range slider
FormFileUploadField maxSize, maxFiles, accept Drag-and-drop with preview

Usage Patterns

Type-safe field components with name autocomplete:

const { FormTextField, FormSelectField } = useFormFields<OrderFormValues>();

<FormTextField name='customer' label='Customer' required placeholder='Name'
  validators={{ onBlur: z.string().min(2) }} />

<FormSelectField name='status' label='Status' required options={STATUS_OPTIONS}
  validators={{ onBlur: z.string().min(1) }} />

Pattern 2: form.AppField render prop

Full control for custom field rendering:

<form.AppField name='framework'>
  {(field) => (
    <field.FieldSet>
      <field.Field>
        <field.TextField label='Framework' />
      </field.Field>
      <field.FieldError />
    </field.FieldSet>
  )}
</form.AppField>

Pattern 3: Direct import (no type safety)

For quick prototyping:

import { FormTextField } from '@/components/ui/tanstack-form';
<FormTextField name='name' label='Name' />;

Validation Strategies

<FormTextField
  name='email'
  label='Email'
  validators={{
    onBlur: z.string().email('Invalid email') // Validates when field loses focus
  }}
/>

Form-level (catch-all on submit)

const form = useAppForm({
  validators: { onSubmit: orderSchema }, // Validates entire form on submit
  onSubmit: async ({ value }) => {
    /* ... */
  }
});

Async validation (server-side checks)

<FormTextField
  name='username'
  label='Username'
  validators={{
    onChangeAsync: async ({ value }) => {
      const exists = await checkUsername(value);
      return exists ? 'Username taken' : undefined;
    }
  }}
  asyncDebounceMs={500}
/>

Linked field validation

For dependent fields (e.g., confirm password):

<FormTextField
  name='confirmPassword'
  label='Confirm Password'
  validators={{
    onChangeListenTo: ['password'],
    onChange: ({ value, fieldApi }) => {
      const password = fieldApi.form.getFieldValue('password');
      return value !== password ? 'Passwords must match' : undefined;
    }
  }}
/>

Sheet/Dialog Forms

The key pattern for forms inside sheets or dialogs: give the <form.Form> an id, and use that id on the submit button's form attribute. This allows the submit button to live outside the form element (e.g., in SheetFooter).

<form.AppForm>
  <form.Form id='my-sheet-form' className='space-y-4'>
    {/* fields */}
  </form.Form>
</form.AppForm>;

{
  /* In SheetFooter — button is outside the <form> but still submits it */
}
<SheetFooter>
  <Button type='submit' form='my-sheet-form'>
    Save
  </Button>
</SheetFooter>;

On success, call onOpenChange(false) to close the sheet and form.reset() for create forms.


Multi-Step Forms

Use withFieldGroup + useAppForm with StepButton:

// Define field groups for each step
const Step1 = withFieldGroup({
  fields: ['name', 'email'],
  render: ({ form }) => {
    const { FormTextField } = useFormFields<FormValues>();
    return (
      <>
        <FormTextField name='name' label='Name' />
        <FormTextField name='email' label='Email' />
        <form.StepButton direction='next' label='Next' />
      </>
    );
  }
});

const Step2 = withFieldGroup({
  fields: ['address', 'city'],
  render: ({ form }) => {
    const { FormTextField } = useFormFields<FormValues>();
    return (
      <>
        <FormTextField name='address' label='Address' />
        <FormTextField name='city' label='City' />
        <form.StepButton direction='prev' label='Back' />
        <form.SubmitButton label='Submit' />
      </>
    );
  }
});

Use the useStepper hook from src/hooks/use-stepper.tsx to manage step state.


Advanced Patterns

Nested objects (dot notation)

<FormTextField name='address.street' label='Street' />
<FormTextField name='address.city' label='City' />

Dynamic array rows

<form.AppField name='items' mode='array'>
  {(field) => (
    <>
      {field.state.value.map((_, i) => (
        <form.AppField key={i} name={`items[${i}].name`}>
          {(subField) => <subField.TextField label={`Item ${i + 1}`} />}
        </form.AppField>
      ))}
      <Button onClick={() => field.pushValue({ name: '' })}>Add Row</Button>
    </>
  )}
</form.AppField>

Side effects with listeners

<FormSelectField
  name='country'
  label='Country'
  options={countryOptions}
  listeners={{
    onChange: ({ value }) => {
      // Reset city when country changes
      form.setFieldValue('city', '');
    }
  }}
/>

Custom field with form.AppField

For fields not covered by the built-in 8 types:

<form.AppField name='color'>
  {(field) => (
    <field.FieldSet>
      <Label>Pick a color</Label>
      <field.Field>
        <input
          type='color'
          value={field.state.value}
          onChange={(e) => field.handleChange(e.target.value)}
        />
      </field.Field>
      <field.FieldError />
    </field.FieldSet>
  )}
</form.AppField>

Form-level errors

Display errors that apply to the whole form (e.g., server errors):

import { FormErrors } from '@/components/ui/form-context';

<form.AppForm>
  <form.Form>
    <FormErrors /> {/* Renders form-level validation errors */}
    {/* fields... */}
  </form.Form>
</form.AppForm>;