Engrain by ByteRush

RCT.08.02 Senior· 12 minCore — asked often

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();
This example uses Node.js, so it runs in our checks rather than in your browser.

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.

Compound Tabs, uncontrolled then controlledStep 1 of 4

State

  • context: current = orders

Page

    empty

    Tabs holds the selected value and shares current and select through context.

    Read the steps as text
    1. Tabs holds the selected value and shares current and select through context.
    2. Each Tabs.Tab reads the context to know if it is selected; each Tabs.Panel shows itself only when its value matches.
    3. Clicking Returns calls select("returns"). Uncontrolled: Tabs updates its own state and calls onChange.
    4. 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?

    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.”

    Stays on your device. Nothing is uploaded.
    Follow-up What are compound components? Build a Tabs API.
    Parts that share hidden state through context: <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?
    Use value when it is defined, else internal state from defaultValue, and always call onChange.
    Follow-up What is the state reducer pattern?
    The component passes each proposed state change through a caller-supplied reducer, which can change or block it.

    Practice#

    On your own

    Write the logic of a useControllableState hook, with an optional state reducer.

    Done when

    • Controlled mode shows value and only reports changes through onChange.
    • Uncontrolled mode stores changes and also calls onChange.
    • A reducer can change or block updates; blocked or equal updates do nothing.
    Your solution
    Ctrl/⌘ + Enter

    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
    Prop getters such as 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

    Decide control mode by value !== undefined and keep it fixed for the component’s life; use null for “nothing selected” in controlled mode.
    React warns when an input switches modes Source: React: Controlled and uncontrolled components.

    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

    The state reducer pattern lets callers adjust changes, such as keeping one accordion panel always open.
    Libraries such as Radix ship these patterns with accessibility built in Source: Radix Primitives: Tabs.

    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 Tabs API.
    • Controlled vs uncontrolled components: how do you support both?