Skip to solution
mediumFrontend

What is the difference between controlled and uncontrolled components?

778 views
01

Understand the problem

Question presented to candidate: "What makes a form input controlled, and when would you deliberately leave one uncontrolled?"

What a strong answer should cover:

  • Controlled: React state is the source of truth. The input has a value prop and an onChange that updates state. Every keystroke is a render.
  • Uncontrolled: the DOM is the source of truth. The input has a defaultValue and you read it when you need it — via a ref, or via FormData.
  • defaultValue is the initial value only; changing it later does not move the field, which is exactly the point.
  • Controlled is required when something must happen per keystroke — live validation, a character counter, a dependent field, formatting as you type, or disabling submit while invalid.
  • Uncontrolled is right for a plain field you only read on submit, for file inputs (which cannot be controlled), and for integrating non-React code.
  • React warns about two specific mistakes: a value with no onChange (a read-only field), and switching an input from uncontrolled to controlled mid-life.
  • React 19 Actions made uncontrolled the sensible default again — FormData collects the values, so per-field state is only needed for per-keystroke behaviour.

Clarifying questions expected:

  • "Does anything need to react to each keystroke, or only to the final value?" — that is the whole decision.
  • "Is this a file input?" — those are always uncontrolled.

Code / implementation expected: Optional. The two four-line versions side by side make the difference obvious.

formscontrolled componentsuncontrolled componentsstate
02

Attempt it yourself

Sketch your approach before reading the solution — that's what interviews test.

Nudge consolestandby

Stuck? Beam a request up — the console returns a conceptual nudge that guides your logic without spoiling the implementation.

03

Study the solution

Target Audience: Engineers preparing for React interviews — no prior forms knowledge assumed. Difficulty: Medium

How to read this doc: Concepts are explained in plain language first, then tagged with 📌 Interview term:. The warning texts and the ref reading in sections 4 and 5 were **capture

Solution ready — 2 min read

Classified // press E to declassify

04

Explore the playground snippets

The same field both ways, with a live render counter and the classic warning
Run Playground
import { useState, useRef, useEffect } from "react";

function Controlled({ onRender }) {
  const [value, setValue] = useState("");
  useEffect(() => { onRender(); });          // counted after every commit
  return (
    <Panel title="controlled — React state is the truth">
      <input
        value={value}
        onChange={(e) => setValue(e.target.value)}
        placeholder="type here"
        style={inputStyle}
      />
      <Line>React knows the value continuously: <code>{JSON.stringify(value)}</code></Line>
      <Line>characters: {value.length} — this counter is only possible because it is controlled</Line>
    </Panel>
  );
}

function Uncontrolled({ onRender }) {
  const ref = useRef(null);
  const [read, setRead] = useState(null);
  useEffect(() => { onRender(); });
  return (
    <Panel title="uncontrolled — the DOM is the truth">
      <input ref={ref} defaultValue="" placeholder="type here" style={inputStyle} />
      <button onClick={() => setRead(ref.current.value)}>read the value</button>
      <Line>
        last read: <code>{read === null ? "(never)" : JSON.stringify(read)}</code>
        {" "}— typing caused no render at all
      </Line>
    </Panel>
  );
}

// ❌ The classic bug: value is undefined until the data arrives, so this input
//    starts uncontrolled and silently becomes controlled. React warns in the
//    console. The fix is the ?? "" on the next line down.
function LateData({ broken }) {
  const [data, setData] = useState(undefined);
  useEffect(() => {
    const t = setTimeout(() => setData({ name: "Ada" }), 1200);
    return () => clearTimeout(t);
  }, []);
  return (
    <Panel title={broken ? "❌ value={data?.name}" : "✅ value={data?.name ?? \"\"}"}>
      <input
        value={broken ? data?.name : (data?.name ?? "")}
        onChange={() => {}}
        style={inputStyle}
      />
      <Line>{data ? "data arrived" : "waiting for data…"}</Line>
    </Panel>
  );
}

const inputStyle = { width: "100%", margin: "6px 0" };
const Panel = ({ title, children }) => (
  <div style={{ border: "1px solid #ddd", borderRadius: 8, padding: 12, marginBottom: 12 }}>
    <strong style={{ fontSize: 13 }}>{title}</strong>
    {children}
  </div>
);
const Line = ({ children }) => <div style={{ fontSize: 12, color: "#666" }}>{children}</div>;

export default function App() {
  const counts = useRef({ controlled: 0, uncontrolled: 0 });
  const [, tick] = useState(0);
  // Counted in an effect and stored in a ref, so counting never causes a render.
  const bump = (k) => { counts.current[k]++; };

  return (
    <div style={{ padding: 24, fontFamily: "system-ui", lineHeight: 1.6, maxWidth: 520 }}>
      <Controlled onRender={() => bump("controlled")} />
      <Uncontrolled onRender={() => bump("uncontrolled")} />

      <button onClick={() => tick((n) => n + 1)} style={{ marginBottom: 12 }}>
        show render counts
      </button>
      <Line>
        renders — controlled: <strong>{counts.current.controlled}</strong>
        {" · "}uncontrolled: <strong>{counts.current.uncontrolled}</strong>
      </Line>

      <p style={{ fontSize: 13, color: "#666" }}>
        Type ten characters into each, then press the button. The controlled
        panel has rendered once per keystroke; the uncontrolled one has not
        rendered at all. Open the console for the warning from the broken
        field below, which starts undefined and becomes a string.
      </p>

      <LateData broken={true} />
      <LateData broken={false} />
    </div>
  );
}
05

Join the discussion

Discussion (0)

Sign in to join the discussion.

No responses yet. Be the first to share what you think.

Transmission complete // awaiting log

KEEP THE
STREAK ALIVE.

Dossier 48 of 119 decoded in the React.js track. One more won't hurt.

Back to track