Skip to main content
Markdown

TanStack Form

Build forms by letting TanStack Form manage values, validation, submission, and field state. Use Takeoff Field components for the visible field anatomy: label, description, invalid state, and error message.

Demo

The form below shows the field structure and error display behavior.


function BookingRequestDemo() {
  const formSchema = z.object({
    title: z
      .string()
      .min(5, 'Title must be at least 5 characters.')
      .max(48, 'Title must be at most 48 characters.'),
    cabin: z.string().min(1, 'Select a cabin.'),
    accepted: z.boolean().refine(Boolean, 'You must accept the terms.'),
  });

  const form = useForm({
    defaultValues: {
      title: '',
      cabin: '',
      accepted: false,
    },
    validators: {
      onChange: formSchema,
    },
    onSubmit: async () => {},
  });

  return (
    <form
      id="form-tan-demo"
      onSubmit={(event) => {
        event.preventDefault();
        event.stopPropagation();
        form.handleSubmit();
      }}
      className="grid w-full max-w-90 gap-3"
    >

      <form.Field name="title">
        {(field) => {
          const invalid = field.state.meta.isTouched && !!field.state.meta.errors.length;
          const error = field.state.meta.errors[0];
          const message = typeof error === 'string' ? error : error?.message;

          return (
            <Field invalid={invalid} required>
              <Field.Label>Request title</Field.Label>
              <Input>
                <Input.Field
                  name={field.name}
                  value={field.state.value}
                  onChange={(event) => field.handleChange(event.target.value)}
                  onBlur={field.handleBlur}
                  aria-invalid={invalid}
                  placeholder="Change return flight"
                  autoComplete="off"
                />
              </Input>
              {invalid ? <Field.ErrorMessage>{message}</Field.ErrorMessage> : <Field.Description>Use a short, specific title.</Field.Description>}
            </Field>
          );
        }}
      </form.Field>

      <form.Field name="cabin">
        {(field) => {
          const invalid = field.state.meta.isTouched && !!field.state.meta.errors.length;
          const error = field.state.meta.errors[0];
          const message = typeof error === 'string' ? error : error?.message;

          return (
            <Field invalid={invalid} required>
              <Field.Label>Cabin</Field.Label>
              <Select name={field.name} value={field.state.value} onChange={field.handleChange} invalid={invalid}>
                <Select.Trigger placeholder="Select cabin" aria-invalid={invalid} />
                <Select.Content>
                  <Select.Item value="economy" label="Economy">Economy</Select.Item>
                  <Select.Item value="business" label="Business">Business</Select.Item>
                </Select.Content>
              </Select>
              {invalid ? <Field.ErrorMessage>{message}</Field.ErrorMessage> : null}
            </Field>
          );
        }}
      </form.Field>

      <form.Field name="accepted">
        {(field) => {
          const invalid = field.state.meta.isTouched && !!field.state.meta.errors.length;
          const error = field.state.meta.errors[0];
          const message = typeof error === 'string' ? error : error?.message;

          return (
            <Field invalid={invalid} required>
              <div className="flex items-start gap-3">
                <Checkbox checked={field.state.value} onChange={field.handleChange} aria-invalid={invalid}>
                  <Checkbox.Indicator />
                </Checkbox>
                <div className="grid gap-1">
                  <Field.Label>I accept the booking terms</Field.Label>
                  {!invalid ? <Field.Description>Required before submission.</Field.Description> : null}
                </div>
              </div>
              {invalid ? <Field.ErrorMessage>{message}</Field.ErrorMessage> : null}
            </Field>
          );
        }}
      </form.Field>

      <div className="flex items-center justify-end gap-3">
        <Button type="button" variant="neutral" appearance="outlined" onClick={() => form.reset()}>
          Reset
        </Button>
        <Button type="submit" form="form-tan-demo">
          Continue
        </Button>
      </div>
    </form>
  );
}

render(<BookingRequestDemo />);

Approach

Use TanStack Form's useForm hook to create the form instance. Use form.Field when a component is composed or controlled, because the render function gives you a clear place to map form state into Takeoff props.

  • field.state.value contains the current field value.
  • field.handleChange and field.handleBlur connect the component to the form.
  • field.state.meta.errors contains validation messages.
  • Field invalid={...} drives the field-level visual state.
  • aria-invalid={...} belongs on the interactive control.

Anatomy

<form.Field name="title">
{field => {
const invalid =
field.state.meta.isTouched && !!field.state.meta.errors.length;

return (
<Field invalid={invalid} required>
<Field.Label>Request title</Field.Label>
<Input>
<Input.Field
name={field.name}
value={field.state.value}
onChange={event => field.handleChange(event.target.value)}
onBlur={field.handleBlur}
aria-invalid={invalid}
autoComplete="off"
/>
</Input>
<Field.Description>Use a short, specific title.</Field.Description>
{invalid ? (
<Field.ErrorMessage>
{String(field.state.meta.errors[0])}
</Field.ErrorMessage>
) : null}
</Field>
);
}}
</form.Field>

Form

Create a Form Schema

Define the shape and validation messages in your validators. TanStack Form lets you attach validation where the field lives, or provide a form-level schema with a Standard Schema library such as Zod.

import * as z from 'zod';

const formSchema = z.object({
title: z
.string()
.min(5, 'Title must be at least 5 characters.')
.max(48, 'Title must be at most 48 characters.'),
cabin: z.string().min(1, 'Select a cabin.'),
accepted: z.boolean().refine(Boolean, 'You must accept the terms.'),
});

type FormValues = z.infer<typeof formSchema>;

Set Up the Form

Create the form instance with useForm, pass the schema to validators, and handle submit at the form level.

import { useForm } from '@tanstack/react-form';
import * as z from 'zod';

export function BookingRequestForm() {
const form = useForm({
defaultValues: {
title: '',
cabin: '',
accepted: false,
},
validators: {
onChange: formSchema,
},
onSubmit: async ({ value }) => {
console.log(value);
},
});

return (
<form
onSubmit={event => {
event.preventDefault();
event.stopPropagation();
form.handleSubmit();
}}
>
{/* Build the form here */}
</form>
);
}

Build the Form

Use form.Field for each field and map its state into Field.

import { useForm } from '@tanstack/react-form';
import { Button, Checkbox, Field, Input, Select } from '@takeoff-ui/react-spar';

export function BookingRequestForm() {
const form = useForm({
defaultValues: {
title: '',
cabin: '',
accepted: false,
},
validators: {
onChange: formSchema,
},
onSubmit: async ({ value }) => {
console.log(value);
},
});

return (
<form
onSubmit={event => {
event.preventDefault();
event.stopPropagation();
form.handleSubmit();
}}
className="grid gap-4"
>
<form.Field
name="title"
validators={{
onBlur: ({ value }) =>
!value ? 'Request title is required' : undefined,
}}
>
{field => {
const invalid =
field.state.meta.isTouched && !!field.state.meta.errors.length;

return (
<Field invalid={invalid} required>
<Field.Label>Request title</Field.Label>
<Input>
<Input.Field
name={field.name}
value={field.state.value}
onChange={event => field.handleChange(event.target.value)}
onBlur={field.handleBlur}
aria-invalid={invalid}
autoComplete="off"
/>
</Input>
{invalid ? (
<Field.ErrorMessage>
{String(field.state.meta.errors[0])}
</Field.ErrorMessage>
) : null}
</Field>
);
}}
</form.Field>

<form.Field
name="cabin"
validators={{
onSubmit: ({ value }) => (!value ? 'Select a cabin' : undefined),
}}
>
{field => {
const invalid = !!field.state.meta.errors.length;

return (
<Field invalid={invalid} required>
<Field.Label>Cabin</Field.Label>
<Select
name={field.name}
value={field.state.value}
onChange={field.handleChange}
invalid={invalid}
>
<Select.Trigger
placeholder="Select cabin"
aria-invalid={invalid}
/>
<Select.Content>
<Select.Item value="economy" label="Economy">
Economy
</Select.Item>
<Select.Item value="business" label="Business">
Business
</Select.Item>
</Select.Content>
</Select>
{invalid ? (
<Field.ErrorMessage>
{String(field.state.meta.errors[0])}
</Field.ErrorMessage>
) : null}
</Field>
);
}}
</form.Field>

<form.Field
name="accepted"
validators={{
onSubmit: ({ value }) =>
value ? undefined : 'You must accept the terms',
}}
>
{field => {
const invalid = !!field.state.meta.errors.length;

return (
<Field invalid={invalid} required>
<Checkbox
checked={field.state.value}
onChange={field.handleChange}
aria-invalid={invalid}
>
<Checkbox.Indicator />
</Checkbox>
<Field.Label>I accept the booking terms</Field.Label>
{invalid ? (
<Field.ErrorMessage>
{String(field.state.meta.errors[0])}
</Field.ErrorMessage>
) : null}
</Field>
);
}}
</form.Field>

<Button type="submit">Continue</Button>
</form>
);
}

Done

When the user submits, TanStack Form validates the data and calls your submit handler with the final values. Invalid fields receive errors through field.state.meta.errors, and each field can render its own Field.ErrorMessage.

Validation

Client-Side Validation

Validation lives in the field validators. Field rendering does not need to know where the error came from; it only reads field.state.meta.errors.

<form.Field
name="title"
validators={{
onBlur: ({ value }) => (!value ? 'Request title is required' : undefined),
}}
>
{/* field render */}
</form.Field>

Schema Validation with Zod

TanStack Form supports Standard Schema validators, so you can pass a Zod schema directly to the form or to an individual field validator. This keeps the data shape and validation rules in one place.

import * as z from 'zod';

const formSchema = z.object({
title: z.string().min(5, 'Title must be at least 5 characters.'),
cabin: z.string().min(1, 'Select a cabin.'),
accepted: z.boolean().refine(Boolean, 'You must accept the terms.'),
});

const form = useForm({
defaultValues: {
title: '',
cabin: '',
accepted: false,
},
validators: {
onChange: formSchema,
},
onSubmit: async ({ value }) => {
console.log(value);
},
});

Validation Modes

TanStack Form lets you choose when each validator runs:

ModeDescription
onChangeValidate on every change.
onBlurValidate when a field loses focus.
onSubmitValidate when the form is submitted.
onMountValidate when the form or field mounts.
<form.Field
name="email"
validators={{
onBlur: ({ value }) => (!value ? 'Email is required' : undefined),
onSubmit: ({ value }) =>
!value.includes('@') ? 'Enter a valid email' : undefined,
}}
>
{/* field render */}
</form.Field>

Displaying Errors

Display errors next to the field they belong to. For styling and accessibility:

  • Compute invalid from the field meta.
  • Pass that value to Field invalid.
  • Pass the same value to the interactive control with aria-invalid.
  • Render Field.ErrorMessage only when the field is invalid.
const invalid = field.state.meta.isTouched && !!field.state.meta.errors.length;
const error = field.state.meta.errors[0];

return (
<Field invalid={invalid}>
<Field.Label>Request title</Field.Label>
<Input>
<Input.Field
name={field.name}
value={field.state.value}
onChange={event => field.handleChange(event.target.value)}
onBlur={field.handleBlur}
aria-invalid={invalid}
/>
</Input>
{invalid ? <Field.ErrorMessage>{error.message}</Field.ErrorMessage> : null}
</Field>
);

If you use field-level string validators, render String(field.state.meta.errors[0]). If you use a schema validator such as Zod, render the structured error message.

Working with Different Field Types

The field anatomy stays the same. Only the value mapping changes:

ComponentMapping
Input.FieldUse value={field.state.value} and pass event.target.value to field.handleChange.
SelectUse value={field.state.value} and onChange={field.handleChange} on Select; put aria-invalid on Select.Trigger.
CheckboxUse checked={field.state.value} and onChange={field.handleChange}.
SwitchUse checked={field.state.value} and onChange={field.handleChange}.
RadioUse value={field.state.value} and onChange={field.handleChange} on the Radio root.

Array Fields

Use mode="array" when the form needs a repeatable set of fields such as passengers, baggage items, or contact methods. Keep the array state in TanStack Form, then render one Field per item.

The live demo below mirrors the same add, remove, reset, and submit flow as the code sample.


function PassengerArrayDemo() {
  const form = useForm({
    defaultValues: {
      passengers: [{ fullName: '' }],
    },
    onSubmit: async () => {},
  });

  return (
    <form
      onSubmit={(event) => {
        event.preventDefault();
        event.stopPropagation();
        form.handleSubmit();
      }}
      className="grid w-full max-w-90 gap-3"
    >
      <form.Field name="passengers" mode="array">
        {(passengersField) => (
          <>
            {passengersField.state.value.map((_, index) => {
              const canRemove = passengersField.state.value.length > 1;

              return (
                <form.Field
                  key={index}
                  name={'passengers[' + index + '].fullName'}
                  validators={{
                    onChange: ({ value }) => (!value.trim() ? 'Passenger name is required.' : undefined),
                  }}
                >
                  {(field) => {
                    const invalid = field.state.meta.isTouched && !!field.state.meta.errors.length;
                    const error = field.state.meta.errors[0];
                    const message = typeof error === 'string' ? error : error?.message;

                    return (
                      <Field invalid={invalid} required>
                        <Field.Label>Passenger {index + 1}</Field.Label>
                        <Input>
                          <Input.Field
                            name={field.name}
                            value={field.state.value}
                            onChange={(event) => field.handleChange(event.target.value)}
                            onBlur={field.handleBlur}
                            aria-invalid={invalid}
                            autoComplete="name"
                            placeholder="Ada Lovelace"
                          />
                          <Input.Suffix>
                            <button
                              type="button"
                              aria-label={canRemove ? 'Remove passenger ' + (index + 1) : 'At least one passenger is required'}
                              disabled={!canRemove}
                              onClick={() => passengersField.removeValue(index)}
                              className="inline-flex h-5 w-5 justify-center"
                            >
                              x
                            </button>
                          </Input.Suffix>
                        </Input>
                        {invalid ? <Field.ErrorMessage>{message}</Field.ErrorMessage> : null}
                      </Field>
                    );
                  }}
                </form.Field>
              );
            })}
            <Button
              type="button"
              variant="neutral"
              appearance="outlined"
              onClick={() => passengersField.pushValue({ fullName: '' })}
              className="w-full"
            >
              Add passenger
            </Button>

            <div className="flex w-full justify-end border-t border-gray-300 pt-3">
              <Button type="submit">Save passengers</Button>
              <Button
                type="button"
                variant="neutral"
                appearance="text"
                onClick={() => form.reset({ passengers: [{ fullName: '' }] })}
              >
                Reset
              </Button>
            </div>
          </>
        )}
      </form.Field>
    </form>
  );
}

render(<PassengerArrayDemo />);

Notes

  • Keep validation and value transformation in TanStack Form.
  • Pass visual validation state to Field invalid.
  • Put aria-invalid on the interactive control.
  • Prefer Field.ErrorMessage for new examples. Field.ErrorMessage remains supported.