Forms at scale
How to hold, validate, and submit form data so users see the right error at the right moment, never lose what they typed, and get the same rules on the client and the server.
Why forms are harder than they look
Picture a sign-up form with four fields: name, email, username, and password. It looks like the simplest screen in the app. Then the bug reports arrive.
- A user starts typing an email and sees "Email is invalid" in red after the first letter, before they had a chance to finish.
- Another user types a username, sees "Available", submits, and gets "Username taken" from the server. The check answered for an older value.
- Someone on a train fills in a long job application, loses signal, refreshes, and every field is empty again.
- A screen reader user presses Submit. Nothing seems to happen: the errors appeared in red text, but nothing told them so.
- The client says a password of 8 characters is fine. The server wants 10. The user sees a generic "Something went wrong".
None of these are rare. Each one comes from a decision nobody made on purpose: where the value lives, when to check it, where the rules live, and what to do with half-finished work. This lesson makes those decisions explicit.
Form state is its own kind of state
The lesson on state categories names five kinds of state: server, client UI, URL, form, and stream. This lesson goes deep on the fourth. Form state is a draft: values the user has not committed yet. It belongs to the user until they press Submit, and then it becomes a request to the server.
A draft has more parts than just the values. For each field you also track whether the user changed it, whether they have left it, whether it is valid, and whether a check is still running. For the whole form you track whether it is submitting, how many times it was submitted, and what the server said last.
| Piece of state | Per field or per form | Example | Who reads it |
|---|---|---|---|
| value | field | email: "ana@ex" | the input, the submit handler |
| dirty | field and form | email differs from its starting value | the Save button, the leave-page warning |
| touched | field | the user focused email and left it | whether to show its error yet |
| error | field and form | "Enter a full email address" | the error text, aria-invalid |
| validating | field | a username check is in flight | a small spinner next to the field |
| submitting | form | the POST is in flight | the disabled Submit button |
| submitCount | form | 2 | whether to show every error at once |
| serverErrors | form | { username: "taken" } | errors that only the server can know |
What "at scale" means here
"At scale" does not mean millions of users. It means many forms, many fields, and many teams: a 25-field checkout, a 6-step insurance quote, an admin tool with 40 forms built by 5 teams. At that size, the choices in this lesson must be made once and shared, not re-invented in every component.
Where the value lives: controlled and uncontrolled inputs
The first decision is who owns the text inside each input. There are two owners to choose from: your JavaScript state, or the DOM itself. Everything else in this lesson builds on that choice.
Controlled: your state owns the value
In a controlled input, the value lives in component state. The input shows value={email}, and every keystroke calls onChange, which calls setEmail, which re-renders the component, which puts the new value back in the input. Your code sees every character the moment it is typed.
That is useful when the UI must react while the user types: formatting a card number into groups of four, a live character counter, a preview, or enabling Submit only when the form is valid. The cost is one render per keystroke. For one field that costs nothing. For 40 fields held in one parent component, each keystroke renders all 40, and on a slow phone that shows up as typing lag.
Uncontrolled: the DOM owns the value
In an uncontrolled input, the browser keeps the value, as it always did before frameworks. You set a starting value with defaultValue and read the current value only when you need it: through a ref, or all at once with new FormData(form) on submit. Typing renders nothing in React.
This is cheaper and closer to the platform. Browser autofill, password managers, and the browser's own form restore all work on the DOM value, so they work without help. React Hook Form is built on this idea: it registers inputs through refs and re-renders only the parts that show errors or values you asked to watch.
FormData, when the form is submitted.React 19: form actions and useActionState
React 19 builds its form features on the uncontrolled style. You can pass a function to <form action={...}>. On submit, React calls it with the form's FormData, inside a transition, so the page stays responsive. useActionState wraps that function and gives you three things back: the last result (for example, errors from the server), a wrapped action to pass to the form, and an isPending flag.
type Result = { errors?: Record<string, string>; values?: Record<string, string> };
async function signUp(prev: Result, form: FormData): Promise<Result> {
const values = Object.fromEntries(form) as Record<string, string>;
const res = await fetch("/api/sign-up", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(values),
});
if (res.ok) return {};
const body = await res.json(); // { errors: { username: "Taken" } }
return { errors: body.errors, values }; // keep what the user typed
}
export function SignUpForm() {
const [result, action, isPending] = useActionState(signUp, {});
return (
<form action={action}>
<label htmlFor="username">Username</label>
<input id="username" name="username" defaultValue={result.values?.username} />
{result.errors?.username && <p>{result.errors.username}</p>}
<button disabled={isPending}>Create account</button>
</form>
);
}
useState per field: the browser holds the values, FormData collects them, and useActionState holds only the result.Two details catch people. First, every input needs a name, because FormData collects by name, not by id. Second, after an action passed to <form action> finishes without throwing, React resets the uncontrolled fields to their default values. Returning errors still counts as finishing, so if the server rejected the form, return the submitted values and feed them back through defaultValue, as above, or the user's input disappears together with the error.
Most real forms mix both
The choice is per field, not per app. A good default: uncontrolled for plain text fields, controlled for the few fields whose value drives other UI while typing. A checkout form might keep 20 address fields uncontrolled and control only the card number (for formatting) and the country (because it changes which fields appear below it).
| Question | Controlled | Uncontrolled |
|---|---|---|
| Who holds the value? | React state | The DOM input |
| Renders per keystroke | At least the owning component | None, unless you watch the field |
| Read the value | Any time, from state | On demand: ref, FormData, or a library getter |
| Format while typing | Easy | Awkward: you fight the DOM |
| Autofill and password managers | Work, but you must handle the change event | Work with no extra code |
| Works before JavaScript loads | No | Yes, with a real action URL or a server action |
Check your understanding · Exercise 1
A settings page has 60 text fields in one component, all controlled by one useState object. Typing feels slow on mid-range Android phones. Which changes would help? Select all that apply.
The state around the value: dirty, touched, submitting
Values are only half of form state. The other half is metadata: small flags that answer questions the UI keeps asking.
Should this error show yet? Can the user leave without a warning? Should Submit be disabled? Each flag answers exactly one of these, and mixing them up is the source of most form bugs.
Dirty: has the value changed from where it started?
A field is dirty when its current value differs from its initial value. The form is dirty when any field is. To know this, you must keep the initial values, not just the current ones. On an edit page the initial values come from the server; on a create page they are the defaults.
Dirty drives three things: enabling Save, warning before the user leaves with unsaved work, and sending only changed fields in a PATCH. A common mistake is a flag that flips to true on the first keystroke and never flips back. If the user types a letter and deletes it, the field is clean again. Compare values; don't count keystrokes.
After a successful save, the saved values become the new initial values. Forget that and the form stays dirty forever, and the leave-page warning fires after every save.
Touched: has the user been in this field and left it?
A field is touched after it got focus and then lost it (a blur). Touched has nothing to do with the value. Its whole job is to answer "is it fair to show this field's error now?".
A user who tabs into email, types nothing, and tabs out has touched it, so a "Required" error is fair. A user who has not reached the field yet should not see that error.
Submitting, submit count, and server errors
The form as a whole has its own flags. Submitting is true while the request is in flight: disable the button or ignore a second click, so a double click does not create two accounts. Submit count says whether the user has tried to submit at least once; after the first attempt, every error should show, touched or not, because the user asked for a verdict.
Server errors are errors only the server can know: the username is taken, the coupon expired, the card was declined. Keep them apart from client errors, and clear a field's server error as soon as the user edits that field. Otherwise "Username taken" stays on screen while they type a new name that is free.
Field arrays: lists of rows
Many real forms have repeating rows: line items on an invoice, guests on a booking, phone numbers on a contact. This is a field array. Each row has its own values, errors, and touched flags, and the user can add, remove, and reorder rows.
- Give each row a stable id when it is created, and use it as the React
key. Using the array index as the key makes React reuse the wrong DOM input after a row is deleted, so the typed text appears in the wrong row. - Name the fields by path, like
items.2.quantity. Schema errors come back with the same path, so they land on the right row. - Validate the list too, not only each row: "at least one guest", "no duplicate emails".
- React Hook Form's
useFieldArrayand TanStack Form's array fields handle ids and paths for you.
Dependent fields
Some fields depend on others. The state list depends on the country, "Confirm password" must equal "password", and the end date must come after the start date. A VAT number is required only for business accounts.
- Rules that span fields belong in the schema, attached to the field that shows the error (the confirm field, the end date).
- Re-check the dependant when the source changes. If the user edits the password after typing the confirmation, the confirmation must be checked again, even though the user did not touch it.
- Clear or keep hidden values on purpose. When country changes from US to Germany, the old state value is meaningless. Clear it, or at least do not submit fields the user can no longer see.
Check your understanding · Exercise 2
An edit-profile form loads { name: "Ana" }. The user changes the name to "Anna", then back to "Ana". Then they click Save on a second field, which succeeds. Which statements are true? Select all that apply.
When to show errors
Running validation is cheap: checking that a string has an @ takes microseconds. The hard part is when to show the result. The same rule feels helpful at one moment and rude at another. Validation timing is a UX decision, not a technical one.
Too early and too late
Show errors too early and the form scolds people for work they have not finished. Type the first letter of an email and see "Enter a valid email" in red: it is technically true and completely unhelpful. Premature errors slow people down and make them doubt answers that were about to be correct.
Show errors too late, only on submit, and the user fills in 20 fields before learning the third one was wrong. They now scroll back up, fix it, and submit again. On a long form that feels like being sent back to the end of a queue.
The four strategies
Decision When should a field show its error?
What we pick: Use blur first, then change, for almost every text field. Use on change only for fields where live feedback is the point (a password meter, a character count shown as information, not as an error). Use submit only for very short forms like a login box, where waiting costs nothing. React Hook Form calls the recommended mode onTouched; with its default onSubmit mode it re-validates on change after the first submit, which is a reasonable second choice.
Not everyone validates inline. The GOV.UK Design System, built for public services used by everyone, validates on submit, then shows an error summary at the top and a message on each field. Their reasoning: inline errors can interrupt people who fill forms slowly or with assistive technology.
That is a valid choice too, as long as the submit-time errors are excellent. Whichever you pick, pick it once for the whole product.
Try it: the same typing session, four timings
The lab below replays one scripted session on a sign-up form. The user types an email with a typo, moves to the password, types a short one, goes back, and fixes both. Pick a timing and step through the session. Watch when each error appears and disappears, and count how many moments showed an error the user did not deserve.
What the browser already does
HTML has built-in rules: required, type="email", minlength, maxlength, min, max, and pattern. The browser checks them on submit and shows its own bubble. JavaScript can read the result through input.validity and add custom messages with setCustomValidity.
The built-in bubbles are hard to style, so most apps add novalidate to the form and render their own messages. Keep the attributes anyway: they switch mobile keyboards (type="email" shows an @ key) and still work if your JavaScript fails to load.
CSS can follow the same timing. The :user-invalid pseudo-class matches an invalid field only after the user has interacted with it, unlike :invalid, which matches an empty required field on page load. It works in all major browsers since late 2023.
Check your understanding · Exercise 3
With the "blur first, then change" strategy, which of these moments should show an email error? Select all that apply.
One schema for the browser and the server
Where do the rules live? In many codebases, everywhere: an if in the email component, a regex in a helper, a length check in the API handler. They drift, and the user pays: the browser accepts an 8-character password, the server wants 10, and the user sees a generic failure.
Describe the data once
A schema is a description of what valid data looks like, written as code. Libraries like Zod, Valibot, and ArkType let you write it once and get two things from it: a function that checks a value and returns either clean data or a list of problems, and a TypeScript type for the clean data. Put the schema in a module both the client and the server import.
// shared/sign-up.ts: imported by the form and by the API handler
import { z } from "zod";
export const SignUp = z
.object({
email: z.email("Enter a full email address, like ana@example.com"),
username: z
.string()
.min(3, "Use at least 3 characters")
.max(20, "Use 20 characters or fewer")
.regex(/^[a-z0-9_]+$/, "Use lowercase letters, digits, and _ only"),
password: z.string().min(10, "Use at least 10 characters"),
confirm: z.string(),
})
.refine((v) => v.password === v.confirm, {
error: "The passwords do not match",
path: ["confirm"], // show it on the confirm field
});
export type SignUpInput = z.infer<typeof SignUp>;
Write the messages for people, not for developers. "Enter a full email address, like ana@example.com" tells the user what to do. "Invalid email" only tells them they failed. The schema is a good place for these messages because then every form that uses the field says the same thing.
422 errors end up in the same field-error shape, so the form shows both the same way.The server runs the same schema, and it is the one that counts
Browser validation is for the user's comfort. It is not security: anyone can call your API with curl and skip the form. The server must parse every request with the same schema and reject what fails. This is the "validate at the boundary" rule from the lesson on API contracts and errors, applied to forms.
The server should answer a failed submit with field-level errors, not a single message. A 422 status with a map from field path to message lets the form put each error next to its field, exactly like client errors. Zod's issues already carry a path, so the map is a few lines.
// API route handler
export async function POST(req: Request) {
const parsed = SignUp.safeParse(await req.json());
if (!parsed.success) {
const errors: Record<string, string> = {};
for (const issue of parsed.error.issues) {
const key = issue.path.join("."); // "confirm", "items.2.qty"
errors[key] ??= issue.message; // first message per field
}
return Response.json({ errors }, { status: 422 });
}
if (await usernameTaken(parsed.data.username)) {
return Response.json({ errors: { username: "That username is taken" } }, { status: 422 });
}
await createUser(parsed.data);
return Response.json({ ok: true }, { status: 201 });
}
Plugging the schema into the form
Form libraries accept a schema directly. React Hook Form takes a resolver, zodResolver(SignUp), from its resolvers package. TanStack Form accepts any library that implements Standard Schema, a small shared interface that Zod, Valibot, and ArkType all implement, so you can pass the schema as a validator with no adapter.
To validate one field on blur, libraries run the full schema and keep only that field's issues. If you build it yourself, do the same: parse the whole form, then filter issues by path, so cross-field rules like "passwords match" keep working.
Check your understanding · Exercise 4
Your team shares a Zod schema between the form and the API. Which statements are true? Select all that apply.
Async validation: is this username free?
Some rules need the server: is this username free, is this coupon code valid, does this postcode exist, is this email already registered? These checks are slow (one network round trip, often 100 to 400 ms on mobile), cost server work, and can return out of order. They need the same care as a search box.
Six rules for an async check
- Run the sync rules first. If the schema says the name needs 3 characters, do not ask the server about
as. Only valid-looking values reach the network. - Wait for a pause. Debounce the check by about 300 to 500 ms after the last keystroke, so typing
ana_lopezsends one request, not nine. See the lesson on debounce and throttle. - Drop stale answers. If the user changed the value while a check was in flight, that answer is about an old value. Abort the old request with an
AbortController, and also compare the value the answer is for with the current value before you show it. - Show that you are checking. A small "Checking..." next to the field, then a result. Do not block typing.
- Remember answers. Cache results by value for a short time, so going back to a name you already checked is instant and free.
- Treat the answer as a hint. Someone else can take the name one second after your check said it was free. The server's answer to the real submit is final, and the form must show a "taken" error from the submit like any other field error.
Why aborting is not enough on its own
Aborting stops your code from waiting, but it can lose a race: an answer that has already arrived can still run its handler. So also store the value with each request and show the answer only if the field still holds that value. The lesson on request races covers this pattern in general.
const cache = new Map<string, boolean>(); // value -> available
let controller: AbortController | undefined;
export async function checkUsername(value: string, current: () => string) {
if (cache.has(value)) return cache.get(value)!;
controller?.abort();
controller = new AbortController();
const res = await fetch(`/api/usernames/${encodeURIComponent(value)}`, {
signal: controller.signal,
});
const { available } = (await res.json()) as { available: boolean };
cache.set(value, available);
if (current() !== value) throw new Error("stale"); // the field moved on
return available;
}
AbortError and "stale".Try the race below. The network is scripted so the answer for the older value arrives last. With no guard, the field ends up showing an answer for a name the user is no longer typing.
3 requests sent. The field holds ana_l and finally says "ana is taken": wrong, an answer for an old value.
Async checks in form libraries
TanStack Form has async field validators with an asyncDebounceMs option. By default it skips them when the sync rules fail (the asyncAlways option changes that). React Hook Form accepts async functions in validate but does not debounce them, so many teams run the check themselves and report the result with setError.
Check your understanding · Exercise 5
A user types ana and pauses, then types ana_l and pauses. The check for ana returns after the check for ana_l. Which guards keep the right answer on screen? Select all that apply.
Multi-step forms
A 40-field form on one page is tiring to look at. Split into steps (account, address, payment, review), the same 40 fields feel shorter, and each step can explain itself. Insurance quotes, loan applications, checkout flows, and onboarding all work this way. Steps also add new state: which step you are on, which steps are done, and which steps exist at all.
One form, many views
The most important rule: a multi-step form is one form with one set of values, shown a slice at a time. Do not build each step as its own form with its own state. If you do, going Back loses data, the review step has to gather values from five places, and a rule that spans steps ("the start date on step 3 must be after the birth date on step 1") has nowhere to live.
Keep the values in one store above the steps: a form library instance in a parent component, or a reducer. Each step renders its fields and reads and writes that shared store. Unmounting a step must not delete its values; with React Hook Form, keep shouldUnregister at its default of false for this reason.
Validate per step, then everything
- Give each step its slice of the schema. With Zod, write one object schema per step, build the full schema from their shapes, and add cross-step rules (with
refine) only on the full one. - On Next, validate only the current step. Do not tell the user about payment errors while they are on the address step.
- On the final submit, validate the whole form again. The user may have gone back and changed step 1, which can break a cross-step rule.
- The server validates the whole thing, as always. If it returns an error for a field on step 2, send the user to step 2 and focus that field.
The step pointer is a state machine
A linear wizard is just a number: step 1, 2, 3. Real wizards branch: a business account gets an extra "company details" step, a user who picks "collect in store" skips the shipping step, and a failed payment returns to the payment step with an error.
Once there are branches, model the flow as a state machine, as described in the lesson on state machines: each step is a state, Next and Back are events, and the branches are guards that read the form values.
Put the step in the URL
Put the current step in the URL, like /apply?step=address or /apply/address, as described in the lesson on URL as state. The browser's Back button then moves to the previous step instead of leaving the form, and a refresh lands on the same step. But the URL is input the user can edit: on load, check that every earlier step is valid, and if not, redirect to the first incomplete step.
When the step changes, move focus to the new step's heading so screen reader users know the page changed, and show progress ("Step 2 of 4: Address") as text, not only as a bar.
Check your understanding · Exercise 6
A four-step checkout keeps each step's values in that step component's useState. Which problems should you expect? Select all that apply.
Autosave and drafts: never lose what the user typed
Losing ten minutes of typing to a closed tab, a crashed browser, a dead battery, or an expired session is one of the worst things a web app can do. Users remember it. Autosave prevents it: the form saves its values as the user works and offers them back when the user returns.
When to save
- After a pause in typing, about 1 second, not on every keystroke.
- When a field loses focus, because the user just finished a thought.
- When the page is hidden. Listen for
visibilitychange(andpagehide): on mobile it is often the last event before the tab is frozen or discarded.beforeunloadandunloadoften do not fire there. - Even when fields are invalid. A draft is allowed to be incomplete or wrong; that is what makes it a draft. Validate on submit, not on save.
Where to save
Pick the store by size, by sensitivity, and by whether the draft must follow the user to another device. The lessons on client storage and IndexedDB go deeper.
| Store | Good for | Watch out for |
|---|---|---|
sessionStorage | Surviving a refresh in the same tab | Gone when the tab closes; not shared with other tabs |
localStorage | Small drafts (a few KB) that survive a closed browser | Synchronous, strings only, about 5 MB per origin, readable by any script on the page |
| IndexedDB | Large drafts, field arrays, attached files as Blobs | Async API; use a small wrapper like idb-keyval |
| Server | Drafts that must follow the user (email, documents, applications) | Needs an API, a status indicator, and conflict handling |
What a draft looks like
type Draft = {
schemaVersion: 4; // bump when the form's fields change
savedAt: number; // for "Draft saved 2 minutes ago"
baseVersion?: number; // server version the edit started from
step?: string; // where the user was in a wizard
values: Partial<ApplicationInput>; // never passwords or card numbers
};
const key = (userId: string, formId: string) => `draft:${userId}:${formId}`;
export function loadDraft(userId: string, formId: string): Draft | null {
const raw = localStorage.getItem(key(userId, formId));
if (!raw) return null;
const draft = JSON.parse(raw) as Draft;
return draft.schemaVersion === 4 ? draft : null; // old shape: ignore it
}
- Never store secrets. Leave out passwords, card numbers, security codes, and one-time codes. Anything in
localStoragecan be read by any script that runs on the page, including an XSS payload. - Scope by user and clear on sign-out, so the next person on a shared computer does not see the last person's draft.
- Clear the draft after a successful submit, not before. If the submit fails, the draft is the user's safety net.
- Ask before restoring when the draft is old or the form has server data: "You have an unsaved draft from 10:42. Restore it?" Restoring silently over fresh server data surprises people.
Autosaving to the server
Server autosave, as in Google Docs or a CMS editor, adds two problems. First, saves can overlap: a slow save for version A can finish after a fast save for version B and overwrite it. Allow only one save in flight; if the user changes more while it runs, remember only the latest values and send them once the first save finishes. Send only the dirty fields, with a PATCH.
Second, show the status in a small live region: "Saving...", "Saved", "Offline: saved on this device". Users trust autosave only when they can see it working.
When the draft and the server disagree
Edit forms load a record from the server. If someone else (or the same user in another tab) saves the record while this form is open, the form's draft is based on old data. Saving it blindly overwrites their change: a lost update.
The usual fix is optimistic concurrency. The server returns a version or an ETag with the record. The form sends it back on save, in an If-Match header or a version field.
If the record changed in between, the server refuses with 412 Precondition Failed (or 409 Conflict), and the form shows what changed and lets the user choose: keep mine, take theirs, or merge field by field. The lesson on conflict resolution covers the strategies; for most forms, a per-field comparison is enough because two people rarely edit the same field.
Check the same way when restoring a stored draft: if its baseVersion is older than the server's, tell the user first. Keeping two open tabs of one form in agreement is the topic of the next lesson, cross-tab sync.
Check your understanding · Exercise 7
You add local drafts to a long job application form. Which choices are right? Select all that apply.
Errors everyone can find
An error message helps only if the user notices it, understands it, and can get to the field. Red text under a field does all three for a sighted mouse user and none of them for a screen reader user who just pressed Submit. Accessible errors are mostly a few attributes and one focus move, so there is no reason to skip them.
On each field
- A real label. Every input needs a
labeltied to it withforandid(or by nesting), as covered in the lesson on semantic HTML. A placeholder is not a label: it disappears when the user types. aria-invalid="true"on the input while it has a shown error, so screen readers announce the field as invalid when focus lands on it. Set it on the same timing as the visible error, not on page load.aria-describedbypointing at the error text'sid(and at any hint text). The screen reader then reads the message after the label.aria-errormessageexists for the same job, but support has been uneven, soaria-describedbyis the safer choice today.- More than colour. Add an icon or the word "Error" next to the message; about 1 in 12 men have some form of colour vision deficiency.
- Say how to fix it. "Use at least 10 characters" beats "Invalid password".
<label for="email">Email</label>
<p id="email-hint">We send the receipt here.</p>
<input id="email" name="email" type="email" autocomplete="email"
aria-invalid="true" aria-describedby="email-hint email-error" />
<p id="email-error">Error: enter a full email address, like ana@example.com</p>
On submit: an error summary and one focus move
When a submit fails, the user needs to know three things: that it failed, how many problems there are, and where they are. An error summary at the top of the form answers all three. It has a heading ("There are 2 problems") and a list of links, one per error, each pointing at its field's id. Clicking a link moves focus to that field.
Then move focus to the summary (give it tabindex="-1" so it can take focus), so screen readers read it at once. For a short form, moving focus straight to the first invalid field is also fine. Moving nothing is not: the user presses Submit and, as far as they can tell, nothing happened.
tabindex="-1") so screen readers announce it at once; each link jumps to its field. The field errors repeat the same words, wired with aria-describedby.- Async status in a polite live region. "Checking..." and "Username available" can go in an element with
aria-live="polite", so they are announced without stealing focus. Do not announce every keystroke's validation result: that is the screen reader version of premature errors. - Do not disable Submit until the form is valid. A disabled button cannot take focus, gives no reason why, and leaves the user stuck. Let them press it and show the errors. Disabling it while a submit is in flight is fine.
- Use
autocompletetokens likeemail,username,new-password, andpostal-code. Autofill and password managers fill these correctly, which removes typing and errors at the same time.
Check your understanding · Exercise 8
A form shows red text under invalid fields after submit, and nothing else. What should you add? Select all that apply.
Libraries, and forms in the interview
Everything so far can be built by hand, and for a login box you should. For a product with dozens of forms, a form library pays for itself: every team gets the same dirty, touched, and error model, the same timing rules, and the same schema hookup.
Decision What should hold the form state in a React app in 2026?
What we pick: For a new React app with real forms, pick React Hook Form or TanStack Form with a shared Zod (or Valibot) schema. Use plain React 19 actions for small, server-first forms like a comment box. Keep Formik where it already works. Whatever you pick, wrap it in shared components (TextField, ErrorSummary, FormStep) so timing and accessibility rules live in one place.
Do not put form state in the global store
A form is a draft that belongs to one screen. In Redux or another global store, every keystroke goes through the store, other components can read half-typed data, and the draft outlives the screen. Keep form state local, as the lesson on global vs local state argues, and save it on purpose.
Check your understanding · Exercise 9
Your team is starting a large admin app with about 40 forms, some with field arrays and async checks. Which plan is sound? Select all that apply.
Forms in a system design interview
Few interview questions are titled "design a form", but many contain one: a checkout, a sign-up flow, a job application, an event booking, a settings page, a comment box. Interviewers notice when a candidate treats the form as a real part of the design instead of a box labelled "form".
You do not need a separate section for it. Bring forms up where they fit: in the data model (the draft and where it lives), in the API (field errors in a 422), and in the deep dives (validation timing, async checks, autosave). Two or three sentences at each point are enough unless the interviewer asks for more.
What to say, in order
- Where state lives: one local store per form, uncontrolled inputs, the wizard step in the URL.
- Rules: one schema on both sides; the server is the authority.
- Timing: blur, then change; everything on submit, with an error summary.
- Server checks: debounced, cancelled, compared, cached, repeated on submit.
- Drafts: versioned, no secrets, cleared after success; a version or
ETagfor edits. - Scale: a form library and shared field components.
How deep to go by level
| Level | What a strong answer includes |
|---|---|
| Junior | Controlled vs uncontrolled, client and server validation, sensible error timing, labels and error messages |
| Mid | Shared schema, dirty and touched, async check with debounce and race handling, multi-step with one store, local drafts |
| Senior | Server autosave with one save in flight, optimistic concurrency and conflict UI, wizard as a state machine with guards, privacy of drafts, a shared form platform for many teams, and how to measure form drop-off |
Summary
- Form state is a draft: values plus initial values, dirty, touched, errors, validating, submitting, and server errors. Keep it local to the form, never in a global store.
- Default to uncontrolled inputs with a
nameon each; control a field only when its value must drive UI while typing. React 19 form actions anduseActionStatefollow this model. - Show errors after the user leaves a field, re-check on every change once an error has shown, and show everything on submit. Never scold someone mid-word.
- Write the rules once as a schema shared by client and server. The server is the authority and returns field errors in the same shape.
- Async checks are debounced, cancelled, compared with the current value, cached, and repeated on submit.
- A multi-step form is one form with one store; the step pointer is a small state machine, kept in the URL.
- Autosave versioned drafts without secrets, clear them only after a confirmed submit, and use a version or
ETagto catch conflicting edits. - Accessible errors need
aria-invalid,aria-describedby, an error summary, and a focus move after a failed submit.
In the interview
Questions interviewers ask about this. Answer each out loud first, then compare.
Question 1
Design the validation for a sign-up form with a unique username. What runs where, and when?
One Zod schema holds the format rules and is imported by both the form and the API. In the browser, a field shows its error after the user leaves it, then re-checks on every change once an error has shown; on submit, every error shows, an error summary appears, and focus moves to it.
Uniqueness is an async check that runs only after the format passes, debounced by about 400 ms, with the old request aborted and the answer compared with the current value before it is shown. Answers are cached by value.
The server parses the request with the same schema and checks uniqueness again, because the name can be taken between the check and the submit. It returns a 422 with a field map, so "taken" shows next to the username field like any other error.
Question 2
A 40-field form lags when typing on mid-range phones. What is likely wrong and how do you fix it?
Most likely every field is controlled by one parent's state, so each keystroke re-renders all 40 fields, and maybe the whole schema runs on each keystroke too.
Fix it by making plain fields uncontrolled (React Hook Form, or FormData on submit), or by using a library where each field subscribes to its own slice (TanStack Form). Validate only the changed field and its dependants. Measure with the React Profiler before and after.
Question 3
How do you make sure a user never loses a long application form?
Autosave: debounce saves by about a second, save on blur and on visibilitychange. Store a versioned draft scoped to the user, in IndexedDB if it includes files, without secrets. Clear it only after the server confirms the submit, and clear all drafts on sign-out.
If the form must follow the user across devices, save each step to the server, with one save in flight and a visible status. For edit forms, send the record's version with each save so the server can refuse a stale write with 412, and show a conflict view instead of overwriting.
Question 4
How would you build a checkout wizard whose steps change for business customers?
One form store for all steps, so Back keeps values and cross-step rules have one place to run. A small state machine holds the current step: Next and Back are events, and a guard reading accountType decides whether the company-details step is included.
Each step validates its slice of the schema on Next; the final submit validates everything again, and the server does too. The step lives in the URL so refresh and Back work, with a check on load that earlier steps are complete. Focus moves to the step heading on each change.
Question 5
Controlled or uncontrolled inputs: which would you choose for a new form, and why?
Uncontrolled by default: the DOM holds the value, typing does not re-render, autofill works for free, and React 19's form actions and FormData read the values on submit. React Hook Form builds on the same idea.
Controlled only for fields whose value must drive UI while the user types: formatting a card number, a live preview, or a country field that changes which fields show next.
Practice it
These design problems lean on forms at scale.
- Design a star rating widgetEasy
- Design an OTP inputEasy
- Design a signature padEasy
- Design a custom select dropdownMedium
- Design a date pickerMedium
- Design a tag (multi-select) inputMedium
- Design a multi-step form wizardMedium
- Design an e-commerce cart and checkoutMedium
- Design sign up and sign inMedium
- Design a CSV import with column mappingMedium
- Design a food delivery appMedium
- Design an admin CMSMedium
- Design an e-signature workflow like DocuSignMedium
- Design a rich text editorHard
- Design a drag-and-drop form builderHard
- Design a checkout flowHard
- Design LinkedInHard
- Design a flight booking siteHard
- Design GmailHard
- Design a long insurance application formHard
References
- MDN: Client-side form validation HTML constraint validation, the validity API, and custom messages.
- MDN: :user-invalid Styling invalid fields only after the user has interacted with them.
- React: useActionState Form actions, the result state, and the pending flag in React 19.
- Zod documentation Schemas, refinements, issues with paths, and type inference.
- React Hook Form: useForm Validation modes such as onTouched, formState flags, and resolvers.
- TanStack Form Field subscriptions, sync and async validators, and async debounce.
- GOV.UK Design System: Error summary A well-tested pattern for showing errors after submit and moving focus.
- W3C WAI: Form notifications tutorial Accessible success and error messages for forms.
Read next
- State machines for UIModel a flow as a fixed set of states and the events that move between them, so impossible combinations like "loading and failed at once" can't happen and every button does the right thing in every state.
- API contracts, versioning, and errorsThe written agreement between the frontend and the API: a typed schema both sides are checked against, rules for changing it without breaking old clients, and one error shape the UI can always understand, including for partial failures and safe retries.
- Semantic HTML firstUsing the HTML element that already means what you need, like
button,a,nav, andh2, so keyboards, screen readers, and voice control understand the page with no extra code. - Focus managementDeciding where keyboard focus goes when the page changes under the user (route changes, modals, deletes, new content) so keyboard and screen reader users never lose their place.
- Debounce and throttleTwo small timing tools that turn a flood of events (keystrokes, scrolls, resizes) into a few calls you can afford: debounce waits for a pause, throttle runs at a steady rate.
- Syncing state across tabsEvery open tab of your app is a separate program with its own memory, so a change in one tab is invisible to the others until you send it across with the storage event, BroadcastChannel, a SharedWorker, or a leader tab chosen with Web Locks.