Index

Forms: controlled, uncontrolled and validated

Every form question reduces to one thing: does React own the value, or does the DOM? Controlled means state is the source of truth and the input just displays it; uncontrolled means the DOM node holds the value and you go and read it when you need it. Pick a side per input and never straddle - the single most common React warning in existence is what straddling looks like. The second half of the topic is validation timing, and the answer nobody expects you to have is touched: errors that appear before the user has typed anything are the reason forms feel hostile.

Who owns the value

A controlled input has both value and onChange. React renders the value from state, the DOM tells you a keystroke happened, you set state, React re-renders with the new value. The input can only ever show what state says - which is why formatting, masking, disabling and cross-field validation all become trivial, and why every keystroke is a render.

An uncontrolled input has defaultValue (or defaultChecked) and a ref, or nothing at all. The DOM holds the value; React sets the initial one and then stops caring. You read ref.current.value at submit time, or read the whole form at once with FormData.

Default to controlled. Reach for uncontrolled when the DOM genuinely owns the value and cannot hand it over - a file input - when you are wrapping a non-React widget that mutates its own node, or when a large form is re-rendering on every keystroke and you have measured that it matters.

`value` + `onChange`
Controlled. Omit the onChange and React logs a warning and makes the field read-only, because you have told it the value can never change.
`defaultValue`
Uncontrolled. It seeds the DOM on the first render and is ignored on every render after - changing it later does nothing, which is the bug people hit when they try to 'reset' a form by changing defaultValue.
`value={undefined}`
Neither, and then both. React sees no value on the first render so it treats the input as uncontrolled; when the data arrives and the value becomes a string, it switches - and warns. Initialise every field to "", never undefined or null.

When to show the error

Validate on submit, always - that is the one that has to be right. Then improve it: once a field has been blurred, validate it on every change so the error clears as the user fixes it. That combination is what a good form feels like, and it is three lines of state.

The state is three objects with the same keys: values, errors, touched. errors is derived - a pure validate(values) function returning a map of field to message - and touched is what decides whether an error is allowed to be visible yet. Showing errors.email gated on touched.email is the difference between a form that helps and a form that shouts at an empty page.

On submit, mark everything touched at once, run the validator, and bail if it returned anything. That way the submit attempt reveals every problem in the form, which is what the user asked for by pressing the button.

Questions

Answer each one yourself before you open it.

  1. 01Controlled or uncontrolled - define both.

    Controlled: the input's value comes from React state and an onChange writes every keystroke back to state, so state is the single source of truth. Uncontrolled: the DOM node holds the value, you seed it with defaultValue, and you read it out of a ref when you need it.

    The word that matters is 'source of truth'. In a controlled input there is exactly one place the value can be, and the rendered field is a projection of it. In an uncontrolled one React has genuinely handed the value to the browser and has no idea what it is between renders.

  2. 02Which do you default to, and why?

    Controlled. Anything that reacts to the value as it is typed - a disabled submit, a character counter, live validation, one field depending on another, formatting as you type - is free when state already holds the value, and awkward otherwise.

    The cost is a render per keystroke, which for a normal form is nothing: React is re-rendering one small component with one changed string. Trading that for the ability to always know what the form contains is the right trade until a profiler says otherwise.

    Follow-up So when is controlled the wrong call?

    A very large form where every keystroke re-renders hundreds of nodes, and any case where the DOM owns the value by definition. That is when you go uncontrolled or reach for react-hook-form, which is uncontrolled underneath for exactly this reason.

  3. 03Name a case where uncontrolled is the only option.

    A file input. <input type="file"> is always uncontrolled - you cannot set its value from JavaScript, for security reasons, so React can only read it. The same is true when you integrate a non-React widget, like a date picker or a rich text editor, that owns and mutates its own DOM node.

    Being able to set a file input's value programmatically would let a page silently attach a file from the user's disk. The browser forbids it, so value is meaningless and the only path is a ref plus onChange reading e.target.files.

  4. 04How do you handle ten fields without ten useState calls?

    One state object and one generic handler keyed by the input's name: setValues(v => ({ ...v, [e.target.name]: e.target.value })). Give every input a name that matches a key, and one handleChange serves the whole form.

    The computed key [e.target.name] is what makes it generic - it evaluates the expression and uses the result as the property name. The spread copies the other fields, because useState replaces rather than merges. Miss the spread and typing in one field empties every other one.

    Follow-up And a checkbox in that same handler?

    Branch on the type: const value = e.target.type === "checkbox" ? e.target.checked : e.target.value;. A checkbox's value is the string "on" unless you set one, so reading value for a checkbox is always wrong.

  5. 05Why does the submit handler call preventDefault?

    Because a <form> submitting natively navigates the page - a full reload - and everything React was holding is gone. e.preventDefault() stops that so you can submit with fetch instead.

    It is worth still using a real <form> with an onSubmit rather than a bare button with an onClick: you get Enter-to-submit, the browser's own required-field semantics if you want them, and the correct role for assistive technology. You just intercept the navigation.

    Follow-up Does the submit button need type="submit"?

    It is the default inside a form, but any button without an explicit type is a submit button - which is why a stray <button>Cancel</button> inside a form reloads the page. Put type="button" on anything that is not the submit.

  6. 06When do you run validation?

    On submit for certain, and on change for any field that has already been blurred. So the user is never told they are wrong before they have finished, and the moment they fix it the message disappears.

    Validating on every change from the start means an empty required field is an error before the user has touched it. Validating only on submit means they fix one field, press submit, and find another. The blurred-then-live combination is what people mean by a form that feels good, and it is what touched exists to express.

  7. 07What shape is the errors object?

    A plain map of field name to message, with a key present only when that field is invalid: { email: 'Enter a valid email' }. Valid means the key is absent, so Object.keys(errors).length === 0 is the whole 'is the form valid' test.

    Keep it derived from a pure validate(values) function rather than assembled piecemeal in handlers. One function, one place to read what the rules are, testable without rendering anything - and it lets submit and blur share exactly the same logic.

    Follow-up Why not a boolean isValid in state?

    Because then there are two sources of truth that can disagree, and the one you rendered from is the one that will be stale.

  8. 08What is touched for?

    A map of which fields the user has blurred, so an error is only rendered when both errors.email and touched.email are set. Without it a freshly opened form shows every error immediately.

    On submit you mark all of them touched in one go - build the map from Object.keys(values) - so pressing submit reveals every outstanding problem at once. That is the behaviour people expect and it is two lines.

  9. 09Should the submit button be disabled while the form is invalid?

    Disable it while SUBMITTING, not while invalid. A permanently disabled button with no explanation is a dead end - the user cannot press it and is not told why. Let them press it and show them the errors.

    It is also an accessibility problem: a disabled button is skipped by keyboard navigation and announces nothing, so a screen reader user gets silence instead of a reason. Disabling during an in-flight submit is different - it prevents a double submit, and the reason is visible because the button says 'Saving…'.

  10. 10Walk me through an async submit.

    preventDefault, mark everything touched, validate and bail if there are errors, set submitting true, await the request in a try/catch/finally, put a server failure into a separate submitError, reset the form on success, and clear submitting in the finally.

    The finally matters: without it a thrown request leaves the button disabled forever and the user is stuck on a form that will not resubmit. And the server error goes in its own piece of state rather than into the field-level errors map, because it is not about a field - unless the server sends back per-field messages, in which case you merge them into errors.

    Follow-up How do you stop a double submit?

    The submitting flag both disables the button and guards the top of the handler. The guard is what actually stops it - a fast double press can beat the re-render.

  11. 11How do you reset the form after a successful submit?

    Set state back to the initial values object, and clear errors and touched. Keep the initial object as a constant outside the component so nothing can mutate it and the reset is one assignment.

    For an uncontrolled form the equivalent is formRef.current.reset(), which is the DOM's own method. What does not work in either case is changing defaultValue - that is only read on the first render.

  12. 12What is the gotcha with checkboxes?

    They use checked, not value, and the thing that changed is e.target.checked. A checkbox's value attribute defaults to the string "on" and has nothing to do with whether it is ticked.

    This is what breaks the generic handler: [e.target.name]: e.target.value puts the string "on" in state for every checkbox, forever, whether ticked or not - and "on" is truthy, so the bug survives every if you write against it.

  13. 13How do you handle a group of checkboxes producing an array?

    Keep an array in state and add or remove on change: setTags(t => e.target.checked ? [...t, e.target.value] : t.filter(x => x !== e.target.value)). Each box gets checked={tags.includes(tag)} and a real value.

    This is the one case where a checkbox's value matters - you are using it to identify which box it is. checked still answers 'is it on', value answers 'which one', and you need both.

  14. 14What is the gotcha with radio buttons?

    They share one name and one piece of state. Each input gets its own value, and checked={choice === thisValue} - the state holds the selected value, not a boolean per button.

    The shared name is what makes the browser treat them as one group and give you native arrow-key navigation. People often reach for a boolean per radio, which then lets two be on at once in React state while the DOM shows only one.

  15. 15How does a <select> differ from plain HTML in React?

    The value goes on the <select> itself, not selected on an <option>. React reads value and marks the matching option.

    It is one of the few places React deliberately diverges from HTML, and it exists so a select is controlled the same way every other input is - one value, one onChange. Putting selected on an option gets you a warning telling you to use value on the select.

    Follow-up And a multi-select?

    value becomes an array, and to read it you need Array.from(e.target.selectedOptions, o => o.value) - e.target.value on a multi-select gives you only the first selected option, which is a silent wrong answer rather than an error.

  16. 16How do you render a textarea in React?

    With a value prop, like every other input: <textarea value={bio} onChange={...} />. Not as children - HTML puts the text between the tags, React does not.

    Same motivation as the select divergence: one uniform controlled-input contract. Children on a textarea gets you a warning telling you to use defaultValue or value.

  17. 17"A component is changing an uncontrolled input to be controlled" - what causes it?

    The input's value was undefined or null on an early render and became a string later. React decided it was uncontrolled the first time it saw it, and the switch is what it is warning about.

    The two usual sources are a state object that does not have a key for that field yet, and data arriving asynchronously - value={user.name} while user is still null. Initialise every field to "" in the initial state object, or write value={user?.name ?? ""}. The reverse warning, controlled to uncontrolled, is the same bug in the other direction: someone set a value back to undefined.

    Follow-up Why does React care?

    Because the two modes have different behaviour and it cannot switch cleanly - an uncontrolled input has whatever the user typed, and the moment it becomes controlled that content is replaced by state, which the user experiences as their typing being deleted.

  18. 18defaultValue vs value?

    value is controlled and applies on every render. defaultValue seeds the DOM on the first render only and is ignored afterwards. Using both on one input is a bug - React ignores the defaultValue and warns.

    The consequence people trip on: you cannot clear an uncontrolled input by setting defaultValue to "", because nothing re-reads it. You call form.reset(), set ref.current.value = "" directly, or change the input's key to force a fresh node.

  19. 19How do you build an uncontrolled form?

    A ref per input plus defaultValue, then read nameRef.current.value in the submit handler. Or one ref on the <form> and Object.fromEntries(new FormData(formRef.current)), which needs a name on every input and no refs at all.

    The FormData version is the one worth knowing: it is shorter, it scales to any number of fields without touching the component, and it is what a plain HTML form would have sent. Its limitation is that unticked checkboxes are simply absent from the result rather than present and false.

  20. 20How do you read the file a user picked?

    e.target.files - a FileList, so e.target.files[0] for a single file and Array.from(e.target.files) for multiple. Keep the File object in state if you need a preview, and append it to a FormData to upload it.

    The input itself stays uncontrolled; what you can control is your own state holding the selected file. For a preview, URL.createObjectURL(file) gives you a src - and it needs a URL.revokeObjectURL in an effect cleanup or you leak the blob.

    Follow-up How do you clear it?

    ref.current.value = "". It is the one place assigning to a DOM node directly is the normal answer.

  21. 21A number input gives you what type?

    A string, always. e.target.value on <input type="number"> is "42", and it is "" when the field is empty - so Number(value) on an empty field gives you 0, not NaN.

    That 0 is the trap: a required-quantity validation written as if (!Number(qty)) accidentally accepts an empty field as a zero, and Number("") === 0 is why. Validate the raw string first, then convert. valueAsNumber is the other option and gives NaN for empty, which fails more usefully.

  22. 22Can you debounce a controlled input?

    Not the input itself - it must re-render on every keystroke or typing lags and the cursor jumps. Debounce the CONSUMER: keep value bound to state, and pass a debounced copy of it to the search or the validation.

    This is the question behind useDebounce. Two values, one fast and one slow: the fast one is what the user sees, the slow one is what does the expensive work.

  23. 23Is a controlled form slow?

    Only if a keystroke re-renders something big. One input re-rendering its own component is nothing; a hundred-field form where every field lives in one component's state re-renders all hundred on every character.

    The fixes in order of effort: move each field into its own component so state is local, memoise the expensive siblings, or go uncontrolled. Reaching for the third before measuring is how you end up with a form you cannot validate live.

  24. 24When would you reach for react-hook-form?

    When the form is big, the validation is non-trivial, or you want a schema. It keeps inputs uncontrolled via register, so typing does not re-render the form, and it gives you errors, touchedFields, isSubmitting and a resolver for Zod or Yup without you writing any of it.

    Everything on this page is what the library is doing for you - it is worth being able to say that. The judgement answer is that a login form with two fields does not need it, and a checkout with conditional sections, per-field async validation and a field array does.

    Follow-up Why is it faster?

    Because uncontrolled inputs do not re-render the form on each keystroke; it subscribes to what it needs and only re-renders the subtrees that display errors.

  25. 25How do you handle a dynamic list of fields - add and remove a row?

    Keep an array of objects in state, each with a stable id generated when the row is created. Render with key={row.id}, and add or remove by producing a new array.

    The key must be the row's own id, not the array index. With an index key, deleting the middle row makes React reuse the wrong DOM nodes and the values shift up a row - text that belonged to row three appears in row two, because the index says they are the same element.

  26. 26How do you validate a field that depends on another one?

    In the shared validate(values) function, which sees the whole object - password confirmation is if (values.confirm !== values.password). That is the payoff of validating from one pure function over all values rather than per input.

    It also means changing the password field re-evaluates the confirm error automatically, since the whole map is recomputed. Per-field validators cannot do that without wiring the fields to each other.

  27. 27How do you make form errors accessible?

    Every input gets a real <label htmlFor>, an invalid one gets aria-invalid="true" and aria-describedby pointing at its message element, and the message container is a live region so it is announced when it appears.

    A red border is invisible to a screen reader and to a colourblind user; the message has to be text, associated with the field, and announced. On submit, moving focus to the first invalid field is the other half - otherwise the user is left at the bottom of the form with an error they cannot find.

    Follow-up How do you focus the first invalid field?

    Keep a ref per field, or query the form for the first [aria-invalid="true"] after validation fails and call focus() on it.

  28. 28Where does server-side validation fit?

    Underneath all of it, always. Client validation is for the user's benefit - instant, specific feedback. The server validates because anything reaching it can be forged, and a curl request never ran your React.

    The practical consequence for the form: the submit handler has to be able to render errors that came back from the server, which means the errors map has to accept an update from a response as well as from validate().

  29. 29How do you keep two forms in sync - an edit form seeded from fetched data?

    Don't sync it with an effect. Give the form component a key of the record's id so a different record mounts a fresh form, and pass the fetched values as the initial state.

    The effect version - 'when user changes, set every field' - fights the user: a response arriving while they are typing overwrites what they typed. The key approach makes 'a different record' a remount and 'the same record' a no-op, which is the actual rule you wanted.

  30. 30What does onChange mean in React versus in HTML?

    React's onChange fires on every keystroke - it is the DOM's input event under the hood. HTML's native change fires only on blur or commit. So React's is the one you want and it is deliberately misnamed.

    It matters when you are integrating with a non-React widget or reading the DOM directly, and it is a good detail to have ready: React normalises the event across browsers into a synthetic event, and its onChange is input, not change.

  31. 31What is a synthetic event?

    React's cross-browser wrapper around the native event, with the same API. Since React 17 the listeners are attached at the root container of your app, not at the document, and since React 18 the objects are no longer pooled.

    The pooling detail is worth knowing because old code has e.persist() in it: React used to reuse the event object, so reading e.target.value inside an async callback after an await gave you null. That is gone in React 17+, but the safe habit - read the value into a local first - is still what stops the question.

  32. 32How do you build a masked or formatted input - a phone number?

    Controlled, and format inside the change handler: strip everything non-numeric, reformat, set the formatted string as state. Keep the raw digits separately if the server wants them unformatted.

    This is only possible because the value round-trips through state - the input can only display what you give back, so you can give back something different from what was typed. It is the clearest argument for controlled inputs, and the caret position is the thing that will bite you: naive reformatting sends the cursor to the end mid-edit.

Predict the output

The output is hidden. Predict it before you open it.

What type does a number input give you?

<input
  type="number"
  onChange={(e) =>
    console.log(typeof e.target.value, JSON.stringify(e.target.value), e.target.valueAsNumber)
  }
/>

// the user types 4, then clears the field
Show the output
string "4" 4
string "" NaN

e.target.value is always a string, including the empty string when the field is cleared. valueAsNumber is the numeric view and gives NaN for empty, which is the more useful failure - because Number("") is 0.

The empty-string-to-zero trap

console.log(Number(""), Number("   "), Number("4"));
console.log(parseInt("", 10));
console.log(Boolean("   "), "   ".trim().length);
Show the output
0 0 4
NaN
true 0

A required-quantity check written as if (!Number(qty)) treats an empty field as the number zero, and a required-name check written as if (!name) accepts three spaces. Validate the raw string - value.trim() === "" - before you convert anything.

The computed key, with and without the brackets

const values = { name: "sample", email: "" };
const name = "email";

console.log({ ...values, [name]: "a@b.c" });
console.log({ ...values, name: "a@b.c" });
Show the output
{ name: 'sample', email: 'a@b.c' }
{ name: 'a@b.c', email: '' }

Brackets evaluate the expression and use the result as the key. Without them the literal string name is the key - so a generic handler missing its brackets writes every field into a property called name and nothing else ever updates.

What a checkbox reports

<input
  type="checkbox"
  name="terms"
  onChange={(e) => console.log(e.target.name, e.target.value, e.target.checked)}
/>

// ticked, then unticked
Show the output
terms on true
terms on false

A checkbox's value defaults to the string "on" and never changes. Only checked carries the information. Set a real value when the box is one of a group and you need to know which box it was.

The generic handler meeting a checkbox

function reduce(values, e) {
  return { ...values, [e.target.name]: e.target.value };
}

// checkbox name="terms": ticked, then unticked
let state = {};
state = reduce(state, tickEvent);
console.log(state);
state = reduce(state, untickEvent);
console.log(state);
Show the output
{ terms: 'on' }
{ terms: 'on' }

Unticking changes nothing, and "on" is truthy, so a terms-accepted check passes forever. The fix is one line in the handler: const value = e.target.type === "checkbox" ? e.target.checked : e.target.value.

Reading a multi-select

<select
  multiple
  onChange={(e) => {
    console.log(e.target.value);
    console.log(Array.from(e.target.selectedOptions, (o) => o.value));
  }}
>
  <option value="a">A</option>
  <option value="b">B</option>
  <option value="c">C</option>
</select>

// A and C are selected
Show the output
a
['a', 'c']

e.target.value on a multi-select returns only the first selected option - no error, just a quietly wrong answer. selectedOptions is the collection you actually want, and the controlled value prop for a multi-select is an array.

Is the form valid?

const errors = {};

console.log(Object.keys(errors).length === 0);
if (errors) console.log("this branch always runs");
console.log(Object.keys({ email: "Required" }).length === 0);
Show the output
true
this branch always runs
false

An empty object is truthy, so if (errors) is not a validity check - it is always true. Count the keys. This is the single most common way a form submits while invalid.

The warning everyone has seen

function NameField({ user }) {
  return <input value={user.name} onChange={() => {}} />;
}

// first render: user = {}
// after the fetch resolves: user = { name: "sample" }
Show the output
Warning: A component is changing an uncontrolled input to be controlled. This is likely caused by the value changing from undefined to a defined value, which should not happen.

On the first render value is undefined, so React treats the input as uncontrolled. When the data lands it becomes a string and React has to switch modes mid-life. Fix it at the source: value={user.name ?? ""}, or initialise every field in the state object to an empty string.

A value with no way to change it

<input value="hello" />
Show the output
Warning: You provided a `value` prop to a form field without an `onChange` handler. This will render a read-only field. If the field should be mutable use `defaultValue`. Otherwise, set either `onChange` or `readOnly`.

React renders exactly what state says, and you gave it a constant - so typing does nothing. The warning names all three legitimate intentions: controlled (add onChange), uncontrolled (use defaultValue), or genuinely read-only (say readOnly).

defaultValue after the first render

function Draft() {
  const [n, setN] = useState(0);
  const ref = useRef(null);

  return (
    <div>
      <input ref={ref} defaultValue={"draft " + n} />
      <button
        onClick={() => {
          setN(n + 1);
          console.log(ref.current.value);
        }}
      >
        bump
      </button>
    </div>
  );
}

// the user types "hello" over the default, then clicks bump twice
Show the output
hello
hello

defaultValue seeds the DOM node once. Re-rendering with a different one changes nothing, because the node already exists and owns its value. To reset an uncontrolled input you call form.reset(), assign ref.current.value directly, or change the input's key to force a new node.

FormData and the missing checkbox

// <form>
//   <input name="email" defaultValue="a@b.c" />
//   <input type="checkbox" name="terms" />   <-- left unticked
// </form>

const data = Object.fromEntries(new FormData(formRef.current));
console.log(data);
console.log(data.terms === false);
Show the output
{ email: 'a@b.c' }
false

An unticked checkbox is not submitted at all, so the key is absent rather than false - and undefined === false is false, so a naive check passes. Read booleans off the element (form.elements.terms.checked) or merge the FormData over a defaults object.

Forgetting preventDefault

function handleSubmit(e) {
  console.log("submitting", { email: "a@b.c" });
}

// <form onSubmit={handleSubmit}>
//   <input name="email" defaultValue="a@b.c" />
//   <button>Send</button>
// </form>
Show the output
submitting { email: 'a@b.c' }
(then the browser navigates to ?email=a%40b.c and the console clears)

The handler runs, and then the default action does too: a native form submit is a full page navigation. Everything React was holding is gone, which is why the symptom is usually reported as "my state resets when I submit".

What a radio's onChange reports

<input
  type="radio"
  name="pay"
  value="cash"
  checked={choice === "cash"}
  onChange={(e) => console.log(e.target.value, e.target.checked)}
/>

// the user selects it
Show the output
cash true

A radio's change event only fires when it becomes selected, so checked is always true and carries nothing. The information is value, which is why radio state is the selected value rather than a boolean per button.

Write it from memory

Complete, runnable code. Practice typing it from memory before your interview.

A complete controlled form - validation, touched, async submit

The one to type from memory. Every piece an interviewer looks for is in it: one state object with a generic handler, a pure validator, a touched map gating the messages, all fields marked touched on submit, an in-flight flag with a finally, a server error kept separate from the field errors, and labels wired for accessibility.

import { useState } from "react";

const INITIAL = {
  name: "",
  email: "",
  password: "",
  plan: "free",
  terms: false,
};

const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

function validate(values) {
  const errors = {};
  if (!values.name.trim()) errors.name = "Name is required";
  if (!values.email.trim()) errors.email = "Email is required";
  else if (!EMAIL.test(values.email)) errors.email = "Enter a valid email";
  if (values.password.length < 8) errors.password = "Use at least 8 characters";
  if (!values.terms) errors.terms = "You have to accept the terms";
  return errors;
}

export default function SignUpForm() {
  const [values, setValues] = useState(INITIAL);
  const [touched, setTouched] = useState({});
  const [submitting, setSubmitting] = useState(false);
  const [submitError, setSubmitError] = useState(null);
  const [done, setDone] = useState(false);

  const errors = validate(values);
  const isValid = Object.keys(errors).length === 0;
  const showError = (field) => (touched[field] ? errors[field] : undefined);

  function handleChange(e) {
    const { name, type } = e.target;
    const value = type === "checkbox" ? e.target.checked : e.target.value;
    setValues((v) => ({ ...v, [name]: value }));
  }

  function handleBlur(e) {
    const { name } = e.target;
    setTouched((t) => ({ ...t, [name]: true }));
  }

  async function handleSubmit(e) {
    e.preventDefault();
    if (submitting) return;

    const all = {};
    for (const key of Object.keys(INITIAL)) all[key] = true;
    setTouched(all);

    if (!isValid) return;

    setSubmitting(true);
    setSubmitError(null);
    try {
      const res = await fetch("/api/signup", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(values),
      });
      if (!res.ok) throw new Error("Could not sign up (" + res.status + ")");
      setValues(INITIAL);
      setTouched({});
      setDone(true);
    } catch (err) {
      setSubmitError(err.message);
    } finally {
      setSubmitting(false);
    }
  }

  if (done) return <p role="status">Account created.</p>;

  return (
    <form onSubmit={handleSubmit} noValidate>
      <div>
        <label htmlFor="name">Name</label>
        <input
          id="name"
          name="name"
          value={values.name}
          onChange={handleChange}
          onBlur={handleBlur}
          aria-invalid={Boolean(showError("name"))}
          aria-describedby={showError("name") ? "name-error" : undefined}
        />
        {showError("name") && (
          <p id="name-error" role="alert">
            {errors.name}
          </p>
        )}
      </div>

      <div>
        <label htmlFor="email">Email</label>
        <input
          id="email"
          name="email"
          type="email"
          value={values.email}
          onChange={handleChange}
          onBlur={handleBlur}
          aria-invalid={Boolean(showError("email"))}
          aria-describedby={showError("email") ? "email-error" : undefined}
        />
        {showError("email") && (
          <p id="email-error" role="alert">
            {errors.email}
          </p>
        )}
      </div>

      <div>
        <label htmlFor="password">Password</label>
        <input
          id="password"
          name="password"
          type="password"
          value={values.password}
          onChange={handleChange}
          onBlur={handleBlur}
          aria-invalid={Boolean(showError("password"))}
          aria-describedby={showError("password") ? "password-error" : undefined}
        />
        {showError("password") && (
          <p id="password-error" role="alert">
            {errors.password}
          </p>
        )}
      </div>

      <div>
        <label htmlFor="plan">Plan</label>
        <select
          id="plan"
          name="plan"
          value={values.plan}
          onChange={handleChange}
          onBlur={handleBlur}
        >
          <option value="free">Free</option>
          <option value="pro">Pro</option>
          <option value="team">Team</option>
        </select>
      </div>

      <div>
        <label htmlFor="terms">
          <input
            id="terms"
            name="terms"
            type="checkbox"
            checked={values.terms}
            onChange={handleChange}
            onBlur={handleBlur}
          />
          I accept the terms
        </label>
        {showError("terms") && <p role="alert">{errors.terms}</p>}
      </div>

      {submitError && <p role="alert">{submitError}</p>}

      <button type="submit" disabled={submitting}>
        {submitting ? "Creating…" : "Create account"}
      </button>
    </form>
  );
}
  • errors is computed during render from a pure function - not stored in state. One source of truth, and cross-field rules like password confirmation come for free because the validator sees every value.
  • The submit marks every field touched BEFORE bailing, so one press reveals every outstanding problem rather than one at a time.
  • The submit button is disabled only while submitting, never while invalid: a permanently dead button with no explanation is worse than letting the user press it and showing them why.
  • if (submitting) return at the top of the handler is the real double-submit guard - a fast second press can beat the re-render that disables the button.
  • noValidate turns off the browser's own bubbles so yours are the only ones, and the type attributes still give mobile the right keyboard.
  • The checkbox reads e.target.checked, and its state is a real boolean. Reading value there would store the string "on" forever.

The same form uncontrolled - refs and FormData

The comparison an interviewer asks for right after the controlled one. No state per keystroke, no re-renders while typing, and the values are read once at submit. Note what you lose: nothing can validate live, and nothing can react to a value until submit.

import { useRef, useState } from "react";

const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

function validate(values) {
  const errors = {};
  if (!values.name.trim()) errors.name = "Name is required";
  if (!values.email.trim()) errors.email = "Email is required";
  else if (!EMAIL.test(values.email)) errors.email = "Enter a valid email";
  if (values.password.length < 8) errors.password = "Use at least 8 characters";
  if (!values.terms) errors.terms = "You have to accept the terms";
  return errors;
}

export default function SignUpFormUncontrolled() {
  const formRef = useRef(null);
  const nameRef = useRef(null);
  const [errors, setErrors] = useState({});
  const [submitting, setSubmitting] = useState(false);
  const [submitError, setSubmitError] = useState(null);

  async function handleSubmit(e) {
    e.preventDefault();
    if (submitting) return;

    const form = e.currentTarget;
    const raw = Object.fromEntries(new FormData(form));
    const values = {
      name: raw.name ?? "",
      email: raw.email ?? "",
      password: raw.password ?? "",
      plan: raw.plan ?? "free",
      terms: form.elements.terms.checked,
    };

    const found = validate(values);
    setErrors(found);
    if (Object.keys(found).length > 0) {
      if (found.name) nameRef.current.focus();
      return;
    }

    setSubmitting(true);
    setSubmitError(null);
    try {
      const res = await fetch("/api/signup", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(values),
      });
      if (!res.ok) throw new Error("Could not sign up (" + res.status + ")");
      form.reset();
      setErrors({});
    } catch (err) {
      setSubmitError(err.message);
    } finally {
      setSubmitting(false);
    }
  }

  return (
    <form ref={formRef} onSubmit={handleSubmit} noValidate>
      <div>
        <label htmlFor="name">Name</label>
        <input id="name" name="name" ref={nameRef} defaultValue="" />
        {errors.name && <p role="alert">{errors.name}</p>}
      </div>

      <div>
        <label htmlFor="email">Email</label>
        <input id="email" name="email" type="email" defaultValue="" />
        {errors.email && <p role="alert">{errors.email}</p>}
      </div>

      <div>
        <label htmlFor="password">Password</label>
        <input id="password" name="password" type="password" defaultValue="" />
        {errors.password && <p role="alert">{errors.password}</p>}
      </div>

      <div>
        <label htmlFor="plan">Plan</label>
        <select id="plan" name="plan" defaultValue="free">
          <option value="free">Free</option>
          <option value="pro">Pro</option>
          <option value="team">Team</option>
        </select>
      </div>

      <div>
        <label htmlFor="terms">
          <input id="terms" name="terms" type="checkbox" defaultChecked={false} />
          I accept the terms
        </label>
        {errors.terms && <p role="alert">{errors.terms}</p>}
      </div>

      {submitError && <p role="alert">{submitError}</p>}

      <button type="submit" disabled={submitting}>
        {submitting ? "Creating…" : "Create account"}
      </button>
      <button type="button" onClick={() => formRef.current.reset()}>
        Reset
      </button>
    </form>
  );
}
  • FormData needs a name on every input, and gives you strings. An unticked checkbox is absent from it entirely, which is why terms is read off form.elements instead.
  • form.reset() is the DOM's own reset and is the only thing that clears uncontrolled inputs - re-rendering with a different defaultValue does nothing.
  • The named ref exists for one job: moving focus to the first invalid field. Without it the user is left staring at a message they may not have scrolled to.
  • type="button" on the reset button is essential. A button in a form with no type is a submit button.
  • What you gave up: no character counter, no live password strength, no disabling anything based on what is typed, and no validation until submit. That is the trade, and saying it out loud is the answer to "which would you use here?".

useForm - the reusable hook

Everything from the controlled build, extracted. This is roughly what react-hook-form gives you, minus the uncontrolled performance trick - worth building once so you can say exactly what the library is doing.

import { useState, useCallback } from "react";

export function useForm({ initialValues, validate, onSubmit }) {
  const [values, setValues] = useState(initialValues);
  const [touched, setTouched] = useState({});
  const [submitting, setSubmitting] = useState(false);
  const [submitError, setSubmitError] = useState(null);

  const errors = validate ? validate(values) : {};
  const isValid = Object.keys(errors).length === 0;

  const handleChange = useCallback((e) => {
    const { name, type } = e.target;
    const value = type === "checkbox" ? e.target.checked : e.target.value;
    setValues((v) => ({ ...v, [name]: value }));
  }, []);

  const handleBlur = useCallback((e) => {
    const { name } = e.target;
    setTouched((t) => ({ ...t, [name]: true }));
  }, []);

  const reset = useCallback(() => {
    setValues(initialValues);
    setTouched({});
    setSubmitError(null);
  }, [initialValues]);

  async function handleSubmit(e) {
    e.preventDefault();
    if (submitting) return;

    const all = {};
    for (const key of Object.keys(initialValues)) all[key] = true;
    setTouched(all);

    if (!isValid) return;

    setSubmitting(true);
    setSubmitError(null);
    try {
      await onSubmit(values);
      reset();
    } catch (err) {
      setSubmitError(err.message);
    } finally {
      setSubmitting(false);
    }
  }

  function fieldProps(name) {
    const invalid = Boolean(touched[name] && errors[name]);
    return {
      name,
      id: name,
      value: values[name] ?? "",
      onChange: handleChange,
      onBlur: handleBlur,
      "aria-invalid": invalid,
    };
  }

  function checkboxProps(name) {
    return {
      name,
      id: name,
      type: "checkbox",
      checked: Boolean(values[name]),
      onChange: handleChange,
      onBlur: handleBlur,
    };
  }

  const errorFor = (name) => (touched[name] ? errors[name] : undefined);

  return {
    values,
    errors,
    touched,
    submitting,
    submitError,
    isValid,
    handleSubmit,
    fieldProps,
    checkboxProps,
    errorFor,
    reset,
    setValues,
  };
}

// Using it - the whole login form is now nine lines of JSX
const INITIAL = { email: "", password: "" };

function validateLogin(values) {
  const errors = {};
  if (!values.email.trim()) errors.email = "Email is required";
  if (!values.password) errors.password = "Password is required";
  return errors;
}

export function LoginForm() {
  const form = useForm({
    initialValues: INITIAL,
    validate: validateLogin,
    onSubmit: async (values) => {
      const res = await fetch("/api/login", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(values),
      });
      if (!res.ok) throw new Error("Wrong email or password");
    },
  });

  return (
    <form onSubmit={form.handleSubmit} noValidate>
      <label htmlFor="email">Email</label>
      <input {...form.fieldProps("email")} type="email" />
      {form.errorFor("email") && <p role="alert">{form.errorFor("email")}</p>}

      <label htmlFor="password">Password</label>
      <input {...form.fieldProps("password")} type="password" />
      {form.errorFor("password") && (
        <p role="alert">{form.errorFor("password")}</p>
      )}

      {form.submitError && <p role="alert">{form.submitError}</p>}

      <button type="submit" disabled={form.submitting}>
        {form.submitting ? "Signing in…" : "Sign in"}
      </button>
    </form>
  );
}
  • INITIAL lives outside the component on purpose. Passing an object literal would be a new identity every render, which invalidates reset's useCallback and makes the hook's dependencies churn - the same object-identity problem as an effect that loops.
  • fieldProps(name) spread onto an input is the ergonomic win: it is name, id, value, onChange, onBlur and aria-invalid in one, and it is why react-hook-form's API is called register.
  • The hook throws nothing on a failed submit - it catches, stores the message in submitError, and always clears submitting in the finally.
  • errorFor is where touched is applied, so no component has to remember the gating rule.
  • setValues is returned as an escape hatch, for seeding the form from a fetched record or clearing one field from outside.
← Hooks: useState, useEffect and the restFetching data, and the race condition →