Controlled vs uncontrolled
A reusable widget should support both controlled (parent owns state via value/onChange) and uncontrolled (component owns state via defaultValue) modes — built once with a useControllableState hook that prefers the prop when present and falls back to internal state.
You built a Tabs widget for your compound-components unit. The first screen that uses it just wants tabs that work — click a tab, it shows. The second screen needs the active tab to live in the URL, sync with a “next” button, and reset when a filter changes. Same widget, two completely different relationships to state. If your Tabs only supports one of those, half its users will fight it.
This is the controlled/uncontrolled duality — the oldest reusable-component pattern in React, the one <input> itself is built on. A senior widget offers both modes from one implementation, so the trivial use stays trivial and the demanding use gets full control. Getting this wrong produces one of React’s most infamous runtime warnings.
After this lesson you can explain the difference between a controlled component (parent owns the state via value/onChange) and an uncontrolled one (the component owns it via defaultValue); implement a useControllableState hook that uses the prop when it’s provided and internal state otherwise; design a compound widget’s API so simple callers stay terse and complex callers get full control; and name the failure mode — forcing controlled-only, or silently switching modes mid-life (the controlled-to-uncontrolled warning bug).
Controlled means the parent owns the state; the component is a pure function of the value prop. It renders what you pass and reports intent through onChange — it never remembers anything itself. This is the React mental model applied to a single field: UI = f(state), where state lives in the parent. You get full control: derive the value, validate before committing, sync it to a URL, reset it on demand.
function Tabs({ value, onChange }: { value: string; onChange: (v: string) => void }) {
// no useState here — the active tab is whatever the parent says it is
return (
<div role="tablist">
{["a", "b"].map((id) => (
<button key={id} aria-selected={value === id} onClick={() => onChange(id)}>
{id}
</button>
))}
</div>
);
}
// caller owns the state and every transition
const [tab, setTab] = useState("a");
<Tabs value={tab} onChange={setTab} />;The cost: every caller must wire up useState and an onChange, even ones that only want the obvious behaviour.
Uncontrolled means the component owns the state; the parent only seeds it with defaultValue. The component keeps its own useState, initialised once from defaultValue, and never reads that prop again. The caller writes one line and walks away. This is exactly how a native <input defaultValue="hi" /> behaves: you set the starting text, the DOM remembers the rest.
function Tabs({ defaultValue }: { defaultValue: string }) {
const [value, setValue] = useState(defaultValue); // seeded once, then owned internally
return (
<div role="tablist">
{["a", "b"].map((id) => (
<button key={id} aria-selected={value === id} onClick={() => setValue(id)}>
{id}
</button>
))}
</div>
);
}
// caller does nothing but pick a starting tab
<Tabs defaultValue="a" />;The cost: the parent can’t read or steer the value — no URL sync, no programmatic reset, no “validate before applying”. Terse, but a dead end the moment a caller needs control.
A reusable widget should support both modes from one implementation — the prop’s presence picks the mode. The convention React itself uses: if value is passed, you’re controlled (the prop wins); if it’s absent, you’re uncontrolled (internal state wins, seeded by defaultValue). The whole decision collapses into one hook, useControllableState, that every reusable widget can share. It always keeps an internal state cell as a fallback, but reads from the prop whenever the prop is defined.
function useControllableState<T>(opts: {
value: T | undefined; // the controlled prop (undefined ⇒ uncontrolled)
defaultValue: T; // seed for uncontrolled mode
onChange?: (next: T) => void; // notify the parent on every change
}): [T, (next: T) => void] {
const isControlled = opts.value !== undefined;
const [internal, setInternal] = useState(opts.defaultValue);
const value = isControlled ? (opts.value as T) : internal;
const setValue = (next: T) => {
if (!isControlled) setInternal(next); // only own the state when uncontrolled
opts.onChange?.(next); // always report, so controlled parents hear it
};
return [value, setValue];
}Notice the asymmetry: in controlled mode setValue does not touch internal state — it only calls onChange and lets the prop flow back in. That keeps a single source of truth instead of two competing copies.
The failure mode has two faces: forcing controlled-only, and silently switching modes mid-life. Force controlled-only and you tax every trivial caller — a one-off tab strip on a settings page now needs its own useState, for nothing. That friction is why developers abandon otherwise-good components. The subtler bug is switching modes during the component’s life: a value that starts undefined and later becomes defined (or vice-versa) flips the component between owning and not owning its state, and React fires the classic “a component is changing an uncontrolled input to be controlled” warning — symptomatic of a lost or duplicated source of truth.
// BUG: value is undefined on first render, then defined → mode flips mid-life
function Field({ user }: { user?: User }) {
// user?.name is undefined until the fetch resolves
return <Tabs value={user?.name} onChange={onChange} />; // uncontrolled → controlled ⚠️
}
// FIX: commit to one mode. Coalesce to a stable default so value is never undefined…
<Tabs value={user?.name ?? "a"} onChange={onChange} />;
// …or go fully uncontrolled and let the widget own it:
<Tabs defaultValue={user?.name ?? "a"} />;The rule the hook enforces: a component is controlled or uncontrolled for its whole lifetime, decided on the first render, never flipped.
Take a controlled-only Rating widget and make it dual-mode without breaking existing callers. Here’s the rigid version — every caller is forced to own state:
function Rating({ value, onChange }: { value: number; onChange: (n: number) => void }) {
return (
<div>
{[1, 2, 3, 4, 5].map((n) => (
<button key={n} aria-pressed={n <= value} onClick={() => onChange(n)}>★</button>
))}
</div>
);
}
// a read-mostly "leave a review" form must still spin up useState just to display stars:
const [stars, setStars] = useState(0);
<Rating value={stars} onChange={setStars} />;Now make value/onChange optional and route both through the hook. Controlled callers are untouched; uncontrolled callers get a one-liner:
function Rating({
value,
defaultValue = 0,
onChange,
}: {
value?: number; // optional now — its presence picks the mode
defaultValue?: number;
onChange?: (n: number) => void;
}) {
const [rating, setRating] = useControllableState({ value, defaultValue, onChange });
return (
<div>
{[1, 2, 3, 4, 5].map((n) => (
<button key={n} aria-pressed={n <= rating} onClick={() => setRating(n)}>★</button>
))}
</div>
);
}
// uncontrolled: terse, the widget remembers the stars itself
<Rating defaultValue={3} />;
// controlled: full control — needed because we submit the rating with a form action
const [stars, setStars] = useState(0);
<Rating value={stars} onChange={setStars} />;One implementation, two contracts. The simple “show me stars” case is a single line; the demanding “this rating is part of a form action and must round-trip through server state” case keeps every lever. That range — terse by default, controllable when it matters — is precisely what makes the widget reusable across the whole app instead of just the screen it was born on.
▸Why this works
Why let the prop’s presence decide the mode, instead of an explicit mode="controlled" flag? Because presence is what the native platform already does (value vs defaultValue on <input>), so it matches what every React developer’s hands already know — no new API to learn. It also makes the controlled path impossible to forget: if you pass value, you’re committing to feed it, and TypeScript can even pair value with a required onChange via a discriminated union so a controlled-without-handler combination won’t compile. An explicit flag is a second source of truth about which source of truth you’re using — more to keep in sync, more to get wrong.
▸Common mistake
The bug that ships most often: writing the controlled branch so it also sets internal state (setInternal(next); onChange?.(next); with no isControlled guard). Now there are two copies of the value — the prop and the stale internal cell — and they drift. A parent that ignores onChange (or validates and rejects the change) sees the UI move anyway, because the internal copy updated independently. The single-source-of-truth rule is the whole point: in controlled mode, setValue must only notify the parent and let the new value prop flow back down. If both copies update, you no longer have a controlled component — you have a desynchronised one.
A teammate's useControllableState updates internal state on every change AND calls onChange, with no isControlled guard. A parent renders the widget controlled (passes value) but validates onChange and sometimes rejects the new value by not updating its state. What goes wrong?
A controlled component lets the parent own the state through value/onChange and renders as a pure function of the prop; an uncontrolled one owns its own useState, seeded once by defaultValue, and the parent can’t steer it. A reusable widget should offer both from one implementation: useControllableState keeps an internal cell as a fallback but reads the value prop whenever it’s defined, and in controlled mode setValue only fires onChange — never touching internal state — so there is a single source of truth. Offering both modes is what makes a widget reusable across contexts: the trivial caller stays a one-liner (uncontrolled), the demanding caller gets every lever (controlled). The failure mode is forcing controlled-only — taxing every simple use — or letting value flip between undefined and defined mid-life, which triggers React’s controlled/uncontrolled warning. Pick the mode on the first render and hold it for the component’s whole lifetime.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.