Skip to content

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

FixDevs ·

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.

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.

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(); }}.

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