Composition solves most prop drilling without Context, by passing rendered elements instead of the data to render them.

Bishal Biswas
Self Learner
Prop drilling
function App() {
const [user, setUser] = useState(null);
return <Layout user={user} />;
}
function Layout({ user }) {
return <Sidebar user={user} />;
}
function Sidebar({ user }) {
return <UserPanel user={user} />;
}
function UserPanel({ user }) {
return <p>{user.name}</p>;
}
Layout and Sidebar do not use user. They only pass it along.
This is annoying rather than fatal. But it means adding a field touches four files, and every intermediate component is coupled to data it does not care about.
The instinct is to reach for Context. Often there is a simpler answer.
Composition
Instead of passing the data down, pass the rendered element down.
function App() {
const [user, setUser] = useState(null);
return (
<Layout sidebar={<UserPanel user={user} />} />
);
}
function Layout({ sidebar }) {
return (
<div className="layout">
<aside>{sidebar}</aside>
<main>Content</main>
</div>
);
}
function UserPanel({ user }) {
return <p>{user.name}</p>;
}
Layout no longer knows user exists. It receives something to render and puts it in a slot.
The chain went from four components knowing about user to two: the one with the data and the one displaying it.
Why this works
JSX is a value. <UserPanel user={user} /> is created where user is in scope, so it already has what it needs by the time Layout receives it.
Layout becomes genuinely reusable, because it makes no assumptions about its contents.
Using children
The same idea with the most common prop of all.
function Card({ title, children }) {
return (
<div className="card">
<h2>{title}</h2>
<div className="card-body">{children}</div>
</div>
);
}
function Dashboard() {
const [stats, setStats] = useState(null);
return (
<Card title="Statistics">
<StatsChart stats={stats} />
</Card>
);
}
Card never hears about stats.
Several slots
function PageLayout({ header, sidebar, children, footer }) {
return (
<div className="page">
<header>{header}</header>
<div className="body">
<aside>{sidebar}</aside>
<main>{children}</main>
</div>
<footer>{footer}</footer>
</div>
);
}
function ProductPage({ product }) {
return (
<PageLayout
header={<Breadcrumbs product={product} />}
sidebar={<CategoryNav categoryId={product.categoryId} />}
footer={<RelatedProducts ids={product.relatedIds} />}
>
<ProductDetail product={product} />
</PageLayout>
);
}
PageLayout is pure structure. Every piece of data stays with the component that needs it.
Compound components
For a group that shares state, expose the parts and coordinate through Context internally.
const TabsContext = createContext(null);
function Tabs({ defaultTab, children }) {
const [active, setActive] = useState(defaultTab);
const value = useMemo(() => ({ active, setActive }), [active]);
return <TabsContext value={value}>{children}</TabsContext>;
}
function TabList({ children }) {
return <div role="tablist">{children}</div>;
}
function Tab({ id, children }) {
const { active, setActive } = useContext(TabsContext);
return (
<button role="tab" aria-selected={active === id} onClick={() => setActive(id)}>
{children}
</button>
);
}
function TabPanel({ id, children }) {
const { active } = useContext(TabsContext);
if (active !== id) return null;
return <div role="tabpanel">{children}</div>;
}
Tabs.List = TabList;
Tabs.Tab = Tab;
Tabs.Panel = TabPanel;
function Settings() {
return (
<Tabs defaultTab="profile">
<Tabs.List>
<Tabs.Tab id="profile">Profile</Tabs.Tab>
<Tabs.Tab id="billing">Billing</Tabs.Tab>
</Tabs.List>
<Tabs.Panel id="profile"><ProfileForm /></Tabs.Panel>
<Tabs.Panel id="billing"><BillingForm /></Tabs.Panel>
</Tabs>
);
}
The caller controls the markup and ordering completely, while the shared state stays hidden. Compare a <Tabs items={[...]} /> API, which would need a new prop for every customisation anyone ever wants.
Render props
Pass a function that returns markup, when the child needs to supply data to the parent’s rendering.
function MousePosition({ children }) {
const [position, setPosition] = useState({ x: 0, y: 0 });
useEffect(() => {
function handleMove(event) {
setPosition({ x: event.clientX, y: event.clientY });
}
window.addEventListener('mousemove', handleMove);
return () => window.removeEventListener('mousemove', handleMove);
}, []);
return children(position);
}
function App() {
return (
<MousePosition>
{({ x, y }) => <p>Mouse at {x}, {y}</p>}
</MousePosition>
);
}
Custom hooks replaced most uses of this. useMousePosition() is simpler. Render props still make sense when the shared thing is markup rather than logic.
Composition or Context?
Composition when the data has a clear owner and a clear destination. It keeps everything explicit and traceable, with no hidden dependencies.
Context when the value is genuinely app wide and read from many unrelated places: the theme, the current user, the language.
The mistake is reaching for Context first. Try composition, and if the structure still fights you, then add Context.
The limit
Composition does not help when a component deep inside a list needs a value.
function App() {
const { theme } = useTheme();
return (
<ProductGrid>
{products.map((p) => (
<ProductCard key={p.id} product={p} theme={theme} />
))}
</ProductGrid>
);
}
If every nested piece needs the theme, you are back to drilling. That is exactly the case Context exists for.
Quick recap
- Prop drilling passes data through components that do not use it.
- Composition passes the rendered element instead, so the middle never sees the data.
- JSX is a value, so it can be any prop, giving you slots.
childrenis the most common slot.- Compound components expose parts and share state internally.
- Render props still suit sharing markup, but hooks replaced most uses.
- Try composition before Context, and use Context for genuinely global values.
import { createContext, useContext, useState, useMemo } from 'react';
// PROP DRILLING: Layout and Sidebar do not use `user`, they just carry it
function Drilled({ user }) {
return <Layout user={user} />; // -> Sidebar -> UserPanel
}
// COMPOSITION: pass the rendered ELEMENT, not the data. Layout no longer
// knows `user` exists at all, and becomes genuinely reusable.
function PageLayout({ header, sidebar, children, footer }) {
return (
<div className="page">
<header>{header}</header>
<div className="body">
<aside>{sidebar}</aside>
<main>{children}</main>
</div>
<footer>{footer}</footer>
</div>
);
}
function ProductPage({ product, user }) {
// Every element is created HERE, where the data is in scope
return (
<PageLayout
header={<Breadcrumbs product={product} />}
sidebar={<UserPanel user={user} />}
footer={<RelatedProducts ids={product.relatedIds} />}
>
<ProductDetail product={product} />
</PageLayout>
);
}
// COMPOUND COMPONENTS: expose the parts, hide the shared state.
// The caller controls the markup completely.
const TabsContext = createContext(null);
function Tabs({ defaultTab, children }) {
const [active, setActive] = useState(defaultTab);
const value = useMemo(() => ({ active, setActive }), [active]);
return <TabsContext value={value}>{children}</TabsContext>;
}
Tabs.List = function TabList({ children }) {
return <div role="tablist">{children}</div>;
};
Tabs.Tab = function Tab({ id, children }) {
const { active, setActive } = useContext(TabsContext);
return (
<button role="tab" aria-selected={active === id} onClick={() => setActive(id)}>
{children}
</button>
);
};
Tabs.Panel = function TabPanel({ id, children }) {
const { active } = useContext(TabsContext);
return active === id ? <div role="tabpanel">{children}</div> : null;
};
export default function Settings() {
return (
<Tabs defaultTab="profile">
<Tabs.List>
<Tabs.Tab id="profile">Profile</Tabs.Tab>
<Tabs.Tab id="billing">Billing</Tabs.Tab>
</Tabs.List>
<Tabs.Panel id="profile"><ProfileForm /></Tabs.Panel>
<Tabs.Panel id="billing"><BillingForm /></Tabs.Panel>
</Tabs>
);
}
// Try COMPOSITION first. Reach for Context only when a value is genuinely
// app wide and read from many unrelated places, or when the components
// that need it are buried inside a list you are mapping over.
Was this explanation helpful?
Your feedback helps us improve the content for everyone.
Thank you for your feedback!
We appreciate you helping us improve.
What could we improve? (Optional)
Read aloud
Ready