Concept
The beginner framing: beyond individual hooks and components, React has a handful of well-established patterns for structuring related pieces of UI or sharing behavior across components, compound components, render props, and higher-order components (HOCs), each solving similar problems in a different shape.
The precise mental model: all three patterns exist to answer some version of "how do multiple pieces of UI coordinate shared state or behavior without the consumer having to manage it explicitly." They emerged in a rough historical order, and understanding why each one exists, and which problems hooks have since made unnecessary, is what separates recognizing a pattern from actually knowing when to reach for it.
Compound components: implicit coordination via Context
const TabsContext = createContext(null);
function Tabs({ children, defaultTab }) {
const [activeTab, setActiveTab] = useState(defaultTab);
return <TabsContext.Provider value={{ activeTab, setActiveTab }}>{children}</TabsContext.Provider>;
}
function TabList({ children }) {
return <div className="tab-list">{children}</div>;
}
function Tab({ id, children }) {
const { activeTab, setActiveTab } = useContext(TabsContext);
return (
<button className={activeTab === id ? "active" : ""} onClick={() => setActiveTab(id)}>
{children}
</button>
);
}
// Usage, the pieces coordinate IMPLICITLY, without the consumer wiring any state themselves:
<Tabs defaultTab="profile">
<TabList>
<Tab id="profile">Profile</Tab>
<Tab id="settings">Settings</Tab>
</TabList>
</Tabs>Tabs, TabList, and Tab are separate components that share state through Context (see Context API) behind the scenes, the consumer just nests them, without ever touching activeTab directly. This is the right shape for genuinely multi-part, tightly related UI: a set of pieces that only make sense together (Tabs/Tab, Accordion/AccordionItem, Select/Option).
Render props: a prop that's a function
function MouseTracker({ children }) {
const [position, setPosition] = useState({ x: 0, y: 0 });
return (
<div onMouseMove={(e) => setPosition({ x: e.clientX, y: e.clientY })}>
{children(position)} {/* the CONSUMER decides what to render with this data */}
</div>
);
}
<MouseTracker>{(pos) => <p>{pos.x}, {pos.y}Before hooks existed, this was the primary way to share stateful behavior (without the UI itself) across components, the consumer supplies a function that receives the shared state and returns whatever JSX it wants. Custom hooks (see Custom Hooks) now solve the same problem more directly, const position = useMouseTracker();, without the extra nesting a render prop requires.
Higher-order components (HOCs): wrapping a component to inject behavior
function withLoading(Component) {
return function WithLoading({ isLoading, ...props }) {
if (isLoading) return <Spinner />;
return <Component {...props} />;
};
}
const UserProfileWithLoading = withLoading(UserProfile);A HOC is a function that takes a component and returns a new, wrapped one with added behavior. Like render props, this predates hooks and was the standard way to share cross-cutting behavior (loading states, auth checks) across many components, largely superseded today by a custom hook handling the same logic without an extra wrapper component in the tree.
Try It
Predict what happens before checking the solution.
function Tabs({ children }) {
const [activeTab, setActiveTab] = useState("a");
return <TabsContext.Provider value={{ activeTab, setActiveTab }}>{children}</TabsContext.Provider>;
}
function Tab({ id, children }) {
const { activeTab } = useContext(TabsContext);
return <span>{activeTab === id ? "ACTIVE: " : ""}{children}</span>;
What happens when Tab is rendered without a Tabs ancestor providing the context?
Solution
useContext(TabsContext) returns whatever default value createContext was given (often null), so destructuring { activeTab } from it either produces undefined (if the default was an object shape) or throws if the default itself is null and you try to destructure a property from it. This is exactly why compound components are usually built defensively, either providing a sensible default context value, or throwing a clear, deliberate error ("Tab must be used within a Tabs") if rendered without its required ancestor, rather than failing with a confusing generic error.
Implement It Yourself
Build a minimal compound component with a deliberate guard against misuse:
const TabsContext = createContext(null);
function useTabsContext() {
const context = useContext(TabsContext);
if (!context) {
throw new Error("Tab and TabList must be used within a <Tabs> component"); // clear, deliberate error
}
return context;
}
function Tabs({ children, defaultTab }) {
const [activeTab, setActiveTab] = useState(defaultTab);
return <TabsContext.Provider value={{ activeTab, setActiveTab }}>{children
useTabsContext is itself a small custom hook (see Custom Hooks), this is a common, deliberate combination: compound components for the coordination shape, a custom hook wrapping useContext for a clear failure mode when the pieces are used incorrectly.
Under the Hood
Compound components are a direct, applied use of Context API, the "implicit coordination" is just a Context Provider and several consumers, dressed up as a friendlier component API. Render props and HOCs are React's earlier answers to the same problem Custom Hooks solves more directly today: sharing stateful logic across components, the historical shift from render-props/HOCs to hooks is essentially the same "extract reusable behavior into a function" idea, just without the extra function-as-child or wrapper-component indirection those older patterns required.
Common Mistakes
1. Reaching for a HOC or render prop where a custom hook would be simpler
const EnhancedComponent = withAuth(withTheme(withLoading(BaseComponent))); // "wrapper hell", hard to traceStacking multiple HOCs (or nesting multiple render props) makes the resulting component tree harder to inspect and debug, each wrapper adds an extra layer with its own props-forwarding behavior to reason about. A custom hook accomplishes the same behavior-sharing without adding any wrapper component to the tree at all.
2. Making a compound component too rigid about JSX ordering
function Tabs({ children }) {
const tabList = children[0]; // ❌ assumes children are in an EXACT expected order/shape
const panels = children[1];
}Relying on children's exact positional shape makes a compound component brittle to reasonable reordering. Prefer Context (as shown above) so sub-components can be nested and ordered flexibly, coordinating through shared state rather than positional assumptions.
3. Building a "controlled or uncontrolled" component without following the undefined-triggers-default convention
function Accordion({ activeId, onActiveChange }) {
const [internalActive, setInternalActive] = useState(null);
const isControlled = activeId !== undefined; // ✅ correct check
// ...
}Generalizing the controlled/uncontrolled choice (see Forms) to a custom component means following the same convention props defaults already use (see Props): undefined means "uncontrolled, manage it internally," any other value (including null) means "controlled, the parent owns it."
Best Practices
- Prefer custom hooks over HOCs/render props for new code, they compose more simply and don't add wrapper components to the tree (see Custom Hooks).
- Reserve compound components for genuinely multi-part, tightly related UI, pieces that don't make sense independently (
Tabs/Tab,Select/Option), not as a default structuring choice for unrelated components. - Guard a compound component's internal context access with a custom hook that throws a clear error when used outside its required ancestor, rather than failing with a confusing generic error.
- Support both controlled and uncontrolled modes for reusable component-library components specifically, following the same
undefined-triggers-default convention established for props generally.
Performance Tips
- HOCs that don't forward
refs or props carefully can silently break a wrapped component's compatibility withReact.memoorforwardRefusage further up the tree, an underappreciated cost of the pattern beyond just readability. - Compound components built on Context inherit Context's all-consumers-re-render behavior (see Context API), for a compound component with many sub-parts and frequent state changes, the same profiling and splitting considerations from that topic still apply.
