"use client"
import { toast } from "sonner"
import { z } from "zod"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Form, useForm } from "@/components/ui/tanstack-form"
const accountSchema = z.object({
email: z.string().email("Invalid email"),
password: z.string().min(8, "Password must be at least 8 characters"),
})
export function TanstackFormDemo() {
const form = useForm({
schema: accountSchema,
defaultValues: {
email: "",
password: "",
},
onSubmit: async (values) => {
await new Promise((resolve) => setTimeout(resolve, 1000))
toast.success("Account created successfully!")
console.log(values)
},
})
return (
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="text-lg font-semibold">Create Account</CardTitle>
<CardDescription>TanStack Form with Zod validation</CardDescription>
</CardHeader>
<CardContent>
<Form form={form} className="space-y-4">
<form.AppField name="email">
{(field) => (
<field.Field>
<field.Label>Email</field.Label>
<field.Content>
<field.Input type="email" placeholder="you@example.com" />
<field.Message />
</field.Content>
</field.Field>
)}
</form.AppField>
<form.AppField name="password">
{(field) => (
<field.Field>
<field.Label>Password</field.Label>
<field.Content>
<field.Input type="password" placeholder="••••••••" />
<field.Message />
</field.Content>
</field.Field>
)}
</form.AppField>
<form.SubmitButton className="w-full">
Create Account
</form.SubmitButton>
</Form>
</CardContent>
</Card>
)
}
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):
"use client"
import { toast } from "sonner"
import { z } from "zod"
import { Card } from "@/components/ui/card"
import {
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { useForm } from "@/components/ui/tanstack-form"
const registrationSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email("Please enter a valid email address"),
role: z.enum(["developer", "designer", "manager"], {
required_error: "Please select a role",
}),
bio: z
.string()
.min(10, "Bio must be at least 10 characters")
.max(200, "Bio must be at most 200 characters"),
newsletter: z.boolean(),
notifications: z.boolean(),
terms: z.boolean().refine((val) => val === true, {
message: "You must accept the terms and conditions",
}),
})
export function TanstackFormComplexDemo() {
const form = useForm({
schema: registrationSchema,
defaultValues: {
name: "",
email: "",
role: "developer" as const,
bio: "",
newsletter: false,
notifications: true,
terms: false,
},
onSubmit: async (values) => {
await new Promise((resolve) => setTimeout(resolve, 1000))
toast.success("Registration successful!", {
description: `Welcome, ${values.name}!`,
})
console.log(values)
},
})
return (
<Card className="mx-auto w-full max-w-2xl p-6">
<form
onSubmit={(e) => {
e.preventDefault()
e.stopPropagation()
form.handleSubmit()
}}
className="space-y-6"
>
<div className="space-y-2">
<h3 className="text-lg font-semibold">Create Account</h3>
<p className="text-muted-foreground text-sm">
Fill out the form below to create your account
</p>
</div>
<form.AppField name="name">
{(field) => (
<field.Field>
<field.Label>Full 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 Address</field.Label>
<field.Content>
<field.Input type="email" placeholder="john@example.com" />
<field.Message />
</field.Content>
</field.Field>
)}
</form.AppField>
<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="developer">Developer</SelectItem>
<SelectItem value="designer">Designer</SelectItem>
<SelectItem value="manager">Manager</SelectItem>
</SelectContent>
</field.Select>
<field.Description>
Choose the role that best describes you
</field.Description>
<field.Message />
</field.Content>
</field.Field>
)}
</form.AppField>
<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>
Write a short bio (10-200 characters)
</field.Description>
<field.Message />
</field.Content>
</field.Field>
)}
</form.AppField>
<form.AppField name="newsletter">
{(field) => (
<div className="flex items-start gap-3">
<field.Checkbox id="newsletter" />
<div className="space-y-1 leading-none">
<field.Label htmlFor="newsletter" className="font-normal">
Subscribe to newsletter
</field.Label>
<field.Description>
Receive weekly updates about new features
</field.Description>
<field.Message />
</div>
</div>
)}
</form.AppField>
<form.AppField name="notifications">
{(field) => (
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<field.Label>Push Notifications</field.Label>
<field.Description>
Receive push notifications about account activity
</field.Description>
</div>
<Switch
checked={Boolean(field.state.value)}
onCheckedChange={(checked) =>
field.handleChange(Boolean(checked))
}
/>
</div>
)}
</form.AppField>
<form.AppField name="terms">
{(field) => (
<div className="flex items-start gap-3">
<field.Checkbox id="terms" />
<div className="space-y-1 leading-none">
<field.Label htmlFor="terms" className="font-normal">
I accept the terms and conditions
</field.Label>
<field.Description>
You agree to our Terms of Service and Privacy Policy
</field.Description>
<field.Message />
</div>
</div>
)}
</form.AppField>
<form.AppForm>
<form.SubmitButton className="w-full">
{form.state.isSubmitting ? "Creating account..." : "Create Account"}
</form.SubmitButton>
</form.AppForm>
</form>
</Card>
)
}
Advanced Usage
Array fields
Dynamic array fields with add/remove functionality. Use mode="array" on form.AppField and access field.pushValue and field.removeValue:
"use client"
import { PlusIcon, XIcon } from "lucide-react"
import { toast } from "sonner"
import { z } from "zod"
import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
import { useForm } from "@/components/ui/tanstack-form"
const teamSchema = z.object({
teamName: z.string().min(2, "Team name must be at least 2 characters"),
users: z
.array(
z.object({
email: z.string().email("Please enter a valid email address"),
})
)
.min(1, "You must add at least one user")
.max(10, "Maximum 10 users allowed"),
})
export function TanstackFormArrayDemo() {
const form = useForm({
schema: teamSchema,
defaultValues: {
teamName: "",
users: [{ email: "" }],
},
onSubmit: async (values) => {
await new Promise((resolve) => setTimeout(resolve, 1000))
toast.success("Team created!", {
description: `${values.teamName} with ${values.users.length} members`,
})
console.log(values)
},
})
return (
<Card className="mx-auto w-full max-w-2xl p-6">
<form
onSubmit={(e) => {
e.preventDefault()
e.stopPropagation()
form.handleSubmit()
}}
className="space-y-6"
>
<div className="space-y-2">
<h3 className="text-lg font-semibold">Create Team</h3>
<p className="text-muted-foreground text-sm">
Add team members by email address
</p>
</div>
<form.AppField name="teamName">
{(field) => (
<field.Field>
<field.Label>Team Name</field.Label>
<field.Content>
<field.Input placeholder="Engineering Team" />
<field.Message />
</field.Content>
</field.Field>
)}
</form.AppField>
<form.AppField name="users" mode="array">
{(usersField) => (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h4 className="text-sm font-medium">Team Members</h4>
<p className="text-muted-foreground text-sm">
Add email addresses for team members (1-10 users)
</p>
</div>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => usersField.pushValue?.({ email: "" })}
disabled={usersField.state.value.length >= 10}
>
<PlusIcon className="mr-2 size-4" />
Add User
</Button>
</div>
<div className="space-y-2">
{usersField.state.value.map((_, index) => (
<form.AppField key={index} name={`users[${index}].email`}>
{(field) => (
<div className="flex items-start gap-2">
<div className="flex-1">
<field.Input
type="email"
placeholder="user@example.com"
/>
<field.Message />
</div>
<Button
type="button"
size="icon"
variant="ghost"
onClick={() => usersField.removeValue?.(index)}
disabled={usersField.state.value.length === 1}
>
<XIcon className="size-4" />
</Button>
</div>
)}
</form.AppField>
))}
</div>
<usersField.Message />
</div>
)}
</form.AppField>
<form.AppForm>
<form.SubmitButton className="w-full">
{form.state.isSubmitting ? "Creating..." : "Create Team"}
</form.SubmitButton>
</form.AppForm>
</form>
</Card>
)
}
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>>| Parameter | Type | Description | Default |
|---|---|---|---|
schema | z.ZodType | Zod schema for validation | - |
defaultValues | z.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>| Parameter | Type | Description |
|---|---|---|
name | string | Type-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 statefield.Label- Label for the field (automatically linked to input)field.Content- Content wrapper for input and messagesfield.Input- Input componentfield.Textarea- Textarea componentfield.Select- Select component wrapper (use with shadcn/ui Select components)field.Checkbox- Checkbox componentfield.Switch- Switch componentfield.Description- Helper text for the fieldfield.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 valueserrors- 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 > 0for 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"onform.AppField - Use
field.pushValue?.()andfield.removeValue?.()for array operations - Always provide unique keys when mapping over array items
On This Page
InstallationInstall dependenciesAdd the componentAboutFeaturesUsageBasic form with InputTextarea fieldSelect fieldCheckbox fieldSwitch fieldComplex Form ExampleAdvanced UsageArray fieldsNested objectsAPI ReferenceuseFormForm ComponentsFormform.AppFieldField Componentsform.SubmitButtonForm StateValidation ModesType SafetyBest Practices1. Use validation mode wisely2. Handle loading states3. Provide helpful error messages4. Use field.Description for hints5. Reset forms after submissionTroubleshootingValidation not triggeringType errors with field namesArray fields not updating