skip to content

In a React form built from uncontrolled inputs, how do you read the field values when the user submits — with refs or with FormData?

level: middleimportance: should knowfreq 55%

answer

  1. ask the DOM, not React state
  2. one field versus the whole form
  3. name attribute is what counts
  4. refs are only safe after commit
  5. unchecked boxes are missing, not false

basics

~20 s

Read one or two fields through a ref, as inputRef.current.value. For a whole form, construct FormData from the form element in the submit handler and pull fields out by name, which needs no per-field wiring at all.

solid answer

~50 s

Both work, and the choice is about how many fields you need. A ref hands you the DOM node, so `inputRef.current.value` (or `.checked`, or `.files`) gives you that one field — good for one or two inputs, and the same ref usually earns its keep for focusing the field. For a whole form, `new FormData(event.currentTarget)` in the submit handler snapshots every named control at once: `data.get('email')`, `data.getAll('tag')` for repeated names, `Object.fromEntries(data)` for a plain object. The catch is that `FormData` keys off the `name` attribute, not `id` or ref, so every field you want must have one. Know what it omits: disabled controls, and unchecked checkboxes, which are simply absent rather than false. Refs are only readable after mount — in handlers and effects, never during render, where `ref.current` may still be null. React 19's `<form action={fn}>` hands your function the same `FormData` object.

code

jsx · 27 lines
jsx
export function SignupForm({ onSubmit }) {
  function handleSubmit(e) {
    e.preventDefault();
    const data = new FormData(e.currentTarget);

    onSubmit({
      email: data.get('email'),
      plan: data.get('plan'),
      newsletter: data.has('newsletter'),
      interests: data.getAll('interest'),
    });
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="email" type="email" defaultValue="" />
      <select name="plan" defaultValue="pro">
        <option value="free">Free</option>
        <option value="pro">Pro</option>
      </select>
      <input name="newsletter" type="checkbox" />
      <input name="interest" type="checkbox" value="react" />
      <input name="interest" type="checkbox" value="css" />
      <button type="submit">Create account</button>
    </form>
  );
}

go deeper

for a junior

Know that an uncontrolled field is read from the DOM node — inputRef.current.value in the submit handler — and that FormData needs each input to carry a name attribute.

for a middle

Explain when each approach fits, and name FormData's omissions: disabled controls, absent unchecked checkboxes, getAll for repeated names, File objects from file inputs.

for a senior

Demonstrate the boundary discipline — strings converted and validated once as they leave FormData — and pick refs only where imperative DOM access is genuinely needed.

for a principal

Set the house pattern for reading and validating submissions so every form parses input the same way, and keep the field contract in the markup rather than duplicated in hand-maintained ref lists.

## The premise: the DOM already knows An uncontrolled input holds its own value. Nothing in React state mirrors it, so "reading the form" means asking the DOM, at the moment you actually need the answer — normally when the user submits. ## Reading one field with a ref ```jsx function Search() { const queryRef = useRef(null); function handleSubmit(e) { e.preventDefault(); console.log(queryRef.current.value); } return ( <form onSubmit={handleSubmit}> <input ref={queryRef} defaultValue="" /> <button type="submit">Go</button> </form> ); } ``` `queryRef.current` is the real `HTMLInputElement`, so everything the DOM offers is available: `.value` for text, `.checked` for a checkbox or radio, `.files` for a file input, `.select()` and `.focus()` for interaction. The timing rule matters. React attaches the node during commit, so `ref.current` is `null` on the first render and becomes the element afterwards. Read it from event handlers or effects; reading it during render is both null-prone and a render-purity violation. Detaching cleans up too: when the element unmounts, React sets `current` back to `null`. Refs scale badly. Ten fields means ten refs to declare, thread and keep in sync with the markup — at which point you are hand-rolling what the platform already does. ## Reading the whole form with FormData ```jsx function handleSubmit(e) { e.preventDefault(); const data = new FormData(e.currentTarget); const email = data.get('email'); // string | File | null const tags = data.getAll('tag'); // every control named "tag" const all = Object.fromEntries(data); // plain object, last value wins } ``` `FormData` walks the form's controls exactly as a native submission would. Note `e.currentTarget`, not `e.target`: `currentTarget` is the `<form>` the handler is attached to, while `target` is whatever element originated the event — usually the same here, but not something to rely on. What goes in is decided by the `name` attribute. An input with only an `id`, a `ref`, or a React `key` contributes nothing. That single rule accounts for most "my FormData is empty" confusion. ## What FormData includes, and what it quietly leaves out - **Disabled controls are omitted entirely.** A field disabled while a request is in flight is missing from the payload, not blank. - **Unchecked checkboxes are absent**, not `false`. Test with `data.has('optIn')`, and remember that a ticked checkbox with no explicit `value` attribute submits the string `"on"`. - **Radio groups contribute one entry** — the selected member of the group, under the shared name. - **Repeated names** all appear. `data.get()` returns the first; `getAll()` returns every one; `Object.fromEntries` keeps only the last, which silently loses data on multi-value fields. - **File inputs yield `File` objects**, not strings, so the same `get()` call returns a different type. - **Submit buttons are not included** by plain `new FormData(form)`; you pass the button as the constructor's second argument if you need to know which one submitted. Because values arrive as strings, numbers and booleans need converting at the boundary — `Number(data.get('quantity'))` — which is exactly the seam where a schema validator earns its place. ## Choosing between them Use a **ref** for one or two fields, or when you also need imperative access (focus the first invalid field, select the text, read `.files`). Use **FormData** for a form of any real size: no per-field wiring, the field list is the markup, and adding an input costs one `name` attribute. Mixed forms are normal — a controlled field or two for live feedback, the rest read from `FormData` at submit. ## A React 19 note In React 19 you can pass a function to a form's `action` prop, and React calls it with the same `FormData` object, no submit handler and no `preventDefault` needed. The mechanics of reading fields are identical; only the plumbing that delivers the object differs.

  • A field is in the markup but missing from the FormData object. What do you check first?
    Whether it has a `name` attribute — `FormData` keys off `name`, and `id`, `ref` or React `key` contribute nothing. After that, check whether the control is disabled, since disabled controls are omitted entirely, and whether it is an unchecked checkbox, which is absent rather than false. Finally, confirm the control is actually inside the `<form>` element you passed.
  • Why is reading inputRef.current.value during render a mistake?
    Because the ref is only populated after React commits the element to the DOM — on the first render `current` is still null. Beyond the crash, reading mutable DOM state during render makes the render impure: the same props could produce different output, which breaks the assumptions React makes when it re-renders or discards work. Read refs in handlers and effects.
  • How do you get typed values out of FormData, given everything arrives as a string?
    Convert at the boundary and validate there too: `Number(data.get('quantity'))`, `data.has('optIn')` for checkboxes, `data.getAll('tag')` for repeated names. Feeding `Object.fromEntries(data)` into a schema validator gives you parsing and validation in one step — and note that `fromEntries` keeps only the last value for a repeated name, so multi-value fields need `getAll`.

saying these in an interview costs you the question

  • Expects FormData to pick up fields that have no name attribute
  • Reads ref.current.value during render instead of in a handler
  • Thinks an unchecked checkbox appears in FormData as false
  • Uses Object.fromEntries on repeated names and loses values
  • Adds a ref per field rather than using the form element

context