🧩 Component Composition Patterns
React gives you a small set of primitives and expects you to combine them into something bigger. Composition is how you do that well — assembling focused components into rich interfaces without deep inheritance, brittle configuration props, or copy-pasted markup. Master these patterns and your components become genuinely reusable.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Explain why React favors composition over inheritance for sharing UI
- Use the containment pattern with the
childrenprop to build flexible wrappers - Pass named regions of UI through props (slots) when a component needs more than one hole to fill
- Build a compound component family that shares state through Context
- Apply the render-props pattern to share behavior, and know when a custom hook is the better choice
Estimated Time: 35–45 minutes • Difficulty: Intermediate
Hands-on: Build a reusable <Modal> with a compound Modal.Header / Modal.Body / Modal.Footer API.
In This Lesson
What Is Composition?
Component composition is the practice of building complex interfaces by combining smaller, single-purpose components. Instead of one giant component that knows everything, you assemble a tree of focused pieces — each doing one job well and handing off the rest to its neighbors.
💡 A useful analogy: Composition is like building with LEGO. Each brick is dumb on its own, but the studs-and-sockets connection is standardized, so you can snap bricks together in endless combinations. In React, the "stud" is the children prop and the "socket" is the JSX you nest inside a component.
Every non-trivial React app is a composition tree. A page is made of sections, sections of cards, cards of buttons and text. The skill you're building here is designing components whose "sockets" are in the right places so the pieces snap together cleanly.
Composition vs. Inheritance
In many object-oriented languages you'd share UI by extending a base class. React deliberately steers you away from that. The official guidance is blunt: use composition instead of inheritance to reuse code between components.
Why? Inheritance couples a child to the exact shape of its parent. Change the base and every subclass can break. Composition couples components only through props — a narrow, explicit contract. The benefits stack up:
- Reusability — a well-composed component drops into many contexts unchanged.
- Separation of concerns — each component owns one responsibility.
- Maintainability — small components are easier to read, test, and refactor.
- Flexibility — callers combine pieces in ways you never anticipated.
🏭 Real-world analogy: Think of a car assembly line. No one builds a whole car in one step. Specialized teams produce the engine, the transmission, the body panels — each tested independently — and the line assembles them. The same engine ships in three different models. That reuse is only possible because the parts connect through standard interfaces, not because one part inherits from another.
⚠️ The "prop explosion" smell
If a component grows a dozen boolean props (showHeader, hideFooter, withBorder, compact…) to cover every variation, that's a sign it should accept composed children instead of configuration flags. Let callers pass the UI they want rather than toggling it on and off.
The Containment Pattern (children)
Some components don't know their children ahead of time — dialogs, cards, sidebars, layout shells. These "generic boxes" use the special children prop to render whatever JSX the caller nests inside them, exactly like a built-in <div> does.
A reusable Card component
// Card.jsx
function Card({ title, children }) {
return (
<div className="card">
{title && <div className="card-header">{title}</div>}
<div className="card-body">{children}</div>
</div>
);
}
// Usage — the Card owns structure & style; the caller owns content
function App() {
return (
<>
<Card title="User Profile">
<h3>Jane Doe</h3>
<p>Software Engineer</p>
<button>View Profile</button>
</Card>
<Card title="Weather">
<h3>San Francisco</h3>
<p>68°F, Partly Cloudy</p>
</Card>
</>
);
}
The Card is responsible for the frame — padding, border, header styling — while the content stays entirely in the caller's hands. One component, unlimited uses.
📖 Key Terms
children: a built-in prop holding whatever you nest between a component's opening and closing tags. It can be text, one element, many elements, or even a function.
Containment: a component rendering unknown children inside a known frame.
Fragment (<>…</>): groups siblings without adding an extra DOM node.
Every major component library — Material UI, Chakra UI, Radix, shadcn/ui — leans on containment for its cards, dialogs, and layout primitives. It is the workhorse pattern of React composition.
Slots: Multiple Named Holes
Sometimes one children hole isn't enough. A page layout might need a distinct header, sidebar, and content region. Because JSX elements are just values, you can pass entire chunks of UI through ordinary props — the React equivalent of "named slots."
function SplitLayout({ sidebar, children }) {
return (
<div className="split">
<aside className="split-sidebar">{sidebar}</aside>
<main className="split-main">{children}</main>
</div>
);
}
// Each slot receives its own tree of JSX
function App() {
return (
<SplitLayout sidebar={<NavMenu items={links} />}>
<Dashboard />
</SplitLayout>
);
}
Here sidebar is a slot filled with a whole <NavMenu>, while children holds the main area. This keeps SplitLayout layout-only and completely ignorant of what goes in each region.
💡 children is just a prop
Writing <Box>hi</Box> is identical to <Box children="hi" />. Once you internalize that JSX is data you can store, pass, and render on demand, slots stop feeling like magic.
Compound Components
The most powerful composition pattern is the compound component: a family of components designed to work together, sharing implicit state through React Context. The classic example is a set of tabs. The caller writes clean, declarative markup; the components coordinate behind the scenes.
import { createContext, useContext, useState } from 'react';
const TabsContext = createContext(null);
function Tabs({ defaultTab, children }) {
const [activeTab, setActiveTab] = useState(defaultTab);
return (
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
<div className="tabs">{children}</div>
</TabsContext.Provider>
);
}
function useTabs() {
const ctx = useContext(TabsContext);
if (!ctx) throw new Error('Tab components must be used inside <Tabs>');
return ctx;
}
function TabList({ children }) {
return <div className="tab-list" role="tablist">{children}</div>;
}
function Tab({ value, children }) {
const { activeTab, setActiveTab } = useTabs();
const selected = activeTab === value;
return (
<button
role="tab"
aria-selected={selected}
className={selected ? 'tab active' : 'tab'}
onClick={() => setActiveTab(value)}
>
{children}
</button>
);
}
function TabPanel({ value, children }) {
const { activeTab } = useTabs();
if (value !== activeTab) return null;
return <div role="tabpanel" className="tab-panel">{children}</div>;
}
// Expose the family as properties of the parent for a clean namespace
Tabs.List = TabList;
Tabs.Tab = Tab;
Tabs.Panel = TabPanel;
export default Tabs;
Now the usage reads almost like plain HTML — no wiring, no manual state plumbing:
function Settings() {
return (
<Tabs defaultTab="profile">
<Tabs.List>
<Tabs.Tab value="profile">Profile</Tabs.Tab>
<Tabs.Tab value="account">Account</Tabs.Tab>
<Tabs.Tab value="alerts">Alerts</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="profile"><ProfileForm /></Tabs.Panel>
<Tabs.Panel value="account"><AccountForm /></Tabs.Panel>
<Tabs.Panel value="alerts"><AlertSettings /></Tabs.Panel>
</Tabs>
);
}
✅ Why Context, not prop drilling?
The active tab lives in Tabs, but Tab and TabPanel may be nested arbitrarily deep. Threading activeTab down through every intermediate element would be painful and fragile. Context lets any descendant read and update the shared state directly. This is exactly how Radix UI, Headless UI, and React Aria build accessible compound components.
Render Props & Function Children
What if a component owns some behavior — tracking the mouse, watching a media query, managing a toggle — but shouldn't dictate how that behavior is displayed? The render-props pattern solves this by letting children be a function that receives the state and returns the UI.
import { useState } from 'react';
// Owns "open/closed" behavior; renders nothing itself
function Toggle({ children }) {
const [on, setOn] = useState(false);
const toggle = () => setOn(prev => !prev);
return children({ on, toggle });
}
// The caller decides exactly how to render the state
function App() {
return (
<Toggle>
{({ on, toggle }) => (
<>
<button onClick={toggle}>{on ? 'Hide' : 'Show'} details</button>
{on && <p>Here are the details!</p>}
</>
)}
</Toggle>
);
}
⚠️ Reach for a custom hook first
Render props were the go-to way to share stateful logic before Hooks existed. Today, a custom hook — const { on, toggle } = useToggle() — usually does the same job with less nesting and no "wrapper hell." Render props still shine when the shared thing must live in the tree (e.g. an <AutoSizer> that measures its own DOM box, or virtualized-list libraries). You'll build custom hooks in the very next lesson.
Hands-on: A Composable Modal
🏋️ Build a Modal with a compound API
Objective: Create a <Modal> that uses containment for its body and exposes Modal.Header, Modal.Body, and Modal.Footer so callers can compose exactly the dialog they need.
Requirements:
- A full-screen backdrop; clicking it calls
onClose. - A centered content box that does not close when clicked (stop propagation).
isOpencontrols visibility; returnnullwhen closed.- Sub-components
Modal.Header,Modal.Body,Modal.Footerfor structured regions.
Start from this skeleton:
function Modal({ isOpen, onClose, children }) {
if (!isOpen) return null;
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
{children}
</div>
</div>
);
}
// TODO: add Modal.Header, Modal.Body, Modal.Footer
💡 Hint
The sub-components can be trivial containment wrappers — each just renders children inside a styled <div>. Attach them to the parent function so they share a namespace: Modal.Header = function Header({ children }) { … }. You don't need Context here because the sections don't share state; containment alone is enough.
✅ Sample solution
function Modal({ isOpen, onClose, children }) {
if (!isOpen) return null;
return (
<div className="modal-backdrop" onClick={onClose}>
<div
className="modal-content"
role="dialog"
aria-modal="true"
onClick={(e) => e.stopPropagation()}
>
{children}
</div>
</div>
);
}
Modal.Header = function Header({ children }) {
return <div className="modal-header">{children}</div>;
};
Modal.Body = function Body({ children }) {
return <div className="modal-body">{children}</div>;
};
Modal.Footer = function Footer({ children }) {
return <div className="modal-footer">{children}</div>;
};
// Usage
function ConfirmDialog({ open, onClose, onConfirm }) {
return (
<Modal isOpen={open} onClose={onClose}>
<Modal.Header><h2>Delete project?</h2></Modal.Header>
<Modal.Body><p>This action cannot be undone.</p></Modal.Body>
<Modal.Footer>
<button onClick={onClose}>Cancel</button>
<button className="danger" onClick={onConfirm}>Delete</button>
</Modal.Footer>
</Modal>
);
}
Going further: render the modal into a portal with createPortal(…, document.body) so it escapes overflow-clipping ancestors, and close it on the Escape key with a useEffect listener.
Best Practices
✅ Do
- Prefer
childrenand slots over piles of boolean configuration props. - Keep components single-purpose; split when one starts juggling two jobs.
- Use compound components + Context for families that share state (tabs, accordions, menus).
- Throw a clear error when a compound child is used outside its provider.
- Forward
...restprops andrefon low-level wrappers so callers can extend them.
❌ Don't
- Don't reach for class inheritance to share UI — compose instead.
- Don't drill one piece of state through five layers of props when Context fits.
- Don't over-engineer: a simple component that's used once doesn't need a compound API.
- Don't mutate or reach into
childrenunless you truly needReact.Children/cloneElement— it's a last resort.
🎯 Quick Quiz
Question 1: Which prop lets a component render whatever JSX a caller nests inside its tags?
Question 2: A compound component family (like Tabs, Tabs.Tab, Tabs.Panel) typically shares its state through what?
Question 3: Today, what usually replaces the render-props pattern for sharing stateful logic?
Summary & Quiz
🎉 Key Takeaways
- Composition beats inheritance in React — combine focused components through props, not class hierarchies.
- The containment pattern uses
childrento build flexible wrappers like cards and dialogs. - Slots pass named regions of UI through ordinary props when one hole isn't enough.
- Compound components share implicit state via Context for a clean, declarative API.
- Render props share behavior via a function child — but custom hooks are usually the modern choice.
📚 Further Reading
- React docs — Passing JSX as children
- React docs — Passing data deeply with Context
- Kent C. Dodds — Compound components with React Hooks
🚀 What's Next?
You've seen how components fit together. Next we'll extract the logic those components share into reusable custom hooks — the modern answer to the render-props question we raised above.
🎉 Great work!
You can now design components that snap together. Let's make their logic just as reusable.