Course outline · 0% complete

0/28 lessons0%

Course overview →

Controlled inputs

lesson 6-1 · ~11 min · 16/28

What a setter call does, again

Lesson 4-1 established that a setter like setText("hi") does two things: it stores the new value and schedules a re-render.

From the render loop in that lesson, the setter records the new state value and asks React to render the component again, which then reads the fresh value out of useState.

That pair is exactly what makes controlled inputs work. The store half means the typed text becomes the app's data, and the re-render half means the input redisplays it, and neither one alone would be enough.

Controlled inputs

An <input> in plain HTML keeps its own text internally, the DOM owns the value. That clashes with React's model, where data lives in state and the UI is computed from it. Two owners of the truth means sync bugs, the exact disease from lesson 1-1.

A controlled input hands ownership to React:

function NameField() {
  const [name, setName] = useState("");
  return (
    <input
      value={name}
      onChange={e => setName(e.target.value)}
    />
  );
}

The cycle on every keystroke:

  1. The user types a character.
  2. onChange fires with the event e, and e.target.value is the input's proposed new text (the DOM event you know from Web Development Fundamentals).
  3. setName stores it and triggers a re-render.
  4. The re-render sets value={name}, so the input shows exactly what state says.

State is the single source of truth. The input displays state, never its own memory.

user types "a"onChange firessetName(e.target.value)state: name = "a"re-render:value={name}
The controlled-input cycle. A keystroke fires onChange, the setter stores the text in state, the re-render feeds state back into value, and the input shows exactly what state says.

Why the loop is worth it

Because step 3 is a hook point where your code decides what the input becomes. Validate, transform, or limit the text before storing it, and the input can never display anything invalid:

onChange={e => setCode(e.target.value.toUpperCase().slice(0, 6))}

Type abc1234xyz and the box shows ABC123. Doing that reliably with an uncontrolled input means writing to the DOM after the browser has already accepted the keystroke, which fights the DOM and produces visible flicker.

It also makes the rest of the app trivial, since any component that needs the current text reads the state variable. There is no document.querySelector("input").value anywhere, and no question about which copy of the text is authoritative.

The pattern generalizes to anything you want to enforce as the user types:

  • upper-casing a coupon or country code
  • stripping non-digits from a phone number
  • capping a bio at 200 characters
  • refusing to store a leading space

Note what this does not replace. Transforming on the way into state stops invalid text from ever being displayed, and full validation on submit is still needed, because an empty field passes every keystroke check by never firing one.

The controlled loop without a browser

setCode plays the role of the onChange handler plus the re-render, transforming the proposed text and printing what the input would then display.

let code = "";

function setCode(next) {
  code = next.toUpperCase().slice(0, 6);
  console.log("input now shows: " + code);
}

setCode("abc");
setCode("abc1234xyz");

Output

input now shows: ABC
input now shows: ABC123

toUpperCase transforms and slice(0, 6) caps the length, so the input can only ever show the sanitized result. The user's raw keystrokes are never what appears on screen, which is the entire point of React owning the value.

The two calls stand in for two moments in a typing session, and note that the second one passes the whole proposed text rather than one new character. Real onChange events work the same way, since e.target.value is always the complete would-be contents of the box.

The transformation is what makes this a controlled input rather than merely a tracked one. A handler that stored next unchanged would still be controlled and would give up the chance to reject anything, and the sanitizing version is the reason people accept the extra plumbing.

Worth noticing that code here is one variable playing the role of one piece of state. In React the same code lives in useState, and the printing is React re-rendering the input with value={code}.

A frozen input

const [email, setEmail] = useState("");
return <input value={email} />;

The bug is that there is no onChange, so state never updates and the input is frozen, meaning typing does nothing.

value={email} pins the input to state, and with no handler the state never changes, so every keystroke triggers nothing and the box keeps displaying the same empty string. React warns about this in the console, calling it a read-only field.

The mental model explains the behavior exactly. The DOM would happily accept the character, and React overwrites the input's value with state on every render, so the character is discarded as fast as it arrives.

There are three coherent shapes and only one bug:

Written asBehavior
value and onChangecontrolled, works
defaultValue, no onChangeuncontrolled, DOM owns the text
value, no onChangefrozen, the bug

defaultValue is the escape hatch worth knowing. It seeds the input once and then lets the DOM own the text, which is occasionally the right choice for a large form you only read on submit, and it gives up the per-keystroke control this unit is built on.