Fix: TanStack Form Not Working — Field Types, Validators, Async Validation, and Subscription Re-renders
Quick Answer
How to fix TanStack Form errors — field name not typed, zod/valibot validators not running, async onChange race conditions, listener not firing, array field state, and SSR with Server Actions.
The Error
You set up TanStack Form and field names don’t autocomplete:
import { useForm } from "@tanstack/react-form";
const form = useForm({
defaultValues: { email: "", password: "" },
onSubmit: async ({ value }) => console.log(value),
});
<form.Field name="emial" /> // typo, no TypeScript error.Or your zod validator runs once and never again:
<form.Field
name="email"
validators={{ onChange: emailSchema }}
>
{(field) => (
<input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
)}
</form.Field>Or the input renders with stale errors after async validation:
validators: {
onChangeAsync: async ({ value }) => {
const taken = await checkUsername(value);
return taken ? "Username taken" : undefined;
},
}
// Type "abc" → wait → type "abcd" → "abc" response arrives, shows wrong error.Or every keystroke re-renders the whole form:
function MyForm() {
console.log("render"); // Logged on every character typed.
...
}Why This Happens
TanStack Form’s API is intentionally low-level — it gives you the primitives (field state, validators, subscriptions) and trusts you to compose them. Four common pain points:
- Field name typing depends on inference from
defaultValues. If yourdefaultValueshas the wrong shape (or you typed it asany), names lose their autocomplete. The fix is to makedefaultValuesstrongly typed before reading from it. - Validators are functions or schema objects, not “schema instances.” A zod schema passed as a validator works because TanStack Form sniffs for
.parse/.safeParse. But if you wrap it incorrectly (e.g. passemailSchema.parseinstead ofemailSchema), nothing runs. - Async validators don’t cancel. Each
onChangeAsyncinvocation runs to completion. Stale responses arriving out of order overwrite fresh state. You need debouncing + the latest-request pattern, oronChangeAsyncDebounceMs. - Re-renders are subscription-based. A component that reads
form.state(the whole state) re-renders on every change. Useform.useStore(selector)or per-field subscriptions to scope updates.
Fix 1: Type defaultValues So Field Names Autocomplete
The compiler infers field names from defaultValues. If the type is wrong, names don’t type-check:
type FormValues = {
email: string;
password: string;
preferences: {
newsletter: boolean;
};
};
const form = useForm({
defaultValues: {
email: "",
password: "",
preferences: { newsletter: false },
} as FormValues,
onSubmit: async ({ value }) => {
// value is typed as FormValues here.
},
});Now <form.Field name="emial"> is a type error, and <form.Field name="preferences.newsletter"> autocompletes.
Pro Tip: Extract defaultValues into a named const. Inlining it with an inferred literal type works, but a named const makes the inferred type discoverable in your editor:
const defaultValues = {
email: "",
preferences: { newsletter: false },
} satisfies FormValues;
const form = useForm({ defaultValues, onSubmit: ... });satisfies keeps the literal type (so preferences is { newsletter: false }, not { newsletter: boolean }) while still validating against FormValues.
Fix 2: Pass Schemas, Not Schema Methods
TanStack Form has first-class support for Zod, Valibot, and Yup schemas via dedicated adapters:
import { useForm } from "@tanstack/react-form";
import { zodValidator } from "@tanstack/zod-form-adapter";
import { z } from "zod";
const form = useForm({
defaultValues: { email: "" },
validatorAdapter: zodValidator(),
onSubmit: async ({ value }) => {...},
});
<form.Field
name="email"
validators={{
onChange: z.string().email("Invalid email"),
}}
>
{(field) => (...)}
</form.Field>Two requirements:
- Install the adapter:
npm install @tanstack/zod-form-adapter zod. - Set
validatorAdapteronuseForm.
Without the adapter, the schema is treated as an opaque object and never runs.
For Valibot:
npm install @tanstack/valibot-form-adapter valibotimport { valibotValidator } from "@tanstack/valibot-form-adapter";
import * as v from "valibot";
const form = useForm({
...,
validatorAdapter: valibotValidator(),
});
<form.Field name="email" validators={{
onChange: v.pipe(v.string(), v.email("Invalid email")),
}} />Common Mistake: Passing emailSchema.parse or emailSchema.safeParse as the validator. The adapter expects the schema, not a method on it.
Fix 3: Debounce Async Validators and Use the Latest-Request Pattern
For async validators that hit a network, debounce so you don’t fire on every keystroke:
<form.Field
name="username"
validators={{
onChangeAsyncDebounceMs: 500,
onChangeAsync: async ({ value, signal }) => {
const res = await fetch(`/api/check?u=${encodeURIComponent(value)}`, {
signal, // Aborts when a newer request supersedes this one.
});
const { taken } = await res.json();
return taken ? "Username taken" : undefined;
},
}}
>
{(field) => (...)}
</form.Field>Two things to do:
onChangeAsyncDebounceMs— wait this many ms after the last change before running the validator.signal— the second arg’ssignalaborts in-flight requests when the field changes again. Honor it infetch.
Without these, you get the classic out-of-order response bug — type “abc” → request fires → type “abcd” → second request fires → first response (slower) arrives last and overwrites the correct state.
Fix 4: Scope Re-renders With form.Subscribe and useStore
By default, calling form.state in your component body subscribes to the entire form state. Every keystroke in any field re-renders the component.
Use form.Subscribe to read only the slice you need:
<form.Subscribe
selector={(state) => [state.canSubmit, state.isSubmitting]}
>
{([canSubmit, isSubmitting]) => (
<button type="submit" disabled={!canSubmit || isSubmitting}>
{isSubmitting ? "Submitting…" : "Submit"}
</button>
)}
</form.Subscribe>Or form.useStore inside a child component:
function SubmitButton({ form }) {
const canSubmit = form.useStore((state) => state.canSubmit);
const isSubmitting = form.useStore((state) => state.isSubmitting);
return <button disabled={!canSubmit || isSubmitting}>Submit</button>;
}This re-renders only when canSubmit or isSubmitting changes, not on every field edit.
Note: <form.Field>’s render prop is already scoped — it re-renders only when that field’s state changes. Don’t read form.state inside the render prop; read field.state instead.
Fix 5: Array Fields With form.Field mode="array"
For dynamic lists (add/remove items), use the array mode:
<form.Field name="emails" mode="array">
{(field) => (
<>
{field.state.value.map((_, i) => (
<form.Field key={i} name={`emails[${i}]`}>
{(subField) => (
<input
value={subField.state.value}
onChange={(e) => subField.handleChange(e.target.value)}
/>
)}
</form.Field>
))}
<button type="button" onClick={() => field.pushValue("")}>
Add email
</button>
<button type="button" onClick={() => field.removeValue(0)}>
Remove first
</button>
</>
)}
</form.Field>Field names for nested arrays use the dot/bracket path: emails[0], users[2].email, teams[0].members[1].role. The path must match a real shape in defaultValues.
Common Mistake: Using index as the React key when you also have pushValue/removeValue reordering items. After a remove, the input keeps the old value because React reuses the same key. Use a stable ID from the item itself when possible.
Fix 6: SSR With Server Actions (Next.js, Remix)
For server-side validation and submission with Next.js Server Actions:
"use client";
import { useForm } from "@tanstack/react-form";
import { submitContact } from "./actions";
export default function ContactForm() {
const form = useForm({
defaultValues: { email: "", message: "" },
onSubmit: async ({ value }) => {
const result = await submitContact(value);
if (!result.ok) {
// Surface server-side errors back to the form.
form.setFieldMeta("email", (prev) => ({ ...prev, errors: [result.error] }));
}
},
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
>
{/* fields */}
</form>
);
}Two things to remember:
onSubmitruns on the client. Re-validate server-side in the action — never trust client validation alone.- Surface server errors back via
setFieldMetaso the UI shows them.
For Remix-style action exports, return errors as JSON and merge them into form state on the client:
const form = useForm({
defaultValues,
onSubmit: async ({ value }) => {
const errors = await submitToServer(value);
Object.entries(errors).forEach(([field, msg]) =>
form.setFieldMeta(field, (prev) => ({ ...prev, errors: [msg] }))
);
},
});Fix 7: Listener / Subscription Not Firing
You added a side effect with form.Subscribe and it doesn’t run:
<form.Subscribe
selector={(state) => state.values.email}
>
{(email) => {
console.log("email:", email);
return null;
}}
</form.Subscribe>Two common causes:
- The component isn’t mounted.
form.Subscribeis a React component. If it’s not rendered, it doesn’t subscribe. Render it (even if it returnsnull) inside the form. - The selector returns a new reference each render. Selectors must return stable references for primitive comparison to work. Don’t construct objects in the selector; pick primitives or use a deep-equal selector.
For non-React side effects (analytics, logging), subscribe via useStore inside an effect:
function FormTracker({ form }) {
const values = form.useStore((state) => state.values);
useEffect(() => {
track("form_change", { values });
}, [values]);
return null;
}Render <FormTracker form={form} /> inside the form. The effect re-runs only when values actually change (referential equality on the selected slice).
Fix 8: Reset, Set, and Programmatic Control
Reset to default values:
form.reset();Reset to a specific state:
form.reset({ email: "[email protected]", password: "" });Set one field without touching others:
form.setFieldValue("email", "[email protected]");Mark a field as touched (useful when programmatically populating):
form.setFieldMeta("email", (prev) => ({ ...prev, isTouched: true }));Submit programmatically:
form.handleSubmit();Pro Tip: When loading initial values asynchronously (e.g. an “edit” form fetching the current record), don’t keep defaultValues empty and setFieldValue after fetch — the user might type before the fetch resolves. Instead, render the form conditionally on the loaded data and pass it as defaultValues.
Still Not Working?
A few less-obvious failures:
form.state.valuesdoesn’t update. You read it once in component body. Useform.SubscribeoruseStorefor reactive access.onSubmitdoesn’t run. A<form.Field>validator is failing, blocking submit. Checkform.state.errorMapto see which field is invalid.- Validation errors show on first render. A field’s
validators.onMountran. Switch toonChangeif you don’t want validation before the user touches the field. - TypeScript can’t narrow nested field names. TS path inference has limits with deeply nested generics. Refactor to top-level fields or use
as constaggressively. field.handleBlurdoesn’t triggeronBlurvalidator. You forgot to wire it to the input:onBlur={field.handleBlur}. The validator only runs whenhandleBluris called.- Form values are
undefinedafterreset. You passed a partial object toreset— TanStack Form replaces the entire values object. Pass all fields, or callreset()with no args to restoredefaultValues. - Adapter version mismatch.
@tanstack/zod-form-adapterand@tanstack/react-formshould be on compatible majors. Check both versions if validators stop firing after an upgrade. - Submitting double-fires. You called
form.handleSubmit()inside anonSubmit={form.handleSubmit}on the<form>element. Pick one — usuallyonSubmit={(e) => { e.preventDefault(); form.handleSubmit(); }}.
For related React form and validation issues, see React Hook Form not working, Zod validation not working, React useState not updating, and Next.js server action not working.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Valibot Not Working — v.pipe Syntax, Error Messages, Async Validation, and Zod Migration
How to fix Valibot errors — v.pipe vs chained methods, parse vs safeParse, async pipelines with v.parseAsync, custom error messages with v.message, optional/nullable variants, Zod migration patterns, and form library integration.
Fix: React Hook Form Not Working — register Not Applying, Validation Not Triggering, or Controller Issues
How to fix React Hook Form issues — register spread syntax, Controller for UI libraries, validation modes, watch vs getValues, nested fields, and form submission errors.
Fix: React 19 Actions Not Working — useActionState, useFormStatus, useOptimistic, and form action
How to fix React 19 actions errors — useActionState signature, form action vs onSubmit, useFormStatus must be in child, useOptimistic state desync, Server Actions in client components, and error handling.
Fix: Next.js 'params should be awaited before using its properties'
How to fix Next.js 15 async params and searchParams errors — await in Server Components, React.use in Client Components, generateMetadata, generateStaticParams, and the codemod migration path.