Skip to the solution
MediumFrontend7 min read

What is the difference between controlled and uncontrolled components?

778 views

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.

  • forms
  • controlled components
  • uncontrolled components
  • state

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

7 min read Β· 10 sections

Try it in the playground

Open any example in the playground to run and change it.

ReactThe same field both ways, with a live render counter and the classic warning
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>
  );
}

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 48 of 119 in React.js

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

All React.js questions
Up nextMedium

What is the difference between useMemo and useCallback?

Continue