The first form in a new app is always cute. Two text fields, a submit button, a useState per field, maybe a Zod schema if the team is feeling fancy. It works. Everyone's happy.
Six months later, that form has eleven sections, a "Save and continue later" button, an autosave indicator, conditional fields based on the user's plan, an admin-only diff view, server-side validation that disagrees with client-side validation, and a section that actually lives on a different team's microservice. Nobody is happy. Especially the person who has to add the next field.
This is the article I wish I'd had the first time I watched a "simple" onboarding form turn into a 3,000-line React component. The library you reach for matters less than how you draw the lines around the form.
The Library Question Is Real, But It's Not First
Just to get it out of the way: in 2026, the React form-library landscape has settled into a few clear options.
- React Hook Form is still the most popular pick. Uncontrolled by default, tiny re-renders, plays beautifully with Zod via
@hookform/resolvers/zod. If you don't have a strong reason to look elsewhere, this is the default. - TanStack Form is the newer headless option. Framework-agnostic core, first-class async validation, very precise type inference. Worth a look if your team is already deep in TanStack Query and Router and wants the same authoring style.
- Conform is the Remix-flavored progressive-enhancement library by Edmund Hung. Its trick is that the same schema validates on the server and the client, and forms work without JavaScript at all. If you're on Remix or React Router 7 with server actions, this is the one to know.
- Formik is still around, still works, but the project has been quiet for a while. New work usually starts on RHF instead.
- React 19's built-in form Actions (shipped December 2024) are not a library, but they change the calculus.
useActionState,useFormStatus, and the<form action={fn}>API give you progressive-enhancement flow out of the box, which makes a Conform-style approach viable without a library.
Pick one. Don't pick three. The disasters I've cleaned up most often were forms that had RHF at the top, plain useState in two sections, and Formik somewhere else "because the original engineer was used to it." The form library is a boundary tool. The whole form should sit inside it.
The Schema Is The Form
The single most important decision is making the schema — not the React component — the source of truth.
import { z } from "zod";
export const companyProfileSchema = z.object({
legalName: z.string().min(2),
country: z.enum(["US", "DE", "UA", "PL"]),
taxId: z.string().optional(),
billingEmail: z.string().email(),
plan: z.enum(["starter", "team", "enterprise"]),
}).refine(
(data) => data.plan !== "enterprise" || !!data.taxId,
{ message: "Tax ID is required on the enterprise plan", path: ["taxId"] }
);
export type CompanyProfile = z.infer<typeof companyProfileSchema>;
That schema is the contract. The form uses it via a resolver. The server uses it again before writing to the database. The TypeScript type comes out of it for free. When a product manager asks for "tax ID required on enterprise" you change one place. The conditional rendering, the validation, the API contract — they all read from the same Zod object.
The mistake I see most: schemas defined per-field inside the component, with the server doing its own thing. Now you have two truths and they will drift. Always.
Multi-Step Forms Are State Machines, Not Pages
A "wizard" form looks like a series of pages. It is not. It is a state machine where each state has its own validation slice, the user can move backwards, and the whole thing must be persistable mid-flight.
Two practical shapes work well:
- One big form, many fieldsets. The whole form lives in a single React Hook Form instance. Each step renders a subset of fields. Step navigation calls
trigger()on the relevant field names to validate just that slice before moving on. This is the simplest approach when steps share data heavily. - One form per step, shared draft store. Each step is its own form, and the persisted draft (in IndexedDB, a server draft, or both) is the actual source of truth between steps. This scales better when steps are owned by different teams or live behind different routes.
The second shape pairs nicely with useActionState in React 19 — each step posts to its own server action, and the action returns the merged draft.
What you don't want is the in-between: shared global Zustand state plus three independent forms plus a separate "submit everything" button at the end. That's three sources of truth and it always loses someone's data.
Server Validation Is The Real Validation
Client validation exists to make the form feel good. It does not exist to make the form correct. Every Zod check you do in the browser must run again on the server, against the same schema, before any database write.
The cleanest pattern in 2026 looks like this:
// app/actions/save-company-profile.ts
"use server";
import { companyProfileSchema } from "@/schemas/company-profile";
import { auth } from "@/lib/auth";
export async function saveCompanyProfile(_: unknown, formData: FormData) {
const session = await auth();
if (!session) return { ok: false, error: "Not signed in" };
const parsed = companyProfileSchema.safeParse(
Object.fromEntries(formData)
);
if (!parsed.success) {
return { ok: false, fieldErrors: parsed.error.flatten().fieldErrors };
}
// ...write to db
return { ok: true };
}
The component side uses useActionState and the form errors flow back as part of the state. Critically, the server can return more errors than the client knew about — taken usernames, billing systems that disagree, a tax ID that's structurally valid but rejected by the upstream service. Treat server errors as a superset of client errors and render them in the same fieldErrors structure.
If your form library is React Hook Form, map server fieldErrors into setError(name, { type: "server", message }) after submit. The UX should not distinguish "the server rejected this" from "you typed something wrong" — the user just wants to know which field needs fixing.
Drafts Need An Owner
"Save and continue later" is a deceptively expensive feature. Done badly, it spreads draft state across localStorage, IndexedDB, the React tree, and the server, and every reload is a small game of "which one wins?"
Pick one owner. A pattern I trust:
- The server owns the draft. There's a
drafttable or a JSON column on the parent record. Every autosave writes there. Submit promotes the draft to the real record. - The client writes through to the server, and only falls back to IndexedDB if it's offline. When the user reconnects, the queued offline write replays.
This means the form on page load reads from the server's draft, not from localStorage. Two devices stay in sync. There's no "the draft from my laptop overwrote my desktop" bug.
The other pattern — pure client-side drafts — is fine for a single-device, low-stakes form. It is not fine for an onboarding flow that finance, ops, and the user's accountant all touch.
Conditional Fields Belong In The Schema, Not The JSX
If you find yourself writing this:
{plan === "enterprise" && <TaxIdField />}
…that's fine for rendering. But the validation rule — "tax ID is required when plan is enterprise" — must live in the schema, not in the component. Otherwise an API client that posts JSON directly will skip it, and your "required" field is required only in the browser.
Zod's .refine and .superRefine are the right tools. Discriminated unions help when whole sections appear and disappear together:
const billing = z.discriminatedUnion("plan", [
z.object({ plan: z.literal("starter") }),
z.object({ plan: z.literal("team"), seats: z.number().int().min(2) }),
z.object({ plan: z.literal("enterprise"), taxId: z.string().min(1) }),
]);
Now the type system itself knows that seats only exists when plan === "team". Your component can .watch("plan") and switch on it, and TypeScript will refuse to let you read seats in the wrong branch.
Permissions, Roles, And The Honest Form
In a real product, not every field is editable by every user. A common shape: the user fills out their company profile; the support team can edit a few admin-only fields the user never sees; the billing system reads but doesn't write.
Two failure modes I see often:
- Hiding fields and calling it security. Hiding the "billing override" field for non-admins is fine UX. The server still has to enforce it. A determined user can post any field they want.
- One mega-form for every role. You end up with
if (role === "admin")sprinkled through twenty fields. Better: split into a base schema and an admin-only extension, and pick at render time based on role.
const adminOnlyFields = z.object({
billingOverride: z.number().optional(),
internalNotes: z.string().optional(),
});
const adminCompanyProfileSchema =
companyProfileSchema.merge(adminOnlyFields);
The server picks the right schema based on the session. The client renders the right fields based on the role. Same Zod. Same source of truth.
What I'd Tell Past Me
The forms that hurt later are the ones that started as "just a form." The ones that hold up are the ones where someone sat down on day one and drew the seams: the schema is the contract, the server is the truth, drafts have one owner, the wizard is a state machine, and the form library is one library, used everywhere.
None of this is glamorous. None of it shows up in a demo video. But the day a teammate adds the eleventh section and the form still feels boring to extend, you'll know the architecture earned its keep.





