How do you design a React `<Tabs>` compound component that supports both `<Tabs defaultValue="overview">` and `<Tabs value={tab} onValueChange={setTab}>`, and what do the parts see in each case?
answer
- one component, two ownership models
- the prop's presence decides the mode
- internal state always exists
- always notify, sometimes store
- parts read a resolved value only
basics
~20 sAlways keep internal state seeded from defaultValue, treat the widget as controlled whenever the value prop is not undefined, and publish the resolved value plus one select callback through context. The parts read only that, so they never know which mode is active.
solid answer
~50 sI always hold internal state with `useState(defaultValue)` and derive the mode from the prop: `const isControlled = value !== undefined`. The value actually rendered is `isControlled ? value : internal`. The `select` callback always calls `onValueChange`, and additionally writes internal state when uncontrolled — so a controlled parent stays the single source of truth and may reject or rewrite a change, while an uncontrolled consumer gets a widget that just works. The context carries `{ value, select }` and nothing else, so `Tab` and `TabPanel` are identical code in both modes and consumers can switch modes without touching their markup. Two rules I hold to: the mode is decided at mount and flipping it later is a bug worth warning about in development, the way React warns for form inputs; and `defaultValue` is an initial value only, so later changes to it are ignored by design.
code
javascript · 34 linesimport { createContext, useCallback, useContext, useMemo, useState } from 'react';
const TabsContext = createContext(null);
export function Tabs({ value, defaultValue, onValueChange, children }) {
const [internal, setInternal] = useState(defaultValue);
const isControlled = value !== undefined;
const current = isControlled ? value : internal;
const select = useCallback(
(next) => {
if (!isControlled) setInternal(next);
onValueChange?.(next);
},
[isControlled, onValueChange],
);
const ctx = useMemo(() => ({ value: current, select }), [current, select]);
return <TabsContext value={ctx}>{children}</TabsContext>;
}
export function Tab({ id, children }) {
const { value, select } = useContext(TabsContext);
return (
<button role="tab" aria-selected={value === id} onClick={() => select(id)}>
{children}
</button>
);
}
export function TabPanel({ id, children }) {
const { value } = useContext(TabsContext);
return value === id ? <div role="tabpanel">{children}</div> : null;
}go deeper
Know the vocabulary: controlled means the parent supplies the current value and gets a change callback, uncontrolled means the component keeps the value itself and only takes an initial one.
Explain how one component supports both — unconditional internal state plus a check on whether the value prop was passed — and why the change callback must fire in both modes.
Demonstrate the API judgment: detect the mode with an undefined check, fix it at mount, keep the parts unaware of it, and warn in development when a consumer flips it.
Weigh whether the library should offer both modes at all: every extra mode doubles the state combinations you support, document and triage bug reports for, across every widget that follows the same convention.
## The two ownership models A widget with state can be exposed two ways. **Uncontrolled**: the widget owns the value, the consumer supplies an initial one and forgets about it. **Controlled**: the consumer owns the value, passes it in on every render, and receives a callback when the widget wants it changed. Both are legitimate, and the reason libraries support both is that the same `Tabs` is used in a page where nobody cares which tab is open, and in a page where the open tab is in the URL. Supporting only the uncontrolled mode makes the second case impossible. Supporting only the controlled mode makes every trivial use site carry a `useState`. So the library does the work once. ## One component, both modes The implementation is small and worth being able to write on a whiteboard: ```jsx function Tabs({ value, defaultValue, onValueChange, children }) { const [internal, setInternal] = useState(defaultValue); const isControlled = value !== undefined; const current = isControlled ? value : internal; const select = useCallback((next) => { if (!isControlled) setInternal(next); onValueChange?.(next); }, [isControlled, onValueChange]); const ctx = useMemo(() => ({ value: current, select }), [current, select]); return <TabsContext value={ctx}>{children}</TabsContext>; } ``` Four decisions are encoded there. **Internal state always exists.** Calling `useState` unconditionally is required by the Rules of Hooks anyway, and keeping the state around costs nothing. It also means a controlled widget still has somewhere to fall back to if the mode ever changes. **The mode is detected by `value !== undefined`.** Not truthiness and not `!= null`. A consumer may legitimately control the value to `null`, `0` or the empty string, and a truthiness check would treat those as "uncontrolled" and silently take over ownership. `undefined` is the only value that reliably means "prop absent", because that is what React gives you for a prop nobody passed. **The callback fires in both modes.** In controlled mode it is the only way the parent learns anything. In uncontrolled mode it is how a consumer observes the selection — for analytics, for a side panel — without being forced into controlled mode just to watch. Firing it only when controlled is a common and annoying bug in hand-rolled components. **Internal state is written only when uncontrolled.** This is what makes controlled mean *controlled*: the parent may ignore the change, validate it, or map it to something else, and the widget must not disagree with the prop it was given. Writing both would produce a widget that briefly shows a tab the parent never approved. ## What the parts see The context carries the resolved value and one callback. `Tab` renders `aria-selected={value === id}` and calls `select(id)` on click; `TabPanel` renders when `value === id`. Neither knows the mode exists, and that is the design goal — a consumer converts an uncontrolled usage into a controlled one by adding two props to the parent, with no change to the markup inside and no change to the library. Resist exposing `isControlled` on the context. The moment parts can branch on it, the mode stops being a parent-level detail and every future part has to handle two cases. ## Fixing the mode at mount A component that starts uncontrolled and later receives a real `value` flips ownership mid-life: the internal state it was using is overridden, and the parent — which was not tracking the selection — may push a stale value and make the UI jump. React itself treats this as a mistake for form inputs and warns when an input switches between controlled and uncontrolled, and your component should do the same in development: ```jsx const wasControlled = useRef(isControlled); if (process.env.NODE_ENV !== 'production' && wasControlled.current !== isControlled) { console.error('Tabs is switching between controlled and uncontrolled.'); } ``` The usual cause is a consumer whose own state starts as `undefined` while data loads. The fix is on their side: give the state a real initial value, or do not render the widget until you have one. ## Defaults are initial values `defaultValue` seeds `useState` on the first render and is ignored afterwards. That surprises people, but the alternative — syncing the prop into state on every change — reintroduces the two-sources-of-truth bug the controlled mode exists to solve. If a consumer needs the widget to start over when the underlying record changes, the answer is to give the widget a new identity so it mounts fresh, not to make `defaultValue` reactive. ## The API names Use the pairing consumers already know from the DOM and from every serious library: `value` with `defaultValue`, `checked` with `defaultChecked`, and a change callback named after the thing that changed. Inventing your own naming here costs discoverability the compound pattern can least afford.
- What breaks if a consumer renders `<Tabs value={undefined}>` at first and passes a real value once data loads?The widget flips from uncontrolled to controlled mid-life. The internal state it had been using is overridden by a parent that was not tracking the selection, so the visible tab can jump backwards. Decide the mode on the first render, warn in development when it changes — React does exactly this for form inputs — and tell consumers to give their state a real initial value.
- Should `onValueChange` fire when the component is used uncontrolled?Yes. Consumers often want to observe the selection — analytics, syncing a side panel — while still letting the widget own it. Firing the callback only in controlled mode forces them to adopt controlled mode purely to watch, which adds a `useState` and a whole class of sync bugs for no benefit.
- Why keep the mode out of the context value?So the parts stay one implementation. If `Tab` can branch on whether the widget is controlled, every future part has to handle two cases, and a consumer switching modes could change part behaviour rather than just parent behaviour. Publishing only the resolved value and one callback keeps the mode a detail of the parent.
saying these in an interview costs you the question
- Uses useEffect to copy the value prop into state
- Detects control with truthiness or a null check
- Only calls onValueChange when the component is controlled
- Writes internal state even while the parent controls the value
- Thinks defaultValue keeps updating the state after mount