TanStack Form provides framework-agnostic type-safe forms — useForm({ defaultValues, validators: { onChange: zodValidator(schema) } }) creates a form instance. <form.Field name="email"> renders a typed field. field.handleChange(value) updates state. field.state.value reads current value. field.state.meta.errors shows validation errors. <form.Subscribe selector={(s) => s.canSubmit}> subscribes to derived state. Field-level validators: validators: { onChange: ({ value }) => value ? undefined : "Required", onBlurAsync: async ({ value }) => { const taken = await checkEmail(value); return taken ? "Email taken" : undefined } }. Array fields: <form.Field name="tags" mode="array"> with field.state.value.map(...) and field.pushValue("new"). Nested: name="address.street". form.handleSubmit(async (values) => { ... }) submits with fully typed values. zodValidator(schema) adapter runs schema per-field or per-form. form.reset() resets to defaults. Claude Code generates TanStack Form schemas, async validation, array fields, and multi-step forms.
CLAUDE.md for TanStack Form
## TanStack Form Stack
- Version: @tanstack/react-form >= 0.35, @tanstack/zod-form-adapter >= 0.35
- Init: const form = useForm({ defaultValues: { email: "", name: "" }, validators: { onChange: zodValidator(FormSchema) } })
- Field: <form.Field name="email">{(field) => <input value={field.state.value} onChange={e => field.handleChange(e.target.value)} />}</form.Field>
- Errors: field.state.meta.errors — string[] from validators
- Submit: <form.Subscribe>{({ canSubmit, isSubmitting }) => <button disabled={!canSubmit || isSubmitting}>Submit</button>}</form.Subscribe>
- Async validator: validators: { onChangeAsync: async ({ value }) => checkEmail(value) ? "Email taken" : undefined, onChangeAsyncDebounceMs: 500 }
- Array: <form.Field name="items" mode="array"> with field.state.value.map((_, i) => <form.Field name={`items[${i}].name`}>)
Form Setup
// lib/forms/adapters.ts — Zod validator adapter for TanStack Form
import { zodValidator } from "@tanstack/zod-form-adapter"
import { z } from "zod"
// Re-export for convenience
export { zodValidator }
// Common schemas
export const PasswordSchema = z
.string()
.min(8, "At least 8 characters")
.regex(/[A-Z]/, "At least one uppercase letter")
.regex(/[0-9]/, "At least one number")
export const PhoneSchema = z
.string()
.regex(/^\+?[1-9]\d{7,14}$/, "Invalid phone number")
.optional()
.or(z.literal(""))
Registration Form
// components/forms/RegisterForm.tsx — TanStack Form with Zod validation
"use client"
import { useForm } from "@tanstack/react-form"
import { zodValidator } from "@tanstack/zod-form-adapter"
import { z } from "zod"
import { useState } from "react"
const RegisterSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters").max(100),
email: z.string().email("Invalid email address"),
password: z
.string()
.min(8, "Password must be at least 8 characters")
.regex(/[A-Z]/, "Password needs an uppercase letter")
.regex(/[0-9]/, "Password needs a number"),
acceptTerms: z.literal(true, {
errorMap: () => ({ message: "You must accept the terms" }),
}),
})
type RegisterValues = z.infer<typeof RegisterSchema>
interface RegisterFormProps {
onSuccess: (data: RegisterValues) => void
}
export function RegisterForm({ onSuccess }: RegisterFormProps) {
const [serverError, setServerError] = useState<string | null>(null)
const form = useForm({
defaultValues: {
name: "",
email: "",
password: "",
acceptTerms: false as boolean,
},
validators: {
onChange: zodValidator(RegisterSchema),
},
onSubmit: async ({ value }) => {
setServerError(null)
try {
const res = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(value),
})
if (!res.ok) {
const err = await res.json() as { message: string }
setServerError(err.message)
return
}
onSuccess(value as RegisterValues)
} catch {
setServerError("Something went wrong. Please try again.")
}
},
})
return (
<form
onSubmit={(e) => {
e.preventDefault()
form.handleSubmit()
}}
className="space-y-4"
>
{serverError && (
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm">
{serverError}
</div>
)}
{/* Name field */}
<form.Field name="name">
{(field) => (
<div className="space-y-1">
<label className="text-sm font-medium">Full Name</label>
<input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
placeholder="John Doe"
className={`w-full px-3 py-2 rounded-lg border text-sm bg-background transition-colors ${
field.state.meta.errors.length > 0
? "border-destructive"
: "border-input focus:border-ring"
}`}
/>
{field.state.meta.errors.map((err, i) => (
<p key={i} className="text-xs text-destructive">{err}</p>
))}
</div>
)}
</form.Field>
{/* Email with async uniqueness check */}
<form.Field
name="email"
validators={{
onChangeAsync: async ({ value }) => {
if (!value || !value.includes("@")) return undefined
const res = await fetch(`/api/auth/check-email?email=${encodeURIComponent(value)}`)
const { available } = await res.json() as { available: boolean }
return available ? undefined : "Email address is already in use"
},
onChangeAsyncDebounceMs: 500,
}}
>
{(field) => (
<div className="space-y-1">
<label className="text-sm font-medium">Email</label>
<div className="relative">
<input
type="email"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
placeholder="[email protected]"
className={`w-full px-3 py-2 rounded-lg border text-sm bg-background ${
field.state.meta.errors.length > 0 ? "border-destructive" : "border-input"
}`}
/>
{field.state.meta.isValidating && (
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-muted-foreground">
Checking...
</span>
)}
</div>
{field.state.meta.errors.map((err, i) => (
<p key={i} className="text-xs text-destructive">{err}</p>
))}
</div>
)}
</form.Field>
{/* Password */}
<form.Field name="password">
{(field) => (
<div className="space-y-1">
<label className="text-sm font-medium">Password</label>
<input
type="password"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
placeholder="Min. 8 characters"
className={`w-full px-3 py-2 rounded-lg border text-sm bg-background ${
field.state.meta.errors.length > 0 ? "border-destructive" : "border-input"
}`}
/>
{field.state.meta.errors.map((err, i) => (
<p key={i} className="text-xs text-destructive">{err}</p>
))}
</div>
)}
</form.Field>
{/* Terms */}
<form.Field name="acceptTerms">
{(field) => (
<div className="space-y-1">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={field.state.value}
onChange={(e) => field.handleChange(e.target.checked)}
className="size-4 rounded border-input"
/>
<span className="text-sm">
I accept the{" "}
<a href="/terms" className="text-primary underline">Terms of Service</a>
</span>
</label>
{field.state.meta.errors.map((err, i) => (
<p key={i} className="text-xs text-destructive">{err}</p>
))}
</div>
)}
</form.Field>
{/* Submit */}
<form.Subscribe
selector={(state) => ({ canSubmit: state.canSubmit, isSubmitting: state.isSubmitting })}
>
{({ canSubmit, isSubmitting }) => (
<button
type="submit"
disabled={!canSubmit || isSubmitting}
className="w-full py-2.5 rounded-xl bg-primary text-primary-foreground font-medium text-sm disabled:opacity-50 transition-opacity"
>
{isSubmitting ? "Creating account..." : "Create Account"}
</button>
)}
</form.Subscribe>
</form>
)
}
Dynamic Array Form
// components/forms/TagsForm.tsx — array fields with TanStack Form
"use client"
import { useForm } from "@tanstack/react-form"
import { z } from "zod"
import { zodValidator } from "@tanstack/zod-form-adapter"
const PostSchema = z.object({
title: z.string().min(5),
tags: z.array(z.string().min(1)).max(10),
links: z.array(z.object({
label: z.string().min(1),
url: z.string().url(),
})).optional().default([]),
})
export function PostMetaForm() {
const form = useForm({
defaultValues: {
title: "",
tags: [""],
links: [{ label: "", url: "" }],
},
validators: {
onChange: zodValidator(PostSchema),
},
onSubmit: async ({ value }) => {
await fetch("/api/posts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(value),
})
},
})
return (
<form onSubmit={(e) => { e.preventDefault(); form.handleSubmit() }} className="space-y-6">
{/* Title */}
<form.Field name="title">
{(field) => (
<input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
placeholder="Post title"
className="w-full px-3 py-2 rounded-lg border text-sm"
/>
)}
</form.Field>
{/* Tags array */}
<div className="space-y-2">
<label className="text-sm font-medium">Tags</label>
<form.Field name="tags" mode="array">
{(tagsField) => (
<div className="space-y-2">
{tagsField.state.value.map((_, i) => (
<div key={i} className="flex gap-2">
<form.Field name={`tags[${i}]`}>
{(tagField) => (
<input
value={tagField.state.value}
onChange={(e) => tagField.handleChange(e.target.value)}
placeholder="tag"
className="flex-1 px-3 py-2 rounded-lg border text-sm"
/>
)}
</form.Field>
<button
type="button"
onClick={() => tagsField.removeValue(i)}
className="px-3 py-2 rounded-lg border text-sm text-destructive hover:bg-destructive/10"
>
✕
</button>
</div>
))}
<button
type="button"
onClick={() => tagsField.pushValue("")}
className="text-sm text-primary hover:underline"
>
+ Add tag
</button>
</div>
)}
</form.Field>
</div>
<form.Subscribe selector={(s) => ({ canSubmit: s.canSubmit, isSubmitting: s.isSubmitting })}>
{({ canSubmit, isSubmitting }) => (
<button
type="submit"
disabled={!canSubmit || isSubmitting}
className="px-6 py-2.5 rounded-xl bg-primary text-primary-foreground font-medium text-sm disabled:opacity-50"
>
{isSubmitting ? "Saving..." : "Save Post"}
</button>
)}
</form.Subscribe>
</form>
)
}
For the React Hook Form alternative when a more mature library with a larger ecosystem, widespread community adoption, useController/Controller for controlled inputs, native Resolver for Zod/Yup/Valibot, and more built-in examples in UI libraries like shadcn/ui is preferred — React Hook Form has broader community support while TanStack Form has better TypeScript generics and framework-agnostic design, see the React Hook Form guide. For the Conform alternative when a progressively-enhanced form library that works with server actions in Next.js/Remix, returns serializable errors for hydration, and supports form submissions without JavaScript is needed — Conform is purpose-built for the Server Actions model while TanStack Form is optimized for fully client-side validation, see the Conform guide. The Claude Skills 360 bundle includes TanStack Form skill sets covering validation, arrays, and async checks. Start with the free tier to try type-safe form generation.