Skip to main content

Forms and Inputs

Controlled Inputs

Written by Published

A controlled input has no memory of its own. React state is the value, and typing is just a request to change that state.

The loop

Two props make an input controlled: value reads from state, and onChange writes back to it. Remove either and the input misbehaves in a specific, recognisable way.

Value in, change out

jsx

function NameField() {
  const [name, setName] = React.useState("")

  return (
    <div>
      <input
        value={name}
        onChange={(event) => setName(event.target.value)}
        placeholder="Type your name"
      />
      <p>State holds: "{name}"</p>
      <button onClick={() => setName("")}>Clear</button>{" "}
      <button onClick={() => setName("Ada")}>Set to Ada</button>
    </div>
  )
}

const root = ReactDOM.createRoot(document.getElementById("root"))
root.render(<NameField />)

Because state is the source of truth, the buttons can change the input just as easily as typing does. That is the payoff - the value is data you own, not something locked inside the DOM.

The read-only mistake

Pass value without onChange and the input freezes: every keystroke is discarded because state never changes and React re-renders it back to the old value. React warns about this in the console.

Three inputs, one works

jsx

function Inputs() {
  const [text, setText] = React.useState("start")

  return (
    <div style={{ display: "grid", gap: 8, maxWidth: 320 }}>
      {/* Frozen: value with no way to change it */}
      <label>
        Frozen: <input value={text} readOnly />
      </label>

      {/* Uncontrolled: React does not track it at all */}
      <label>
        Uncontrolled: <input defaultValue={text} />
      </label>

      {/* Controlled: value and onChange */}
      <label>
        Controlled: <input value={text} onChange={(e) => setText(e.target.value)} />
      </label>

      <p>State: "{text}"</p>
    </div>
  )
}

const root = ReactDOM.createRoot(document.getElementById("root"))
root.render(<Inputs />)

Type in each. The first cannot change. The second changes but React never learns about it. Only the third keeps the state and the screen in agreement.

Each input type

  • text, email, password, textarea - value and event.target.value.
  • checkbox - checked and event.target.checked.
  • radio - checked={value === "x"} on each option.
  • select - value on the select itself, not on an option.
  • number - still gives you a string; convert deliberately.

Every type in one form

jsx

function AllTypes() {
  const [form, setForm] = React.useState({
    name: "", agreed: false, plan: "free", size: "m",
  })

  const update = (field, value) => setForm((f) => ({ ...f, [field]: value }))

  return (
    <div style={{ display: "grid", gap: 8, maxWidth: 340 }}>
      <input value={form.name} onChange={(e) => update("name", e.target.value)} placeholder="Name" />

      <label>
        <input type="checkbox" checked={form.agreed} onChange={(e) => update("agreed", e.target.checked)} />
        {" "}Agree to terms
      </label>

      <div>
        {["free", "paid"].map((plan) => (
          <label key={plan} style={{ marginRight: 10 }}>
            <input
              type="radio"
              checked={form.plan === plan}
              onChange={() => update("plan", plan)}
            />{" "}
            {plan}
          </label>
        ))}
      </div>

      <select value={form.size} onChange={(e) => update("size", e.target.value)}>
        <option value="s">Small</option>
        <option value="m">Medium</option>
        <option value="l">Large</option>
      </select>

      <pre style={{ background: "#f1f5f9", padding: 8 }}>{JSON.stringify(form, null, 2)}</pre>
    </div>
  )
}

const root = ReactDOM.createRoot(document.getElementById("root"))
root.render(<AllTypes />)

One update helper and a computed key handles every field. That scales far better than a separate useState and handler per input.

useId for label associations

A label needs an htmlFor matching its input's id, and a hard-coded id breaks the moment the component is rendered twice on one page - two elements share an id and the label points at the wrong one.

Unique ids without a counter

jsx

function Field({ label }) {
  const id = React.useId()

  return (
    <p>
      <label htmlFor={id}>{label}</label>{" "}
      <input id={id} />
    </p>
  )
}

const root = ReactDOM.createRoot(document.getElementById("root"))
root.render(
  <div>
    <Field label="First name" />
    <Field label="Last name" />
    <p style={{ color: "#64748b" }}>
      Two instances, two different ids — click either label to focus its own input.
    </p>
  </div>
)

useId is for accessibility attributes, not for list keys. It is stable across server and client rendering, which is exactly what a key generated during render is not.

Never start from undefined

Initialising a field with undefined makes the input uncontrolled on its first render, then controlled once a value arrives. React warns loudly about the switch, and the input loses its cursor position at the moment it changes.

Always give a controlled field a real initial value - an empty string for text, false for a checkbox. If the value comes from a server, render an empty field until it loads rather than passing undefined through.

  • value plus onChange makes an input controlled.
  • value alone freezes it; neither makes it uncontrolled.
  • Checkboxes use checked, not value.
  • Number inputs still hand you a string.
  • Start from "" or false, never undefined.