Back to all posts

Forms in React: Where TanStack Form Earns Its Place

Jul 09, 2026
13 min read
Forms in React: Where TanStack Form Earns Its Place

Every codebase has one file everyone is a little afraid of. In the apps I've worked on, it's usually a form.

Not a login form. Those are fine. I mean the real one — the checkout, the policy application, the onboarding flow with the field that only appears when you pick "business account," which itself unlocks a tax ID field, which has to be validated against a server, but only after the user stops typing, and only if the country is one of four. That form. The one where every new requirement lands as another if somewhere in a 600-line component.

Forms are where clean React quietly falls apart. Everywhere else you can lean on good structure and it holds. Forms fight back. They mix local state, async work, cross-field logic, and user timing into one knot, and the naive version — a pile of useState and a submit handler that reads all of them — works right up until the requirements get real.

So this article is about the tool I reach for when a form stops being trivial, and, just as importantly, about the moment you actually need one. Because reaching for a form library too early is its own mistake.

When You Don't Need a Form Library

Let me start against my own topic. A lot of forms don't need a library at all.

function NewsletterSignup() { const [email, setEmail] = useState(''); return ( <form onSubmit={handleSubmit}> <input value={email} onChange={(e) => setEmail(e.target.value)} /> <button type="submit">Subscribe</button> </form> ); }

That's a form. It's fine. One field, no cross-field rules, validation you can do in a single line. Adding a library here buys you nothing but an import. I've seen people install a form framework for a search box, and it always reads like insecurity — reaching for structure the problem doesn't have yet.

The line I use is simple: the moment fields start depending on each other, or validation has to talk to a server, or the same form has more than a handful of inputs, the hand-rolled version starts costing more than it saves. Below that line, useState is the right answer. Above it, you want a real tool. Most interesting forms sit above it.

What Actually Makes Forms Hard

Before the library, it's worth naming what we're fighting, because the pain isn't "typing out inputs." It's four things that stack:

  • State that spreads. Every field is state, but so is touched, dirty, validating, error, submitting. Multiply by twenty fields.
  • Cross-field logic. Field B is required only if field A has a value. Field C's options depend on field D. The rules form a little graph.
  • Async validation. "Is this username taken?" means a server round-trip, debounced, cancelable, with a pending state the UI has to show.
  • Re-render cost. The naive approach lifts everything to one state object, so one keystroke re-renders the entire form. On a big form you feel it.

A good form library is really a tool for managing that specific mess. That's the lens I judge them through — not "does it render inputs," but "does it make those four things smaller."

Why TanStack Form

There are three names worth taking seriously: Formik, React Hook Form, and TanStack Form. I'll be fair to all of them, because none is a bad tool.

Formik was the default for years. It's stable and widely known. But its model re-renders broadly by default, and its TypeScript story shows its age — types feel bolted on rather than designed in. On a large, dynamic form you end up working around it.

React Hook Form is genuinely good, and if you're happy with it I won't try to talk you out of it. It's fast, because it leans on uncontrolled inputs and refs, and it's the right answer for a lot of teams. My hesitation is narrow: that ref-based, uncontrolled model gets awkward exactly where my forms get hard — deeply dynamic fields, tightly controlled components, cross-field reactivity. It optimizes for the common case in a way that can fight the uncommon one.

TanStack Form is the one I default to in this series, and the reason is consistency more than superiority. It's type-safe to the core — your field names, values, and errors are all inferred, so renaming a field is a compiler problem, not a runtime surprise. It's granularly reactive: you subscribe to the exact slice of form state a component cares about, and only that component re-renders. And it's headless and framework-agnostic, which means it makes no styling decisions and hands control back to you — the same principle that decided the UI library article.

If you've followed this series, that reactivity model should feel familiar. It's the same instinct behind the Reatom choice: fine-grained subscriptions instead of one big blob that re-renders on every change. A form is just reactive state with a lot of edges, and TanStack Form treats it that way.

The Shape of It

Here's the same policy form, the kind this series keeps circling, wired with TanStack Form.

import { useForm } from '@tanstack/react-form'; function PolicyApplication() { const form = useForm({ defaultValues: { accountType: 'personal', holderName: '', taxId: '', }, onSubmit: async ({ value }) => { await submitApplication(value); }, }); return ( <form onSubmit={(e) => { e.preventDefault(); form.handleSubmit(); }} > <form.Field name="holderName"> {(field) => ( <input value={field.state.value} onBlur={field.handleBlur} onChange={(e) => field.handleChange(e.target.value)} /> )} </form.Field> </form> ); }

The verbosity is real, and I won't pretend otherwise — the render-prop Field component is more ceremony than React Hook Form's register. But look at what you get for it: field.state.value is typed from defaultValues, name="holderName" is checked against the same shape, and everything inside that Field re-renders in isolation. The ceremony is buying type safety and surgical re-renders. On a small form that's a bad trade. On the 600-line one, it's the whole point.

Conditional Fields Without the Spaghetti

This is the part that sold me. Remember the "business account unlocks a tax ID field" requirement — the one that becomes an if in a naive form. Here it's just reading one field's value to decide whether to render another.

<form.Subscribe selector={(state) => state.values.accountType}> {(accountType) => accountType === 'business' ? ( <form.Field name="taxId" validators={{ onChange: ({ value }) => value.length === 0 ? 'Tax ID is required for business accounts' : undefined, }} > {(field) => ( <input value={field.state.value} onChange={(e) => field.handleChange(e.target.value)} /> )} </form.Field> ) : null } </form.Subscribe>

Two things matter here. First, form.Subscribe with a selector means only this branch re-renders when accountType changes — the rest of the form doesn't flinch. Second, the validation rule lives on the field that owns it, not in a giant central schema that has to know about every conditional. The tax-ID rule exists only when the tax-ID field exists. When the field unmounts, its rule goes with it. That co-location is what keeps dynamic forms from rotting.

Async Validation That Doesn't Fight You

The "is this taken?" problem is where hand-rolled forms accumulate the most incidental complexity — debouncing, cancellation, a pending flag, a race condition you didn't see coming. TanStack Form has a slot for exactly this.

<form.Field name="username" validators={{ onChangeAsyncDebounceMs: 400, onChangeAsync: async ({ value }) => { const taken = await checkUsernameTaken(value); return taken ? 'That username is already in use' : undefined; }, }} > {(field) => ( <> <input value={field.state.value} onChange={(e) => field.handleChange(e.target.value)} /> {field.state.meta.isValidating && <span>Checking…</span>} {field.state.meta.errors[0] && <span>{field.state.meta.errors[0]}</span>} </> )} </form.Field>

The library owns the parts that are easy to get subtly wrong: the debounce, the in-flight tracking via isValidating, cancelling a stale check when the value changes again. You own the one thing that's actually your business logic — the checkUsernameTaken call. That's the right division of labor. I don't want to write another debounced-async-with-cancellation dance by hand; I've written it enough times to know I'll get the edge case wrong.

Let the Schema Do the Talking

Inline validators are fine for one-off rules, but real forms usually already have a schema — and TanStack Form is happy to defer to it. Point a field's validator at a Zod schema and the types and the rules come from one source.

import { z } from 'zod'; const holderName = z.string().min(2, 'Name is too short'); <form.Field name="holderName" validators={{ onChange: holderName }}> {(field) => (/* … */)} </form.Field>

Now the validation rule and the TypeScript type live in the same place, and neither can drift from the other. This is the same instinct as everything else in the series: one source of truth, let the compiler enforce it. A form is often where a Zod schema you already have — for your API payload — gets a second job validating the UI. Reuse it. Don't write the shape twice.

Where a Form Library Still Can't Save You

I want to end honestly, because it's easy to oversell this. A form library manages state, validation, and re-renders. It does not manage product complexity, and most painful forms are painful because the product is.

Multi-step wizards with a back button that has to preserve state. Fields whose valid options depend on an API call that depends on another field. Save-as-draft that has to serialize a half-finished form. A submit that's really five API calls with partial-failure handling. TanStack Form gives you a clean foundation for all of it — but the wizard's flow, the dependency graph, the draft format, the failure strategy are yours to design. The library keeps the state honest. It doesn't tell you what the state should be.

That's the realistic version. The right tool removes the incidental pain — the re-renders, the type drift, the debounce dance — so that what's left is the essential pain, the actual product logic. That's the most any library can do, and it's a lot. A form that only hurts where the problem is genuinely hard is a form you can live with.

For the Playbook, TanStack Form is the choice, for the same reason shadcn/ui and Reatom were: it's headless, type-safe, and granularly reactive, so it points the same direction as everything else we've built. Coherence over any single feature.

Forms handle the data going in. The next article is about making sense of data coming out — visualization and charting in React, and why "just use a chart library" is a much bigger decision than it sounds.

If you've got a form in your codebase that everyone tiptoes around, I'd genuinely like to hear what makes it hard — the specific requirement that broke the clean version. That's usually where the interesting design problems live, and I'm always looking for the ones I haven't hit yet.

Found this helpful?

Let's discuss your project needs.

Get in touch