Compound components and controlled vs uncontrolled
Build flexible component APIs like <Tabs><Tabs.Tab/><Tabs.Panel/></Tabs> with shared context, support both controlled and uncontrolled use with value, defaultValue and onChange, and know the state reducer and prop getter patterns.
Comes up in: React, Low-level design, Machine coding
Learn#
Arjun’s Tabs takes tabs={[{ label, content, icon, badge, disabled }]}. Designers want a badge on one tab, a tooltip on another and a panel with its own layout. Each request adds a prop.
A compound component splits one widget into parts that work together through shared context, so callers arrange the parts like HTML’s select and option:
import { GlobalRegistrator } from "@happy-dom/global-registrator";
GlobalRegistrator.register(); // a DOM for Node; in a browser you already have one
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
const { createElement: h, createContext, useContext, useState, act } = await import("react");
const { createRoot } = await import("react-dom/client");
const TabsContext = createContext(null);
function Tabs({ defaultValue, value, onChange, children }) {
const [inner, setInner] = useState(defaultValue);
const controlled = value !== undefined;
const current = controlled ? value : inner;
const select = (v) => { if (!controlled) setInner(v); onChange?.(v); };
return h(TabsContext.Provider, { value: { current, select } }, children);
}
Tabs.Tab = function Tab({ value, children }) {
const { current, select } = useContext(TabsContext);
return h("button", { role: "tab", "aria-selected": String(current === value), onClick: () => select(value) }, children);
};
Tabs.Panel = function Panel({ value, children }) {
const { current } = useContext(TabsContext);
return current === value ? h("div", { role: "tabpanel" }, children) : null;
};
const box = document.createElement("div");
const root = createRoot(box);
await act(() => root.render(h(Tabs, { defaultValue: "orders" },
h(Tabs.Tab, { value: "orders" }, "Orders"), h(Tabs.Tab, { value: "returns" }, "Returns"),
h(Tabs.Panel, { value: "orders" }, "3 orders"), h(Tabs.Panel, { value: "returns" }, "1 return"))));
const show = () => console.log([...box.querySelectorAll("[role=tab]")].map((t) => `${t.textContent}:${t.getAttribute("aria-selected")}`).join(" "), "|", box.querySelector("[role=tabpanel]").textContent);
show();
await act(() => box.querySelectorAll("[role=tab]")[1].click());
show();Output (verified)
Orders:true Returns:false | 3 orders Orders:false Returns:true | 1 return
A controlled component gets its value from the parent (value + onChange). An uncontrolled one keeps its own state, starting from defaultValue. Good components support both, as Tabs does above.
State
- context: current = orders
Page
empty
Tabs holds the selected value and shares current and select through context.
Read the steps as text
- Tabs holds the selected value and shares current and select through context.
- Each Tabs.Tab reads the context to know if it is selected; each Tabs.Panel shows itself only when its value matches.
- Clicking Returns calls select("returns"). Uncontrolled: Tabs updates its own state and calls onChange.
- Controlled: the parent passes value and decides in onChange whether to change it. Tabs only reports the request.
Predict#
Predict first
A controlled Tabs gets value="orders", and its onChange does nothing. The user clicks Returns. What shows?
In controlled mode, the parent owns the value. A click only reports the request through onChange; if the parent ignores it, nothing changes. That is the point: the parent can block or change the update.
Interview angle#
Say it like this — about 30 seconds
“Compound components split a widget into parts, like Tabs, Tabs.List, Tabs.Tab and Tabs.Panel, that share state through context, so callers control the structure and the component controls behavior and accessibility. For state, I support controlled use with value and onChange, and uncontrolled use with defaultValue, through a useControllableState hook: if value is defined, it is the source of truth, otherwise internal state is. For deeper customization, the state reducer pattern lets callers adjust each state change.”
Follow-up What are compound components? Build a Tabs API.
<Tabs value onChange><Tabs.List><Tabs.Tab value/></Tabs.List><Tabs.Panel value/></Tabs>.Follow-up Controlled vs uncontrolled components: how do you support both?
value when it is defined, else internal state from defaultValue, and always call onChange.Follow-up What is the state reducer pattern?
Practice#
On your own
Write the logic of a useControllableState hook, with an optional state reducer.
Done when
- Controlled mode shows
valueand only reports changes throughonChange. - Uncontrolled mode stores changes and also calls
onChange. - A reducer can change or block updates; blocked or equal updates do nothing.
Remember#
Try to answer each card before you open it. Then add them to your reviews.
How do compound component parts share state?
Through a context provided by the parent component.
Controlled vs uncontrolled?
Controlled: parent owns value via value + onChange. Uncontrolled: internal state from defaultValue.
How do you support both modes?
Use value when defined, else internal state; always call onChange.
What does the state reducer pattern let callers do?
Change or block each proposed state change.
Go deeper
Four layers. Stop at the one your interview needs; each ends with a quick check.
The core ideaEvery round
Older compound components used React.Children.map and cloneElement, which breaks when callers wrap parts in a div. Context reaches parts at any depth.
Check yourself: Why use context instead of cloneElement for compound components?
Context works at any depth and with wrappers; cloneElement only reaches direct children.
How it works underneathSenior rounds
getTabProps({ value }) return the handlers and ARIA attributes for an element the caller renders, merging in the caller’s own props. Headless libraries such as Downshift popularized them.Check yourself: What does a prop getter return?
A props object (handlers, ARIA attributes) for the caller to spread on their element.
Edge cases and trapsWhere people slip
value !== undefined and keep it fixed for the component’s life; use null for “nothing selected” in controlled mode.Check yourself: Why must value never switch between undefined and defined?
The component would switch between controlled and uncontrolled, losing or jumping state.
In productionStaff-level answers
Check yourself: Which accessibility pattern must a Tabs compound component implement?
The WAI-ARIA tabs pattern: roles, aria-selected and arrow-key navigation (QLT.04.01).
Finished the five steps? Mark it, and its cards join your reviews.
Last checked 2026-11-01. Spot a mistake? Use “Report an error” below — fixes are listed on the corrections page.
Interview questions this answers
- What are compound components? Build a
TabsAPI. - Controlled vs uncontrolled components: how do you support both?