Skip to main content

Controlled Inputs: The Input That Argues Back

What you'll learn

  • What "controlled" actually means: the DOM value is a projection of state
  • The enforcement loop: type → event → setState → re-render → React re-asserts the DOM value
  • Why a keystroke you reject simply vanishes, even when state doesn't change
  • The read-only warning, the mode-flip warning, and uncontrolled defaultValue
  • When uncontrolled is genuinely right: file inputs, one-off reads, third-party libraries
  • Checkboxes, radios, and selects without fear

Everyone's first controlled input is written by imitation: value={text}, onChange={e => setText(e.target.value)}. It works, so nobody asks how. Then one day you try to build an input that rejects certain characters, and you discover the input "fights back": characters you don't allow simply never appear. Most developers learn that pattern by heart before they ever learn the mechanism. The mechanism is better, and it's the last chapter's dispatch system plus one sneaky safety net.

The pattern, then the mechanism

import { useState } from 'react';

export default function App() {
  const [text, setText] = useState('');
  return (
    <input
      value={text}
      onChange={e => setText(e.target.value)}
      placeholder="controlled"
    />
  );
}

Jargon: "controlled input". A form field whose DOM value is driven by React state: you pass value={state} and update that state in onChange. State is the source of truth; the DOM is its projection.

One naming trap first: React's onChange is not the browser's change event. The native change event on a text input fires at blur. React's onChange maps to the native input event, it fires on every keystroke, which is what you actually want.

The enforcement loop

Here is the part most people never learn. Type one character, say a, into that input. The full sequence:

Step 1: the browser changes the DOM immediately. The keystroke lands in the real <input> at once. React doesn't (and can't) stop the browser from showing the character. For a moment, the DOM shows a while state still says ''.

Step 2: the event bubbles to the root. React's delegated listener (last chapter) catches it and dispatches your onChange, with e.target.value reading what the browser currently holds: 'a'.

Step 3: your handler decides. setText('a'), or anything else you choose. This is your one chance to accept, transform, or reject what was typed.

Step 4: React re-renders and re-asserts the DOM value. At commit, React writes the input's value from your state. If the DOM already agrees, nothing to do. If it disagrees, React resets the DOM to match state, and the disagreement simply disappears from the screen.

Step 5: the safety net. Even if your handler ignored the input and state never changed, so React bails out of re-rendering entirely (the Object.is bailout from Part 3), React still checks the field after the event and restores it to the last committed value.

Pseudocode model, not real source:

// At commit, for a field with a value prop:
if (domInput.value !== props.value) {
domInput.value = props.value; // re-assert state over reality
}

// And after change events, even if nothing re-rendered:
if (domInput.value !== lastCommittedValue) {
domInput.value = lastCommittedValue; // erase the stray keystroke
}

So a controlled input is not a field React watches. It's a field React overrules.

Intercepting keystrokes: uppercase only

import { useState } from 'react';

export default function App() {
  const [text, setText] = useState('');

  function handleChange(e) {
    setText(e.target.value.toUpperCase());
  }

  return (
    <label>
      Shout at me:
      <input value={text} onChange={handleChange} />
    </label>
  );
}

What happens when you type a:

  1. The browser puts a in the DOM. State is still ''.
  2. onChange fires with e.target.value === 'a'.
  3. Your handler stores 'A'.
  4. Commit compares DOM 'a' with state 'A', mismatch → the DOM is reset to 'A'.
  5. You never see the lowercase letter. It existed for a fraction of a frame.

Rejecting keystrokes: digits only

import { useState } from 'react';

export default function App() {
  const [digits, setDigits] = useState('');

  function handleChange(e) {
    setDigits(e.target.value.replace(/[^0-9]/g, ''));
  }

  return (
    <input value={digits} onChange={handleChange} placeholder="digits only" />
  );
}

What happens when you type x:

  1. The browser shows x; e.target.value is 'x'.
  2. Your handler strips non-digits and calls setDigits(''), the same value state already has.
  3. State unchanged → React bails out; there is no re-render at all.
  4. The safety net fires: DOM 'x' ≠ committed '' → the DOM is restored to ''.
  5. The x vanishes. Try as you might, you cannot make it stay.

Step 4 surprises even experienced developers: rejection works even when your handler produces no state change, because enforcement doesn't depend on re-rendering. This is also the tie-in promised last chapter, after dispatching an input event, React may restore the field's value. Now you've seen why.

Why this makes inputs resilient

The same loop quietly protects you from changes you didn't orchestrate:

  • Browser autofill dumps a value in? The next commit re-asserts from state, and React also listens for autofill-style changes to keep state in sync.
  • A browser extension rewrites the field? Reconciled back at the next checkpoint.
  • IME composition, typing Chinese, Japanese, Korean, or accented characters, temporarily puts the DOM in a half-edited state. React's enforcement is careful to re-assert only at safe moments, which is why controlled inputs survive IMEs while hand-rolled "reset the value" hacks break composition mid-character.

The principle: anything can touch the DOM, but state wins at the next checkpoint.

The read-only warning

<input value={text} />

In development, React warns: "You provided a value prop to a form field without an onChange handler. This will render a read-only field…" And it means it: every keystroke is rejected by the safety net, so the field is effectively frozen. Three honest fixes:

  • You meant it to be editable → add onChange.
  • You meant it to be frozen → say so explicitly: <input value={text} readOnly />.
  • You only wanted a starting value → that's defaultValue, i.e. uncontrolled, next.

Uncontrolled: let the DOM own it

import { useRef } from 'react';

export default function App() {
  const inputRef = useRef(null);

  function handleSubmit(e) {
    e.preventDefault();
    alert('DOM says: ' + inputRef.current.value);
  }

  return (
    <form onSubmit={handleSubmit}>
      <input ref={inputRef} defaultValue="Ada" />
      <button>Read it once</button>
    </form>
  );
}

Jargon: "uncontrolled input". A field the DOM owns. React sets the initial value via defaultValue and then steps back; you read the current value from the DOM (usually through a ref) when you need it.

What happens: typing updates only the DOM, no handlers, no state, no re-renders, no enforcement. And defaultValue is a one-time gift: changing it later does nothing, because the DOM has taken over.

Never flip modes. If a field renders with value={undefined} (uncontrolled) and later value="Ada" (controlled), typically because state started as undefined, React warns: "A component is changing an uncontrolled input to be controlled…" The reverse flip warns too. Why so loud? Because ownership of the field changed hands mid-flight: pending typed text, cursor position, and IME state can be lost in the handover. Initialize text state to '', not undefined, and the warning never appears.

When uncontrolled is genuinely better

  • File inputs, always uncontrolled. Browsers forbid setting a file input's value from JavaScript (a security rule), so value can't work. Read input.files via a ref.
  • One-off reads, a search box you only read at submit time; the controlled loop buys you nothing there.
  • Third-party DOM libraries, a date picker or masked input that manages its own field. Let it own the DOM; sync with React at the edges via refs and events.

Everywhere else, controlled wins: validation, formatting, disabling submit, live previews, all of those need the value in state anyway.

Checkboxes, radios, selects

Same ownership idea, different props:

  • Checkbox / radio: control checked (not value), with onChange reading e.target.checked.
  • Select: put value on the <select> itself, never selected on an <option>. Uncontrolled flavor: defaultValue on the <select>.
import { useState } from 'react';

export default function App() {
  const [agree, setAgree] = useState(false);
  const [color, setColor] = useState('red');

  return (
    <form>
      <label>
        <input
          type="checkbox"
          checked={agree}
          onChange={e => setAgree(e.target.checked)}
        />
        I agree
      </label>
      <select value={color} onChange={e => setColor(e.target.value)}>
        <option value="red">Red</option>
        <option value="blue">Blue</option>
      </select>
    </form>
  );
}

What happens: the identical loop. Click the checkbox → the DOM flips → the event fires → setAgree(true) → commit re-asserts checked from state. Reject the change in the handler, and the box flips back.

And if this bookkeeping feels heavy for a big form, that's exactly what form libraries do: they run this loop (or the uncontrolled-plus-refs variant) for you and hand you tidy values at submit time. Now you know what they're doing under there.

Diagram

Rendered diagram (PNG hi-res):

rendered diagram

Rendered diagram (PNG hi-res):

rendered diagram

Common misconceptions

  • Misconception: React blocks the keystroke before it reaches the DOM. Reality: the browser updates the DOM immediately; React overrules it at the next checkpoint. Enforcement is after-the-fact.
  • Misconception: If onChange ignores the input, the typed character stays. Reality: the post-event safety net restores the field to the last committed value, the character vanishes even with no re-render.
  • Misconception: React's onChange is the native change event. Reality: it maps to the native input event, per keystroke, not on blur.
  • Misconception: value without onChange is a handy read-only trick. Reality: it's a bug-shaped pattern React warns about; say readOnly, or use defaultValue if you only wanted a starting value.
  • Misconception: defaultValue keeps applying whenever it changes. Reality: it's initial-only; the DOM owns the value afterwards.
  • Misconception: File inputs can be controlled with enough effort. Reality: browsers forbid setting their value from JavaScript; they're uncontrolled by rule of the platform.
  • Misconception: <option selected> is how you set a select's value. Reality: value (or defaultValue) goes on the <select>.

Why it works this way

  • One source of truth kills a whole bug class. If the DOM and state can disagree, every validation and formatting rule has two masters. Projection-from-state means there is nothing to keep in sync.
  • After-the-fact enforcement beats prevention. Intercepting keystrokes before they reach the DOM breaks IMEs and assistive tools; letting the browser act and then reconciling keeps every input method working.
  • The safety net covers the bailout. The state comparison would otherwise leave a stray character in the DOM; the post-event restore closes that hole.
  • Mode warnings exist because ownership flips lose data. Typed text, selection, composition, all can vanish when a field changes masters; the warning pushes you to decide once, up front.
  • Uncontrolled remains because the DOM is sometimes the rightful owner. Files, throwaway reads, and foreign libraries all have legitimate claims.

Try it yourself

  1. Type quickly into the uppercase input, trying to catch a lowercase letter on screen. You can't, explain, using the five steps, exactly when the correction happens.
  2. Change the digits handler to ignore the event entirely: setDigits(digits). Letters still vanish. Now you understand the safety net.
  3. Render <input value="fixed" /> and read the development warning; fix it all three ways (onChange, readOnly, defaultValue) and confirm each silences it.
  4. Initialize state with bare useState() (undefined) and render value={text}; after the first keystroke, watch the uncontrolled-to-controlled warning appear. Re-initialize with '' and watch it disappear.
  5. Log both e.target.value and text inside onChange of the digits-only input: the event consistently offers you more than state allows. That's the browser's proposal, yours to veto.

Recap

  • Controlled = value from state + onChange to update it. State is the source of truth; the DOM is a projection.
  • The loop: browser changes DOM → event → your handler decides → commit re-asserts the value → the post-event safety net restores anything that slipped through.
  • Rejecting a keystroke works even with zero state change, that's the safety net, not a re-render.
  • The same loop reconciles autofill, extensions, and IME composition back to state.
  • value without onChange → the read-only warning; fix with onChange, readOnly, or defaultValue.
  • Uncontrolled: defaultValue is initial-only, the DOM owns the value, read it via a ref. Never flip modes, undefined → string is the classic trigger.
  • File inputs are always uncontrolled. Checkbox/radio use checked; a select takes value on the <select> itself.
  • Form libraries mostly run this loop for you.

Next

Portals: rendering outside the box →