Skip to content
shadcn.io is not affiliated with official shadcn/ui

Shadcn TanStack Form for React and Tailwind

Build forms in React using TanStack Form and Zod.

This guide will cover building forms using the Field component, adding schema validation with Zod, handling errors, ensuring accessibility, and more.

Demo

We’ll build a form with a text input and textarea. When you submit, the form data is validated and any errors will be shown.

Bug Report

Help us improve by reporting bugs you encounter.

0/100 characters

Include steps to reproduce, expected behavior, and what actually happened.

Approach

This form uses TanStack Form for state and Zod for validation. We'll build forms using the Field component, which gives you complete flexibility over the markup and styling.

  • Uses TanStack Form's useForm hook for form state management.
  • Uses form.Field with a render function for controlled inputs.
  • Uses the Field components for building accessible forms.
  • Uses client-side validation by passing your Zod schema into validators.

Anatomy

Here's a basic example of a form using TanStack Form with the Field component.

<form
  onSubmit={(e) => {
    e.preventDefault()
    form.handleSubmit()
  }}
>
  <FieldGroup>
    <form.Field
      name="title"
      children={(field) => (
          <Field invalid={!field.state.meta.isValid}>
            <FieldLabel>Bug Title</FieldLabel>
            <Input
              name={field.name}
              value={field.state.value}
              onBlur={field.handleBlur}
              onChange={(e) => field.handleChange(e.target.value)}
              placeholder="Login button not working on mobile"
              autoComplete="off"
            />
            <FieldDescription>
              Provide a concise title for your bug report.
            </FieldDescription>
            <FieldError>
              {field.state.meta.errors
                .map((e) => e?.message || e)
                .join(", ")}
            </FieldError>
          </Field>
        )}
    />
  </FieldGroup>
  <Button type="submit">Submit</Button>
</form>

Form

Create a schema

Define your form shape with a Zod schema.

form.tsx
import * as z from "zod"

const formSchema = z.object({
  title: z
    .string()
    .min(5, "Bug title must be at least 5 characters.")
    .max(32, "Bug title must be at most 32 characters."),
  description: z
    .string()
    .min(20, "Description must be at least 20 characters.")
    .max(100, "Description must be at most 100 characters."),
})

Setup

  • Create the form with useForm from TanStack Form and pass your schema to the onSubmit option.
  • Use e.preventDefault() and e.stopPropagation() before calling form.handleSubmit()
form.tsx
import { useForm } from "@tanstack/react-form";
import * as z from "zod";

const formSchema = z.object({
  // ...
});

export const ExampleForm = () => {
  const form = useForm({
    defaultValues: {
      title: "",
      description: "",
    },
    validators: { onSubmit: formSchema },
    onSubmit: async ({ value }) => {
      console.log(value);
    },
  });

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

Build

build the form using the form.Field from TanStack Form and the shadcn.io Field.

"use client";

import { toast } from "@registry/react/components/toast";
import { useForm } from "@tanstack/react-form";
import * as z from "zod";
import { Button } from "@/components/ui/button";
import {
  Card,
  CardContent,
  CardFooter,
  CardHeader,
} from "@/components/ui/card";
import {
  Field,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import {
  InputGroup,
  InputGroupAddon,
  InputGroupText,
  InputGroupTextarea,
} from "@/components/ui/input-group";

const formSchema = z.object({
  title: z
    .string()
    .min(5, "Bug title must be at least 5 characters.")
    .max(32, "Bug title must be at most 32 characters."),
  description: z
    .string()
    .min(20, "Description must be at least 20 characters.")
    .max(100, "Description must be at most 100 characters."),
});

export const BugReportForm = () => {
  const form = useForm({
    defaultValues: {
      title: "",
      description: "",
    },
    validators: {
      onSubmit: formSchema,
    },
    onSubmit: ({ value }) => {
      toast.info({
        id: "bug-report-submitted",
        title: "Bug submitted",
        description: (
          <pre className="mt-2">
            <code>{JSON.stringify(value, null, 2)}</code>
          </pre>
        ),
      });
    },
  });

  return (
    <Card asChild className="w-full sm:max-w-md">
      <form
        onSubmit={(e) => {
          e.preventDefault();
          e.stopPropagation();
          form.handleSubmit();
        }}
      >
        <CardHeader
          description="Help us improve by reporting bugs you encounter."
          title="Bug Report"
        />
        <CardContent>
          <FieldGroup>
            <form.Field
              children={(field) => (
                <Field invalid={!field.state.meta.isValid}>
                  <FieldLabel>Bug Title</FieldLabel>
                  <Input
                    autoComplete="off"
                    name={field.name}
                    onBlur={field.handleBlur}
                    onChange={(e) => field.handleChange(e.target.value)}
                    placeholder="Login button not working on mobile"
                    value={field.state.value}
                  />
                  <FieldError>
                    {field.state.meta.errors.map((e) => e?.message).join(", ")}
                  </FieldError>
                </Field>
              )}
              name="title"
            />
            <form.Field
              children={(field) => (
                <Field invalid={!field.state.meta.isValid}>
                  <FieldLabel>Description</FieldLabel>
                  <InputGroup>
                    <InputGroupTextarea
                      className="min-h-24 resize-none"
                      name={field.name}
                      onBlur={field.handleBlur}
                      onChange={(e) => field.handleChange(e.target.value)}
                      placeholder="I'm having an issue with the login button on mobile."
                      rows={6}
                      value={field.state.value}
                    />
                    <InputGroupAddon align="block-end">
                      <InputGroupText className="tabular-nums">
                        {field.state.value.length}/100 characters
                      </InputGroupText>
                    </InputGroupAddon>
                  </InputGroup>
                  <FieldDescription>
                    Include steps to reproduce, expected behavior, and what
                    actually happened.
                  </FieldDescription>
                  <FieldError>
                    {field.state.meta.errors.map((e) => e?.message).join(", ")}
                  </FieldError>
                </Field>
              )}
              name="description"
            />
          </FieldGroup>
        </CardContent>
        <CardFooter>
          <Button onClick={() => form.reset()} variant="outline">
            Reset
          </Button>
          <Button type="submit">Submit</Button>
        </CardFooter>
      </form>
    </Card>
  );
};

Done

That's it. You now have a fully accessible form with client-side validation.

When you submit the form, the onSubmit handler receives validated values. If the form is invalid, TanStack Form exposes errors on field.state.meta.errors for FieldError.

Validation

Client-side

TanStack Form validates your form data using the Zod schema. Define a schema and pass it to the validators option of the useForm hook.

example-form.tsx
import { useForm } from "@tanstack/react-form"
import * as z from "zod"

const formSchema = z.object({
  title: z.string(),
  description: z.string().optional(),
})

export const ExampleForm = () => {
  const form = useForm({
    defaultValues: {
      title: "",
      description: "",
    },
    validators: { onSubmit: formSchema },
    onSubmit: async () => {},
  })
}

Modes

Configure when validation runs via the validators option:

form.tsx
const form = useForm({
  validators: { onSubmit: formSchema },
})
ModeDescription
"onChange"Validation triggers on every change.
"onBlur"Validation triggers on blur.
"onSubmit"Validation triggers on submit.

Displaying Errors

Display errors next to the field using FieldError. For styling and accessibility:

  • Add the invalid prop to the Field component.
  • Don't need to add the invalid prop to the form control such as Input, Checkbox, etc.
form.tsx
<form.Field
  name="email"
  children={(field) => (
    <Field invalid={!field.state.meta.isValid}>
      <FieldLabel>Email</FieldLabel>
      <Input
        name={field.name}
        onBlur={field.handleBlur}
        onChange={(e) => field.handleChange(e.target.value)}
        type="email"
        value={field.state.value}
      />
      <FieldError>
        {field.state.meta.errors.map((e) => e?.message).join(", ")}
      </FieldError>
    </Field>
  )}
/>

Different types of fields

Input

  • Bind field.state.value and field.handleChange to Input.
  • Add the invalid prop to the Field component and pass the error message to the FieldError component.

Textarea

  • Bind field.state.value and field.handleChange to Textarea.
  • Add the invalid prop to the Field component and pass the error message to the FieldError component.

NativeSelect

  • Bind field.state.value and field.handleChange to NativeSelect.
  • Add the invalid prop to the Field component and pass the error message to the FieldError component.

Select

  • Wire value and onValueChange on Select. For overlays, call field.handleBlur() from onInteractOutside when your example needs blur sync.
  • Add the invalid prop to the Field component and pass the error message to the FieldError component.

Checkbox

  • Wire field.state.value and field.handleChange to Checkbox.
  • Add the invalid prop to the Field component and pass the error message to the FieldError component.
  • For checkbox arrays, use mode="array" on the form.Field component and TanStack Form's array helpers.
  • Remember to add data-slot="checkbox-group" to the FieldGroup component for proper styling and spacing.

Radio group

  • Wire field.state.value and field.handleChange to RadioGroup.
  • Add the invalid prop to the Field component and pass the FieldError component.

Switch

  • Use field.state.value and field.handleChange with Switch.
  • Add the invalid prop to the Field component and pass the FieldError component.

NumberInput

  • Wire field.state.value and field.handleChange to NumberInput.
  • Add the invalid prop to the Field component and pass the FieldError component.

Slider

  • Wire field.state.value and field.handleChange to Slider.
  • Add the invalid prop to the Field component and pass the FieldError component.

Combobox

  • Wire field.state.value and field.handleChange to Combobox.
  • Add the invalid prop to the Field component and pass the FieldError component.

Autocomplete

  • Wire field.state.value and field.handleChange to Autocomplete.
  • Add the invalid prop to the Field component and pass the FieldError component.

Date Picker

  • Wire field.state.value and field.handleChange to DatePicker.
  • Add the invalid prop to the Field component and pass the FieldError component.

Input OTP

  • Use field.state.value (as string[]) and field.handleChange with InputOTP.
  • Add the invalid prop to the Field component and pass the FieldError component.

Rating

  • Wire field.state.value and field.handleChange to Rating.
  • Add the invalid prop to the Field component and pass the FieldError component.

File Upload

  • Wire onFileAccept to sync files into form state.
  • Add the invalid prop to the Field component and pass the FieldError component.

Complex Forms

Here is an example of a more complex form with multiple fields and validation.

Resetting the Form

Use form.reset() to reset the form to its default values.

<Button type="button" variant="outline" onClick={() => form.reset()}>
  Reset
</Button>

Array Fields

TanStack Form provides powerful array field management with mode="array". This allows you to dynamically add, remove, and update array items with full validation support.

Array Field Structure

Use mode="array" on the parent field to enable array field management.

form.tsx
<form.Field
  name="emails"
  mode="array"
  children={(field) => (
    <FieldSet>
      <FieldLegend variant="label">Email Addresses</FieldLegend>
      <FieldDescription>
        Add up to 5 email addresses where we can contact you.
      </FieldDescription>
      <FieldGroup>
        {field.state.value.map((_, index) => (
          // Nested field for each array item
        ))}
      </FieldGroup>
    </FieldSet>
  )}
/>

Nested Fields

Access individual array items using bracket notation: fieldName[index].propertyName.

form.tsx
<form.Field
  name={`emails[${index}].address`}
  children={(subField) => (
    <Field orientation="horizontal" invalid={!subField.state.meta.isValid}>
      <FieldContent>
        <InputGroup>
          <InputGroupInput
            name={subField.name}
            value={subField.state.value}
            onBlur={subField.handleBlur}
            onChange={(e) => subField.handleChange(e.target.value)}
            placeholder="[email protected]"
            type="email"
          />
          {field.state.value.length > 1 && (
            <InputGroupAddon align="inline-end">
              <InputGroupButton
                type="button"
                variant="ghost"
                size="icon-xs"
                onClick={() => field.removeValue(index)}
                aria-label={`Remove email ${index + 1}`}
              >
                <XIcon />
              </InputGroupButton>
            </InputGroupAddon>
          )}
        </InputGroup>
        <FieldError>
          {subField.state.meta.errors.map((e) => e?.message).join(", ")}
        </FieldError>
      </FieldContent>
    </Field>
  )}
/>

Adding Items

Use field.pushValue(item) to add items to an array field. You can disable the button when the array reaches its maximum length.

form.tsx
<Button
  type="button"
  variant="outline"
  size="sm"
  onClick={() => field.pushValue({ address: "" })}
  disabled={field.state.value.length >= 5}
>
  Add Email Address
</Button>

Removing Items

Use field.removeValue(index) to remove items from an array field. You can conditionally show the remove button only when there's more than one item.

form.tsx
{
  field.state.value.length > 1 && (
    <InputGroupButton
      onClick={() => field.removeValue(index)}
      aria-label={`Remove email ${index + 1}`}
    >
      <XIcon />
    </InputGroupButton>
  )
}

Removing items

Use removeValue(index) on the array field.

form.tsx
{
  emailsField.state.value.length > 1 && (
    <InputGroupAddon align="inline-end">
      <InputGroupButton
        type="button"
        variant="ghost"
        size="icon-xs"
        onClick={() => emailsField.removeValue(index)}
        aria-label={`Remove email ${index + 1}`}
      >
        <XIcon />
      </InputGroupButton>
    </InputGroupAddon>
  );
}

Array validation

Validate arrays with Zod’s z.array() and .min() / .max().

form.tsx
const formSchema = z.object({
  emails: z
    .array(
      z.object({
        address: z.string().email("Enter a valid email address."),
      })
    )
    .min(1, "Add at least one email address.")
    .max(5, "You can add up to 5 email addresses."),
});