React Composition Patterns
Why Composition Over Inheritance
React discourages inheritance. Instead, compose behavior through:
- Children pattern — wrap arbitrary content
- Render props — pass rendering logic as a function
- Slots — named props for specific positions
Inversion of control: the parent decides what renders, the child provides the structure.
Children Pattern
// Card accepts any content
function Card({ title, children }) {
return (
<div className="card">
<h2>{title}</h2>
<div className="card-body">{children}</div>
</div>
);
}
// Usage
<Card title="Profile">
<Avatar user={user} />
<Bio text={user.bio} />
</Card>
Slots Pattern (Named Children)
function Layout({ header, sidebar, main, footer }) {
return (
<div className="layout">
<header>{header}</header>
<aside>{sidebar}</aside>
<main>{main}</main>
<footer>{footer}</footer>
</div>
);
}
// Usage
<Layout
header={<NavBar />}
sidebar={<SideNav />}
main={<ArticleList />}
footer={<Footer />}
/>
Compound Components
Components that work together and share implicit state:
const TabContext = createContext();
function Tabs({ children, defaultTab }) {
const [active, setActive] = useState(defaultTab);
return (
<TabContext.Provider value={{ active, setActive }}>
<div className="tabs">{children}</div>
</TabContext.Provider>
);
}
function TabList({ children }) {
return <div role="tablist">{children}</div>;
}
function Tab({ id, children }) {
const { active, setActive } = useContext(TabContext);
return (
<button
role="tab"
aria-selected={active === id}
onClick={() => setActive(id)}
>
{children}
</button>
);
}
function TabPanel({ id, children }) {
const { active } = useContext(TabContext);
return active === id ? <div role="tabpanel">{children}</div> : null;
}
// Usage
<Tabs defaultTab="general">
<TabList>
<Tab id="general">General</Tab>
<Tab id="privacy">Privacy</Tab>
</TabList>
<TabPanel id="general"><GeneralSettings /></TabPanel>
<TabPanel id="privacy"><PrivacySettings /></TabPanel>
</Tabs>
Render Props Pattern
Pass a function as a prop that receives data and returns JSX:
function DataFetcher({ url, render }) {
const { data, loading, error } = useFetch(url);
return render({ data, loading, error });
}
// Usage: consumer controls what renders
<DataFetcher
url="/api/users"
render={({ data, loading }) =>
loading ? <Spinner /> : <UserList users={data} />
}
/>
// Children-as-function (same pattern)
<DataFetcher url="/api/users">
{({ data, loading }) => loading ? <Spinner /> : <UserList users={data} />}
</DataFetcher>
Provider Pattern
Wrap a tree to inject context without prop drilling:
function ThemeProvider({ theme, children }) {
return (
<ThemeContext.Provider value={theme}>
{children}
</ThemeContext.Provider>
);
}
// Any depth component can consume
function Button() {
const theme = useContext(ThemeContext);
return <button className={`btn-${theme}`}>Click</button>;
}
When to Use Each Pattern
| Pattern | When | Example |
|---|---|---|
| Children | Arbitrary nested content | Card, Modal, Layout |
| Slots | Specific named regions | Page layout, Dialog |
| Compound | Related components sharing state | Tabs, Accordion, Form |
| Render Props | Consumer controls rendering | Data fetcher, Intersection Observer |
| Provider | Cross-tree state without drilling | Theme, Auth, Cart |
Content from Frontend-Master-Prep-Series — 03-react