Lesson 14

UI Library Integration

TanStack Form is headless — it manages state and validation but renders nothing. That means you wire it to whatever UI library your project already uses. The good news: the pattern is always the same.

The General Pattern

Most UI components accept three props: value, onChange, and onBlur. TanStack Form exposes exactly those things through the field object:

<form.Field
  name="email"
  children={(field) => (
    <SomeUIInput
      value={field.state.value}
      onChange={(val) => field.handleChange(val)}
      onBlur={field.handleBlur}
    />
  )}
/>

field.state.value is the current value. field.handleChange updates it. field.handleBlur marks the field as touched. That's the whole adapter layer.

shadcn/ui Input

shadcn/ui components use standard HTML input props, so onChange gives you a DOM event:

import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'

<form.Field
  name="email"
  children={(field) => (
    <div className="space-y-2">
      <Label htmlFor="email">Email</Label>
      <Input
        id="email"
        value={field.state.value}
        onChange={(e) => field.handleChange(e.target.value)}
        onBlur={field.handleBlur}
      />
    </div>
  )}
/>

The only difference from a plain <input> is that you're using the styled component. The wiring is identical.

Select / Combobox Components

Many select components skip the DOM event entirely and pass the selected value directly to a callback:

<form.Field
  name="country"
  children={(field) => (
    <Select
      value={field.state.value}
      onValueChange={(val) => field.handleChange(val)}
    >
      {/* options */}
    </Select>
  )}
/>

Notice onValueChange instead of onChange. That's shadcn/ui's Select API — it hands you the value, not an event. Always check a component's API docs to see what the change callback receives.

Checkbox Components

Checkboxes deal in booleans, not strings:

<form.Field
  name="agreeToTerms"
  children={(field) => (
    <Checkbox
      checked={field.state.value}
      onCheckedChange={(checked) => field.handleChange(Boolean(checked))}
    />
  )}
/>

The Boolean() cast handles the fact that some checkbox components pass "indeterminate" in addition to true/false. Coercing it to a boolean keeps your form state clean.

The Key Insight

Adapting any UI component to TanStack Form takes two steps:

  1. How does the component expose its current value? Wire that to field.state.value.
  2. How does it notify you of changes? Wire that callback to field.handleChange.

That's it. The adapter is always thin. If a component has an unusual API, read its docs, find those two things, and map them. Everything else — validation, error display, touched state — works the same regardless of which component library you're using.

Complete Example

Here's a full form using plain HTML elements that demonstrates all three patterns — text input, select, and checkbox:

import { useForm } from '@tanstack/react-form'

function SettingsForm() {
  const form = useForm({
    defaultValues: {
      displayName: '',
      theme: 'light',
      emailNotifications: false,
    },
    onSubmit: async ({ value }) => {
      console.log('Settings saved:', value)
    },
  })

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault()
        form.handleSubmit()
      }}
    >
      <form.Field
        name="displayName"
        children={(field) => (
          <div>
            <label htmlFor="displayName">Display Name</label>
            <input
              id="displayName"
              value={field.state.value}
              onChange={(e) => field.handleChange(e.target.value)}
              onBlur={field.handleBlur}
            />
          </div>
        )}
      />

      <form.Field
        name="theme"
        children={(field) => (
          <div>
            <label htmlFor="theme">Theme</label>
            <select
              id="theme"
              value={field.state.value}
              onChange={(e) => field.handleChange(e.target.value)}
            >
              <option value="light">Light</option>
              <option value="dark">Dark</option>
              <option value="system">System</option>
            </select>
          </div>
        )}
      />

      <form.Field
        name="emailNotifications"
        children={(field) => (
          <div>
            <label>
              <input
                type="checkbox"
                checked={field.state.value}
                onChange={(e) => field.handleChange(e.target.checked)}
              />
              Email notifications
            </label>
          </div>
        )}
      />

      <button type="submit">Save Settings</button>
    </form>
  )
}

The same patterns apply when you swap <input> for a shadcn/ui <Input>, <select> for a Radix <Select>, or the checkbox for any third-party toggle. The wiring stays the same.