Skip to content

Fix: TanStack Form Not Working — Field Types, Validators, Async Validation, and Subscription Re-renders

FixDevs · (Updated: )

Part of:  React & Frontend Errors

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 your defaultValues has the wrong shape (or you typed it as any), names lose their autocomplete. The fix is to make defaultValues strongly 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. pass emailSchema.parse instead of emailSchema), nothing runs.
  • Async validators don’t cancel. Each onChangeAsync invocation runs to completion. Stale responses arriving out of order overwrite fresh state. You need debouncing + the latest-request pattern, or onChangeAsyncDebounceMs.
  • Re-renders are subscription-based. A component that reads form.state (the whole state) re-renders on every change. Use form.useStore(selector) or per-field subscriptions to scope updates.

The “low-level by design” decision is the real source of friction. React Hook Form ships an opinionated register API that hides subscription wiring; Conform leans on the platform via the native <form> element. TanStack Form sits in the middle — it gives you a <form.Field> that does the subscription for you, but also exposes the raw store so you can integrate with anything. The cost of that flexibility is that nothing is automatic. If you do not wire field.handleBlur to onBlur, the onBlur validator never fires. If you do not pass the schema adapter, the schema is opaque data. The API is correct but unforgiving.

The other reason TanStack Form errors feel obscure is that several official packages must agree on versions: @tanstack/react-form, @tanstack/zod-form-adapter (or valibot-form-adapter, yup-form-adapter), and any UI library wrappers. The library is still pre-1.0 and ships breaking changes in minor versions when the API shape evolves. A schema that runs on @tanstack/[email protected] may silently no-op on 0.30 because the adapter contract changed. Matching the adapter major to the form major is mandatory.

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:

  1. Install the adapter: npm install @tanstack/zod-form-adapter zod.
  2. Set validatorAdapter on useForm.

Without the adapter, the schema is treated as an opaque object and never runs.

For Valibot:

npm install @tanstack/valibot-form-adapter valibot
import { 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:

  1. onChangeAsyncDebounceMs — wait this many ms after the last change before running the validator.
  2. signal — the second arg’s signal aborts in-flight requests when the field changes again. Honor it in fetch.

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:

  1. onSubmit runs on the client. Re-validate server-side in the action — never trust client validation alone.
  2. Surface server errors back via setFieldMeta so 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.Subscribe is a React component. If it’s not rendered, it doesn’t subscribe. Render it (even if it returns null) 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.

Version History: TanStack Form Release Milestones

TanStack Form is one of the newer libraries in the TanStack family. It pre-1.0 status means breaking changes land in minor versions — pinning the exact version your tutorial used saves a long debugging session.

  • Alpha (2023). Initial release as @tanstack/react-form under the TanStack umbrella. The API was unstable through 2023, with field rendering switching between a hook-based and a component-based pattern more than once. Code from this era often uses useField instead of <form.Field>; the modern recommendation is the component form.
  • v0.20 line (early 2024). First widely adopted stable patterns. <form.Field>, form.Subscribe, the validator adapter system, and the array mode (mode="array") were stabilized here. Most tutorials online assume v0.20-style code.
  • v0.30 (mid-2024). Form arrays were significantly reworked. pushValue, removeValue, and moveValue became first-class on array-mode fields, replacing earlier patterns that mutated field.state.value directly. Dependent fields (validators.deps) were added so a validator can re-run when another field changes. The framework-agnostic core was split out so non-React frameworks (Solid, Vue) could share it.
  • v0.40 (late 2024). DX improvements: better TypeScript inference for deeply nested paths, clearer error messages when a validator returns the wrong shape, and the form.useField hook for components that want the field state without the render-prop. Server-side validation helpers landed for Next.js Server Actions and Remix actions.
  • v0.x maintenance. Continuous improvements: tighter integration with @tanstack/router for form-aware navigation, better tree-shaking, and incremental work on the framework-agnostic core that powers the React, Solid, and Vue adapters.
  • vs React Hook Form / Conform. React Hook Form (v7) optimizes for minimal re-renders via ref-based subscriptions; its API is older and more established but uses uncontrolled inputs by default, which can conflict with controlled component libraries. Conform is the newest of the three and leans on the platform — it serializes form state into the HTML <form> element, which integrates naturally with progressive enhancement and Server Actions but offers less flexibility for complex client-side flows. TanStack Form sits between them: controlled inputs like Formik, render-prop subscriptions like RHF v6, and explicit adapters for every schema library.

If your codebase still references import { useField } from "@tanstack/react-form", that pattern predates v0.20 and should be migrated to <form.Field> or form.useField depending on your version. The hook signature changed, and the older form leaks subscriptions in dev mode under React 18 strict mode.

Still Not Working?

A few less-obvious failures:

  • form.state.values doesn’t update. You read it once in component body. Use form.Subscribe or useStore for reactive access.
  • onSubmit doesn’t run. A <form.Field> validator is failing, blocking submit. Check form.state.errorMap to see which field is invalid.
  • Validation errors show on first render. A field’s validators.onMount ran. Switch to onChange if 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 const aggressively.
  • field.handleBlur doesn’t trigger onBlur validator. You forgot to wire it to the input: onBlur={field.handleBlur}. The validator only runs when handleBlur is called.
  • Form values are undefined after reset. You passed a partial object to reset — TanStack Form replaces the entire values object. Pass all fields, or call reset() with no args to restore defaultValues.
  • Adapter version mismatch. @tanstack/zod-form-adapter and @tanstack/react-form should be on compatible majors. Check both versions if validators stop firing after an upgrade.
  • Submitting double-fires. You called form.handleSubmit() inside an onSubmit={form.handleSubmit} on the <form> element. Pick one — usually onSubmit={(e) => { e.preventDefault(); form.handleSubmit(); }}.
  • Dependent field validators do not re-run when a peer changes. Pre-v0.30 there was no built-in way to express dependencies. On 0.30+, use validators.deps: ["otherField"] so the validator re-runs when otherField changes. Without it, the dependent validator only fires on its own field’s edits.
  • Array field reorder breaks input focus. React uses the key to identify list items; using the array index as the key makes a moved input look like a new node. Generate stable IDs at pushValue time (e.g. crypto.randomUUID()) and key by that ID instead.
  • Strict Mode double-mount duplicates async validator calls in dev. React 18+ Strict Mode mounts/unmounts each component twice in dev. If you also lack the abort signal, the first run leaks. Always thread signal from the validator into your fetch call so the second mount cancels the first.

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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles