cf.completefrontend
My progress
FUNDAMENTALS · MODULE 10

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.

Everything a serious form tracks. Most bugs come from storing one of these in the wrong place or not at all.
Piece of statePer field or per formExampleWho reads it
valuefieldemail: "ana@ex"the input, the submit handler
dirtyfield and formemail differs from its starting valuethe Save button, the leave-page warning
touchedfieldthe user focused email and left itwhether to show its error yet
errorfield and form"Enter a full email address"the error text, aria-invalid
validatingfielda username check is in flighta small spinner next to the field
submittingformthe POST is in flightthe disabled Submit button
submitCountform2whether to show every error at once
serverErrorsform{ 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.

Two halves. Controlled: the input sends onChange to useState on every keystroke, useState sends the value back, and the component re-renders on each key; typing ana is 3 renders. Uncontrolled: the DOM input keeps the value and the form action reads it once with FormData on submit; typing ana is 0 renders.Controlledinputvalue={email}useStateholds the valueonChangevaluetyping ana: 3 rendersUncontrolledinputthe DOM keeps itform actionnew FormData(form)on submittyping ana: 0 renders
Controlled inputs make a round trip through React state on every keystroke. Uncontrolled inputs leave the value in the DOM and read it once, with 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.

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

QuestionControlledUncontrolled
Who holds the value?React stateThe DOM input
Renders per keystrokeAt least the owning componentNone, unless you watch the field
Read the valueAny time, from stateOn demand: ref, FormData, or a library getter
Format while typingEasyAwkward: you fight the DOM
Autofill and password managersWork, but you must handle the change eventWork with no extra code
Works before JavaScript loadsNoYes, 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.

A state machine for one field's error. Untouched goes to Typing quietly on the first keystroke. From Typing quietly, a blur with a valid value goes to Valid and a blur with an invalid value goes to Showing error. From Valid, a change to an invalid value goes to Showing error; from Showing error, a change to a valid value goes back to Valid. A note says a submit sends any invalid field straight to Showing error.Untouchedno errorTyping quietlyerror hiddenValidno errorShowing errorlive re-checktypeblur, validblur, invalidchange, invalidchange, validSubmit: every invalid field goes to Showing error
Touched is what separates Typing quietly from the other states. Once a field has been left, every change is checked live, so an error appears or clears on the keystroke that causes it.

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 useFieldArray and 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?

On submit only

Validate everything when the user presses Submit.

Show all errors at once and focus the first one.

Pros

  • Never interrupts typing.
  • Simplest to build: no per-field timing.

Cons

  • Late feedback on long forms.
  • If errors then update only on the next submit, fixing them feels blind.

On change (every keystroke)

Validate on every input event and show the result immediately.

Pros

  • Instant feedback, good for rules the user is aiming at, like a password strength meter.

Cons

  • Shows errors before the user has finished, on almost every field.

On blur

Validate a field when the user leaves it.

Keep checking on blur only.

Pros

  • Respects the user while they type.
  • Errors appear close to where the user is.

Cons

  • After an error shows, it stays until the next blur, even after the user fixed it.
  • The user may tab away just to see if it is fixed.

On blur first, then on change ("touched")

Stay quiet while a field is filled for the first time.

Validate on blur.

After that, re-check on every keystroke, so the error clears the moment it is fixed.

On submit, show every error.

Pros

  • No premature errors.
  • Instant relief once a mistake is being fixed.
  • Matches what most design systems recommend.

Cons

  • Slightly more state per field (touched, has shown 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.

A timeline of 11 moments: the user types a, n, a, @, e, x, presses Tab, then types dot, c, o, m. With errors on every change, the error shows from the first letter through the ninth moment, 6 of them before the user left the field. With blur first then change, nothing shows while typing, the error appears at Tab, stays while the value is still invalid, and clears at the letter o when ana@ex.co becomes valid.typedana@exTab.comon change!!!!!!!!!okokblur first!!!okok6 errors too earlyfair: after Tabquiet while typingclears at once
Both timings agree from the Tab onwards. The difference is the first six moments, where checking on every change shows an error the user has not earned yet.

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.

Show errors
Step 1 of 15: the user types a in Email.
Step 1 of 15 with "On every change". Email holds "a" and shows an error. Password holds "" and shows no error. Over the whole session: 7 errors shown too early, 0 moments where a finished field was wrong with no error, 0 errors still showing after the fix.Sign upEmailaEnter a full emailPasswordno erroremailEpasswordnowtoo early: 7too late: 0stale: 0
E: an error while the user is still typing the field for the first time. L: the user left a wrong field and saw nothing. S: the value is fixed but the error is still there. ! is a fair error.

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.

ts
// 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>;
Field rules, one cross-field rule placed on the field that shows it, and the type, from one source.

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.

A shared SignUp schema at the top feeds two zones. In the browser zone, the SignUpForm uses zodResolver(SignUp) and shows client issues as field errors. In the server zone, POST /sign-up runs SignUp.safeParse on the body and writes to the users database only when valid. The form posts to the server, and the server answers 422 with an errors map that lands in the same field errors.SignUprules + typeBrowserServerSignUpFormzodResolver(SignUp)field errorsnext to each fieldPOST /sign-upSignUp.safeParse(body)usersPOST422 errorsclient issuesvalid only
The schema is one file with two importers. Client issues and the server's 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.

ts
// 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 });
}
Same schema, same paths, same messages. Rules only the server can check (is the name taken?) come back in the same shape.

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

  1. 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.
  2. Wait for a pause. Debounce the check by about 300 to 500 ms after the last keystroke, so typing ana_lopez sends one request, not nine. See the lesson on debounce and throttle.
  3. 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.
  4. Show that you are checking. A small "Checking..." next to the field, then a result. Do not block typing.
  5. Remember answers. Cache results by value for a short time, so going back to a name you already checked is instant and free.
  6. 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.
A sequence diagram between the sign-up form and the API. After a 400 ms pause the form checks ana, then after the user types _l it checks ana_l and aborts the first check. The answer for ana_l arrives first: free, shown. The answer for ana arrives late: taken, ignored because the field says ana_l. Later the form posts the sign-up for ana_l, and the API answers 422 username taken, because someone took it in the meantime.Sign-up formAPIcheck anacheck ana_l (abort ana)ana_l: freeana: taken (late)POST /sign-up ana_l422 username takenignoredfinal word
Debounce cuts the number of checks, abort and value comparison stop a late answer from overwriting a fresh one, and the server's answer to the real submit is final.

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.

ts
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;
}
Called by a debounced handler. Cache, abort, then compare: three guards against a wrong answer on screen. The caller ignores both 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.

Requests
Answers

3 requests sent. The field holds ana_l and finally says "ana is taken": wrong, an answer for an old value.

A timeline from 0 to 1700 ms. The user types a, an, ana, pauses, then ana_ and ana_l. Check ana is sent at 200 ms and answers taken at 1200 ms. Check ana_ is sent at 800 ms and answers free at 1150 ms. Check ana_l is sent at 900 ms and answers free at 1100 ms. The last answer shown is for ana, which is wrong.aananaana_ana_ltypedanatakenana_freeana_lfreefield shows a stale answer
Each bar is one request, from when it is sent to when its answer arrives. The slow answer for ana lands last; without a guard it overwrites the right answer.

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.

A state machine for a checkout wizard. From Account, Next goes to Address for personal accounts, or to Company for business accounts, and Company goes on to Address. Address goes to Payment, Payment to Review, and Review goes to Submitted when the whole form is valid. A note says Back is the reverse of each Next and all values stay in one store.AccountAddressPaymentCompanyReviewSubmitted[personal][business]NextNextSubmit [valid]Back: the reverse of each Nextvalues stay in one store
The machine holds only where the user is and which step comes next; the guards read form values like the account type. The values themselves live in one form store under every step.

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 (and pagehide): on mobile it is often the last event before the tab is frozen or discarded. beforeunload and unload often 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.

A decision tree for where to keep a form draft. Must it follow the user to another device? Yes: a server draft. No: is it large or does it hold files? Yes: IndexedDB. No: must it survive closing the browser? Yes: localStorage. No: sessionStorage. A note says never store passwords or card numbers in any of them.Other devices?phone today, laptop laterFiles or large?blobs, long field arraysSurvive closing?the whole browserserver draftAPI + save statusIndexedDBasync, stores BlobslocalStoragesmall, sync, stringssessionStoragethis tab onlyyesyesyesnonononever passwordsor card numbers
Ask the questions top to bottom and stop at the first yes. Whatever the store, drafts are scoped to the user and never hold secrets.
StoreGood forWatch out for
sessionStorageSurviving a refresh in the same tabGone when the tab closes; not shared with other tabs
localStorageSmall drafts (a few KB) that survive a closed browserSynchronous, strings only, about 5 MB per origin, readable by any script on the page
IndexedDBLarge drafts, field arrays, attached files as BlobsAsync API; use a small wrapper like idb-keyval
ServerDrafts that must follow the user (email, documents, applications)Needs an API, a status indicator, and conflict handling

What a draft looks like

ts
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
}
Scope the key by user (shared computers), version the shape (forms change), and record what the draft was based on.
  • Never store secrets. Leave out passwords, card numbers, security codes, and one-time codes. Anything in localStorage can 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 label tied to it with for and id (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-describedby pointing at the error text's id (and at any hint text). The screen reader then reads the message after the label. aria-errormessage exists for the same job, but support has been uneven, so aria-describedby is 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".
html
<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>
Label, hint, input, and error wired together. A screen reader reads something like: Email, edit text, invalid entry, We send the receipt here, Error: enter a full email address.

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.

A wireframe of a sign-up form after a failed submit. At the top, a red error summary says There are 2 problems, with links: Enter a full email address, and Use at least 10 characters; focus has moved to it. Below, the Email field holds ana@ex with an error under it, and the Password field shows an error under it. A note says each input has aria-invalid true and aria-describedby pointing at its error.Sign upThere are 2 problemsfocus lands hereEnter a full email addressUse at least 10 charactersEmailana@exError: enter a full email addressPasswordError: use at least 10 charactersCreate accountaria-invalid=truearia-describedbypoints at the error
The summary takes focus (it has 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 autocomplete tokens like email, username, new-password, and postal-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.

A comparison matrix of React Hook Form, TanStack Form, Formik, and plain React 19 actions. few re-renders: RHF yes, TanStack yes, Formik no, Actions yes. schema hookup: RHF resolver, TanStack built in, Formik Yup, Actions manual. field arrays: RHF yes, TanStack yes, Formik yes, Actions manual. async debounce: RHF manual, TanStack yes, Formik manual, Actions manual. touched, dirty: RHF yes, TanStack yes, Formik yes, Actions no. active releases: RHF yes, TanStack yes, Formik rare, Actions yes.RHFTanStackFormikActionsfew re-rendersyesyesnoyesschema hookupresolverbuilt inYupmanualfield arraysyesyesyesmanualasync debouncemanualyesmanualmanualtouched, dirtyyesyesyesnoactive releasesyesyesrareyes
RHF is React Hook Form and Actions is plain React 19 form actions with no library. Green is built in, faded means you write it yourself. Library features change; check current docs before choosing.

Decision What should hold the form state in a React app in 2026?

React 19 actions only

Uncontrolled inputs, <form action>, useActionState for the server's result.

Validation on the server with the shared schema, plus HTML attributes in the browser.

Pros

  • No dependency.
  • Works with server functions and progressive enhancement.
  • Very little client code.

Cons

  • No built-in touched, dirty, or per-field timing.
  • Client-side inline validation is yours to build.

React Hook Form

useForm with register on uncontrolled inputs, zodResolver for the schema.

useFieldArray for lists, formState for flags.

Pros

  • Widely used, mature, few re-renders by design.
  • Large ecosystem and many examples.

Cons

  • Controlled third-party inputs need the Controller wrapper.
  • Async validation needs your own debounce.

TanStack Form

A form instance with typed fields; each field subscribes to its own slice.

Validators per field and per form, sync and async, with built-in async debounce.

Pros

  • Strong TypeScript inference for nested paths.
  • Accepts Standard Schema validators directly.
  • Not tied to React: adapters for other frameworks.

Cons

  • Younger, with a smaller set of examples.
  • More verbose JSX for each field.

Formik

Controlled inputs with values in Formik's state; Yup or another schema for rules.

Pros

  • Familiar API, found in many existing codebases.

Cons

  • Controlled by design, so large forms re-render more.
  • Releases have been rare for several years; few teams pick it for new work.

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

  1. Where state lives: one local store per form, uncontrolled inputs, the wizard step in the URL.
  2. Rules: one schema on both sides; the server is the authority.
  3. Timing: blur, then change; everything on submit, with an error summary.
  4. Server checks: debounced, cancelled, compared, cached, repeated on submit.
  5. Drafts: versioned, no secrets, cleared after success; a version or ETag for edits.
  6. Scale: a form library and shared field components.

How deep to go by level

LevelWhat a strong answer includes
JuniorControlled vs uncontrolled, client and server validation, sensible error timing, labels and error messages
MidShared schema, dirty and touched, async check with debounce and race handling, multi-step with one store, local drafts
SeniorServer 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 name on each; control a field only when its value must drive UI while typing. React 19 form actions and useActionState follow 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 ETag to 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?

Question 2

A 40-field form lags when typing on mid-range phones. What is likely wrong and how do you fix it?

Question 3

How do you make sure a user never loses a long application form?

Question 4

How would you build a checkout wizard whose steps change for business customers?

Question 5

Controlled or uncontrolled inputs: which would you choose for a new form, and why?

Practice it

These design problems lean on forms at scale.

References

Read next