Lesson 33 of 80
Tabs and Tab don't pass state through props at all — they share it implicitly through Context, so the caller gets a clean, declarative API with no wiring visible.
14 min read
Every option explains itself — why the right one is right, and why each wrong one is wrong. Miss one and it comes back later on a spaced-repetition schedule.
This topic is about judgment and practical tradeoffs more than a single right answer — a mock interview is the better way to test yourself on it.
Be interviewed on this →Hints without spoilers, and a plain explanation of why a wrong answer was wrong — answered only from this lesson. Free account, no card.
Sign up freeWhat interviewers actually ask about this, and what a strong answer has to cover.
A strong answer covers
Likely follow-ups
A compound component is a set of components designed to be used together, where the parent manages shared state and the children read it implicitly instead of receiving it as explicit props. <select> and <option> in plain HTML already work this way — you never wire <option> to <select> yourself, the browser does it because they're built to cooperate. React's version of that trick uses Context internally, but hides it completely from whoever's using the component.
The payoff is the calling code. Compare a Tabs API that takes a config array:
<Tabs
items={[
{ id: "a", label: "Profile", content: <Profile /> },
{ id: "b", label: "Settings", content: <Settings /> },
]}
/>
against one written as compound components:
<Tabs defaultTab="a">
<TabList>
<Tab id="a">Profile</Tab>
<Tab id="b">Settings</Tab>
</TabList>
<TabPanel id="a"><Profile /></TabPanel>
<TabPanel id="b"><Settings /></TabPanel>
</Tabs>
The second reads like markup, lets the caller reorder, conditionally render, or interleave arbitrary JSX between tabs, and never forces the shared state into a props object at all.
const TabsContext = createContext(null);
function Tabs({ defaultTab, children }) {
const [activeId, setActiveId] = useState(defaultTab);
return (
<TabsContext.Provider value={{ activeId, setActiveId }}>
<div className="tabs">{children}</div>
</TabsContext.Provider>
);
}
function TabList({ children }) {
return <div className="tab-list">{children}</div>;
}
function Tab({ id, children }) {
const { activeId, setActiveId } = useContext(TabsContext);
return (
<button
className={id === activeId ? "tab active" : "tab"}
onClick={() => setActiveId(id)}
>
{children}
</button>
);
}
function TabPanel({ id, children }) {
const { activeId } = useContext(TabsContext);
return activeId === id ? <div className="tab-panel">{children}</div> : null;
}
Tabs.List = TabList;
Tabs.Tab = Tab;
Tabs.Panel = TabPanel;
Tabs owns activeId and hands it to TabsContext. Neither Tab nor TabPanel receives activeId as a prop from their caller — they each pull it straight from context, which is what makes them "implicitly" connected. Attaching Tab and TabPanel onto Tabs as static properties (Tabs.Tab, Tabs.Panel) is a common convention that signals "these only make sense inside a Tabs," without actually enforcing it at compile time.
Tab, TabPanel) can implicitly coordinate without their parent (Tabs) manually wiring props between them.Tab only works inside a Tabs provider. This is a deliberate tradeoff: you give up standalone reusability of the pieces in exchange for a much cleaner API at the call site.Tabs.Tab static-property convention is sugar, not enforcement. Nothing stops someone from importing Tab directly and rendering it outside Tabs — the null-context guard in the callout is the actual safety net, the namespacing is just discoverability.This is the same Context mechanism the Context lesson introduces for skipping prop drilling across distant layers — but redirected to solve implicit coordination between direct siblings instead. The provider and consumers here are typically only one level apart; the value being shared isn't a global concern like theme or auth, it's private state that only this specific family of components should ever read.