TanStack Form

PreviousNext

A complete form management solution using TanStack Form with Zod validation, supporting all input types including arrays and nested objects.

Installation

pnpm dlx shadcn@latest add https://ui.nowts.app/r/tanstack-form.json

About

The TanStack Form component is a complete form management solution built on top of TanStack Form with Zod schema validation, providing type-safe forms with automatic field validation and error handling.

Features

  • Full TypeScript support: Type-safe form data with automatic inference from Zod schemas
  • Zod validation: Schema-based validation with custom error messages
  • All input types: Input, Textarea, Select, Checkbox, RadioGroup, Switch
  • Array fields: Dynamic arrays with add/remove functionality
  • Nested objects: Deep object paths with type-safe field names
  • Field helpers: Pre-built helpers for all input types
  • Validation modes: onBlur, onChange, or onSubmit validation
  • Form state: Access to submitting, valid, dirty states

Usage

Basic form with Input

"use client"
 
import { z } from "zod"
 
import { Form, useForm } from "@/registry/nowts/ui/tanstack-form"
 
const schema = z.object({
  email: z.string().email("Invalid email address"),
  name: z.string().min(2, "Name must be at least 2 characters"),
})
 
export function BasicForm() {
  const form = useForm({
    schema,
    defaultValues: {
      email: "",
      name: "",
    },
    onSubmit: async (values) => {
      console.log(values)
    },
  })
 
  return (
    <Form form={form} className="space-y-4">
      <form.AppField name="name">
        {(field) => (
          <field.Field>
            <field.Label>Name</field.Label>
            <field.Content>
              <field.Input placeholder="John Doe" />
              <field.Message />
            </field.Content>
          </field.Field>
        )}
      </form.AppField>
 
      <form.AppField name="email">
        {(field) => (
          <field.Field>
            <field.Label>Email</field.Label>
            <field.Content>
              <field.Input type="email" placeholder="john@example.com" />
              <field.Message />
            </field.Content>
          </field.Field>
        )}
      </form.AppField>
 
      <form.SubmitButton>Submit</form.SubmitButton>
    </Form>
  )
}

Textarea field

import { z } from "zod"
 
import { Form, useForm } from "@/registry/nowts/ui/tanstack-form"
 
const schema = z.object({
  bio: z
    .string()
    .min(10, "Bio must be at least 10 characters")
    .max(500, "Bio must be at most 500 characters"),
})
 
// Inside your form:
<form.AppField name="bio">
  {(field) => (
    <field.Field>
      <field.Label>Bio</field.Label>
      <field.Content>
        <field.Textarea placeholder="Tell us about yourself..." rows={4} />
        <field.Description>
          A brief description about yourself (10-500 characters)
        </field.Description>
        <field.Message />
      </field.Content>
    </field.Field>
  )}
</form.AppField>

Select field

import { z } from "zod"
 
import {
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select"
import { Form, useForm } from "@/registry/nowts/ui/tanstack-form"
 
const schema = z.object({
  role: z.string().min(1, "Please select a role"),
})
 
// Inside your form:
<form.AppField name="role">
  {(field) => (
    <field.Field>
      <field.Label>Role</field.Label>
      <field.Content>
        <field.Select>
          <SelectTrigger>
            <SelectValue placeholder="Select your role" />
          </SelectTrigger>
          <SelectContent>
            <SelectItem value="admin">Admin</SelectItem>
            <SelectItem value="user">User</SelectItem>
            <SelectItem value="guest">Guest</SelectItem>
          </SelectContent>
        </field.Select>
        <field.Message />
      </field.Content>
    </field.Field>
  )}
</form.AppField>

Checkbox field

import { z } from "zod"
 
import { Form, useForm } from "@/registry/nowts/ui/tanstack-form"
 
const schema = z.object({
  consent: z
    .boolean()
    .refine((val) => val === true, "You must accept the terms to continue"),
})
 
// Inside your form:
<form.AppField name="consent">
  {(field) => (
    <field.Field>
      <field.Content>
        <div className="flex items-start gap-2">
          <field.Checkbox id="consent" />
          <div className="flex flex-col gap-1">
            <field.Label htmlFor="consent" className="font-normal">
              I agree to receive marketing emails and accept the terms and
              conditions
            </field.Label>
            <field.Message />
          </div>
        </div>
      </field.Content>
    </field.Field>
  )}
</form.AppField>

Switch field

Option 1: Using field.Switch (recommended)

import { z } from "zod"
 
import { Form, useForm } from "@/registry/nowts/ui/tanstack-form"
 
const schema = z.object({
  notifications: z.boolean(),
})
 
// Inside your form:
<form.AppField name="notifications">
  {(field) => (
    <field.Field>
      <field.Content>
        <div className="flex items-center justify-between">
          <div className="space-y-0.5">
            <field.Label>Notifications</field.Label>
            <field.Description>
              Receive notifications about updates
            </field.Description>
          </div>
          <field.Switch />
        </div>
      </field.Content>
    </field.Field>
  )}
</form.AppField>

Option 2: Using Switch directly (for custom layouts)

import { Switch } from "@/components/ui/switch"
 
;<form.AppField name="notifications">
  {(field) => (
    <div className="flex items-center justify-between">
      <div className="space-y-0.5">
        <field.Label>Notifications</field.Label>
        <field.Description>
          Receive notifications about updates
        </field.Description>
      </div>
      <Switch
        checked={Boolean(field.state.value)}
        onCheckedChange={(checked) => field.handleChange(Boolean(checked))}
      />
    </div>
  )}
</form.AppField>

Complex Form Example

A complete registration form combining multiple field types (Input, Select, Textarea, Checkbox, Switch):

Advanced Usage

Array fields

Dynamic array fields with add/remove functionality. Use mode="array" on form.AppField and access field.pushValue and field.removeValue:

Example with arrays:

import { Button } from "@/components/ui/button"
 
;<form.AppField name="users" mode="array">
  {(usersField) => (
    <div className="space-y-4">
      <Button
        type="button"
        onClick={() => usersField.pushValue?.({ email: "" })}
      >
        Add User
      </Button>
 
      {usersField.state.value.map((_, index) => (
        <form.AppField key={index} name={`users[${index}].email`}>
          {(field) => (
            <div className="flex gap-2">
              <field.Input type="email" placeholder="user@example.com" />
              <field.Message />
              <Button
                type="button"
                onClick={() => usersField.removeValue?.(index)}
              >
                Remove
              </Button>
            </div>
          )}
        </form.AppField>
      ))}
 
      <usersField.Message />
    </div>
  )}
</form.AppField>

Nested objects

Access deeply nested fields using dot notation. TypeScript provides full autocompletion:

const schema = z.object({
  user: z.object({
    profile: z.object({
      firstName: z.string().min(2),
      lastName: z.string().min(2),
    }),
    contact: z.object({
      email: z.string().email(),
    }),
  }),
})
 
// Use dot notation to access nested fields
<form.AppField name="user.profile.firstName">
  {(field) => (
    <field.Field>
      <field.Label>First Name</field.Label>
      <field.Content>
        <field.Input />
        <field.Message />
      </field.Content>
    </field.Field>
  )}
</form.AppField>
 
<form.AppField name="user.contact.email">
  {(field) => (
    <field.Field>
      <field.Label>Email</field.Label>
      <field.Content>
        <field.Input type="email" />
        <field.Message />
      </field.Content>
    </field.Field>
  )}
</form.AppField>

API Reference

useForm

Create a form instance with Zod validation:

function useForm<TSchema extends z.ZodType>({
  schema: TSchema
  defaultValues: z.infer<TSchema>
  onSubmit: (values: z.infer<TSchema>) => void | Promise<void>
  validationMode?: "onChange" | "onBlur" | "onSubmit"
}): FormApi<z.infer<TSchema>>
ParameterTypeDescriptionDefault
schemaz.ZodTypeZod schema for validation-
defaultValuesz.infer<TSchema>Initial form values-
onSubmit(values) => void | Promise<void>Form submission handler-
validationMode"onChange" | "onBlur" | "onSubmit"When to trigger validation"onBlur"

Form Components

Form

Two ways to handle form submission:

Option 1: Using Form component (wraps form.AppForm)

<Form form={form} className="space-y-4">
  <form.AppField name="email">{/* field content */}</form.AppField>
  <form.SubmitButton>Submit</form.SubmitButton>
</Form>

Option 2: Using form.AppForm directly (more control)

<form.AppForm>
  <form
    onSubmit={(e) => {
      e.preventDefault()
      e.stopPropagation()
      form.handleSubmit()
    }}
    className="space-y-4"
  >
    <form.AppField name="email">{/* field content */}</form.AppField>
    <form.SubmitButton>Submit</form.SubmitButton>
  </form>
</form.AppForm>

form.AppField

Connects a field to the form with type-safe field names. Returns a render prop with field components:

<form.AppField name="email">
  {(field) => (
    <field.Field>
      <field.Label>Email</field.Label>
      <field.Content>
        <field.Input type="email" />
        <field.Message />
      </field.Content>
    </field.Field>
  )}
</form.AppField>
ParameterTypeDescription
namestringType-safe field name (e.g., "email")
mode"value" | "array"Field mode - use "array" for array fields (optional, default: "value")

Field Components

The field render prop provides these components:

  • field.Field - Container for the field with error state
  • field.Label - Label for the field (automatically linked to input)
  • field.Content - Content wrapper for input and messages
  • field.Input - Input component
  • field.Textarea - Textarea component
  • field.Select - Select component wrapper (use with shadcn/ui Select components)
  • field.Checkbox - Checkbox component
  • field.Switch - Switch component
  • field.Description - Helper text for the field
  • field.Message - Error message display

form.SubmitButton

Submit button that automatically disables during submission:

<form.SubmitButton>Submit</form.SubmitButton>

Form State

Access form state using form.Subscribe:

<form.Subscribe selector={(state) => state.isSubmitting}>
  {(isSubmitting) => (
    <Button type="submit" disabled={isSubmitting}>
      {isSubmitting ? "Submitting..." : "Submit"}
    </Button>
  )}
</form.Subscribe>

Available state properties:

  • isSubmitting - Boolean: Is form currently submitting?
  • canSubmit - Boolean: Can form be submitted?
  • isValid - Boolean: Are all fields valid?
  • isDirty - Boolean: Has form been modified?
  • submissionAttempts - Number: How many times has submit been attempted?
  • values - Object: Current form values
  • errors - Array: Form-level validation errors

Validation Modes

Choose when validation should trigger:

// Validate on blur (default) - good balance of UX and performance
const form = useForm({ ..., validationMode: "onBlur" })
 
// Validate on every change - instant feedback but more expensive
const form = useForm({ ..., validationMode: "onChange" })
 
// Validate only on submit - minimal validation until submission
const form = useForm({ ..., validationMode: "onSubmit" })

Type Safety

The form component provides full type safety:

const schema = z.object({
  email: z.string().email(),
  age: z.number().min(18),
})
 
const form = useForm({ schema, ... })
 
// ✅ Valid - autocomplete works
<form.AppField name="email">
 
// ✅ Valid - autocomplete works
<form.AppField name="age">
 
// ❌ TypeScript error - field doesn't exist
<form.AppField name="invalid">
 
// ✅ Valid - nested paths work
const nestedSchema = z.object({
  user: z.object({ email: z.string() })
})
<form.AppField name="user.email">
 
// ✅ Valid - array syntax works
const arraySchema = z.object({
  users: z.array(z.object({ email: z.string() }))
})
<form.AppField name="users[0].email">

Best Practices

1. Use validation mode wisely

// For most forms, onBlur provides the best UX
const form = useForm({ validationMode: "onBlur", ... })
 
// For search/filter forms, onChange provides instant feedback
const searchForm = useForm({ validationMode: "onChange", ... })

2. Handle loading states

<form.SubmitButton>Save</form.SubmitButton>
 
// Or with custom button:
<form.Subscribe selector={(state) => state.isSubmitting}>
  {(isSubmitting) => (
    <Button type="submit" disabled={isSubmitting}>
      {isSubmitting ? "Saving..." : "Save"}
    </Button>
  )}
</form.Subscribe>

3. Provide helpful error messages

const schema = z.object({
  email: z
    .string()
    .min(1, "Email is required")
    .email("Please enter a valid email address"),
  password: z
    .string()
    .min(8, "Password must be at least 8 characters")
    .regex(/[A-Z]/, "Password must contain at least one uppercase letter"),
})

4. Use field.Description for hints

<form.AppField name="email">
  {(field) => (
    <field.Field>
      <field.Label>Email</field.Label>
      <field.Content>
        <field.Input />
        <field.Description>
          We'll never share your email with anyone
        </field.Description>
        <field.Message />
      </field.Content>
    </field.Field>
  )}
</form.AppField>

5. Reset forms after submission

onSubmit: async (values) => {
  await saveData(values)
  form.reset() // Reset to default values
}

Troubleshooting

Validation not triggering

  • Check that you've set the correct validationMode
  • Ensure the field has been touched (blurred at least once)
  • Verify that form.state.submissionAttempts > 0 for submit validation

Type errors with field names

  • Make sure the field name matches your schema exactly
  • Use dot notation for nested objects: "user.email"
  • Use bracket notation for arrays: "users[0].email"

Array fields not updating

  • Make sure to use mode="array" on form.AppField
  • Use field.pushValue?.() and field.removeValue?.() for array operations
  • Always provide unique keys when mapping over array items