Back to Shark UI

TanStack Form

docs/forms/tanstack-form

latest25.9 KB
Original Source

Sections

Components

Utilities

Forms

Hooks

TanStack Form

Copy Markdown

Build forms in React using TanStack Form and Zod.

Docs

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.

Browser validation disabled

For the purposes of this demo, browser validation is disabled to illustrate schema validation. In production, keep native validation enabled when appropriate.

PreviewCode

Bug Report

Help us improve by reporting bugs you encounter.

Bug Title

Description

0/100 characters

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

ResetSubmit

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.

Note: TanStack Form works with Zod and other Standard Schema libraries via its validators API.

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();
      }}
    >
    </form>
  );
};

Build#

build the form using the form.Field from TanStack Form and the Shark Field.

Expand

"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>
  );
};

Expand

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.

PreviewCode

Profile Settings

Update your profile information below.

Username

This is your public display name. Must be between 3 and 10 characters. Must only contain letters, numbers, and underscores.

ResetSave

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.

PreviewCode

Personalization

Customize your experience by telling us more about yourself.

More about you

Tell us more about yourself. This will be used to help us personalize your experience.

ResetSave

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.

PreviewCode

Language Preferences

Select your preferred spoken language.

Spoken Language

For best results, select the language you speak.

AutoEnglishSpanishFrenchGermanItalianChineseJapanese

ResetSave

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.

PreviewCode

Language Preferences

Select your preferred spoken language.

Spoken Language

For best results, select the language you speak.

Select

AutoEnglishSpanishFrenchGermanItalianChineseJapanese

ResetSave

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.

PreviewCode

Notifications

Manage your notification preferences.

Responses

Get notified for requests that take time, like research or image generation.

Push notifications

Tasks

Get notified when tasks you've created have updates.

Push notifications

Email notifications

ResetSave

Radio group#

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

PreviewCode

Subscription Plan

See pricing and features for each plan.

Plan

You can upgrade or downgrade your plan at any time.

Starter (100K tokens/month)

For everyday use with basic features.

Pro (1M tokens/month)

For advanced AI usage with more features.

Enterprise (Unlimited tokens)

For large teams and heavy usage.

ResetSave

Switch#

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

PreviewCode

Security Settings

Manage your account security preferences.

Multi-factor authentication

Enable multi-factor authentication to secure your account.

ResetSave

NumberInput#

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

PreviewCode

Salary expectations

Share your target gross annual salary in EUR.

Expected annual salary

EUR

ResetSave

Slider#

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

PreviewCode

Listings filter

Set a price range so we only show results in your budget.

Price range$0 - $10000

Drag each thumb to set the lower and upper bound.

ResetApply

Combobox#

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

PreviewCode

Role setup

Tell us which team you work with most so we can tailor workflows.

Primary department

Type to filter the list, then pick one option.

ResetSave

Autocomplete#

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

PreviewCode

Tech stack

Select the technology you're most familiar with.

Primary technology

Type to filter the list, then pick one option.

ResetSave

Date Picker#

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

PreviewCode

Scheduling

Pick a day that works for a first-round interview. We will confirm by email.

Preferred interview date

Pick a date

ResetSave

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.

PreviewCode

Account recovery

Enter one of your single-use backup codes if you cannot access your authenticator app.

Backup code

Codes can only be used once.

ResetSave

Rating#

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

PreviewCode

Quick feedback

Help us improve by sharing how likely you are to recommend Shark UI to a colleague.

How useful is this project?

1 = not likely, 5 = very likely.

ResetSave

File Upload#

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

PreviewCode

Application materials

Upload your résumé or CV.

Résumé

Accepted formats: PDF and Word documents.

ResetSave

Complex Forms#

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

PreviewCode

You're almost there!

Choose your subscription plan and billing period.

Subscription plan

Choose your subscription plan.

Basic

For individuals and small teams

Pro

For businesses with higher demands

Billing period

Select

MonthlyYearly

Choose how often you want to be billed.

Add-ons

Select additional features you'd like to include.

Analytics

Advanced analytics and reporting

Backup

Automated daily backups

Priority Support

24/7 premium customer support

Email notifications

Receive email updates about your subscription.

Save preferencesReset

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.

PreviewCode

Contact emails

Manage your contact email addresses.

Email addresses

Add up to 5 email addresses where we can contact you.

Add email address

ResetSave

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."),
});

[

Previous page

React Hook Form ](/docs/forms/react-hook-form)[

Next page

useIsMobile ](/docs/hooks/use-is-mobile)

On This Page

DemoApproachAnatomyFormCreate a schemaSetupBuildDoneValidationClient-sideModesDisplaying ErrorsDifferent types of fieldsInputTextareaNativeSelectSelectCheckboxRadio groupSwitchNumberInputSliderComboboxAutocompleteDate PickerInput OTPRatingFile UploadComplex FormsResetting the FormArray FieldsArray Field StructureNested FieldsAdding ItemsRemoving ItemsRemoving itemsArray validation