1. Prop Drilling vs. Composition
Not every prop-drilling problem needs Week 10's global state — often the real fix is restructuring how components are composed, so data doesn't need to pass through layers that don't use it at all.
function Page({ user }: { user: User }) {
return <Layout user={user} />; // Layout doesn't use user -- just forwards it
}
function Layout({ user }: { user: User }) {
return <Sidebar user={user} />; // Sidebar doesn't use it either
}
function Sidebar({ user }: { user: User }) {
return <UserBadge user={user} />; // finally used here
}
function Page({ user }: { user: User }) {
return (
<Layout>
<Sidebar>
<UserBadge user={user} /> {/* passed directly, skipping the middlemen */}
</Sidebar>
</Layout>
);
}
function Layout({ children }: { children: React.ReactNode }) {
return <div className="layout">{children}</div>; // no user prop needed at all
}
function Sidebar({ children }: { children: React.ReactNode }) {
return <aside>{children}</aside>; // no user prop needed at all
}
Page constructs the already-assembled <UserBadge user={user} />
and passes it down as children — Layout and
Sidebar never see user at all, because they never needed
it in the first place. This is often a better first move than reaching for Context or
Zustand: ask whether the intermediate components genuinely need the data, or are just
passing it through.
2. Compound Components
A compound component is a set of components designed to be used
together, sharing implicit state via Context internally so the consumer never has to
wire that state up themselves. Think of how a native <select> and
<option> work together — that's the shape this pattern reproduces
in React.
import { createContext, useContext, useState, type ReactNode } from 'react';
const TabsContext = createContext<{
activeTab: string;
setActiveTab: (id: string) => void;
} | null>(null);
function useTabsContext() {
const context = useContext(TabsContext);
if (!context) throw new Error('Tabs.* components must be used inside <Tabs>');
return context;
}
function Tabs({ defaultTab, children }: { defaultTab: string; children: ReactNode }) {
const [activeTab, setActiveTab] = useState(defaultTab);
return (
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
<div className="tabs">{children}</div>
</TabsContext.Provider>
);
}
function TabList({ children }: { children: ReactNode }) {
return <div role="tablist">{children}</div>;
}
function Tab({ id, children }: { id: string; children: ReactNode }) {
const { activeTab, setActiveTab } = useTabsContext();
return (
<button
role="tab"
aria-selected={activeTab === id}
onClick={() => setActiveTab(id)}
>
{children}
</button>
);
}
function TabPanel({ id, children }: { id: string; children: ReactNode }) {
const { activeTab } = useTabsContext();
if (activeTab !== id) return null;
return <div role="tabpanel">{children}</div>;
}
Tabs.List = TabList;
Tabs.Tab = Tab;
Tabs.Panel = TabPanel;
export default Tabs;
<Tabs defaultTab="profile">
<Tabs.List>
<Tabs.Tab id="profile">Profile</Tabs.Tab>
<Tabs.Tab id="settings">Settings</Tabs.Tab>
</Tabs.List>
<Tabs.Panel id="profile">Profile content</Tabs.Panel>
<Tabs.Panel id="settings">Settings content</Tabs.Panel>
</Tabs>
The consumer never manages activeTab state themselves, never passes an
onTabChange callback around, and never has to know how Tab
and Panel communicate — that's entirely internal, hidden behind the
shared context. Tabs.Tab = Tab is just attaching the sub-components as
properties of Tabs, purely for the readable Tabs.Tab naming
at the call site.
This is exactly the same internal-Context trick behind Week 10's ThemeContext, applied to a single component's local state instead of app-wide state. The pattern is the same; only the scope of what's being shared changes.
3. Render Props / Function-as-Children
Before hooks existed, sharing logic across components (Week 4's job today) was often done with a render prop: a component that calls a function prop to produce its output, instead of rendering fixed markup. You'll see it far less often now, but it still appears in some libraries and is worth recognizing on sight.
function MouseTracker({
children,
}: {
children: (position: { x: number; y: number }) => ReactNode;
}) {
const [position, setPosition] = useState({ x: 0, y: 0 });
return (
<div onMouseMove={(e) => setPosition({ x: e.clientX, y: e.clientY })}>
{children(position)} {/* children is a FUNCTION, called with the current state */}
</div>
);
}
// Usage
<MouseTracker>
{(position) => <p>Mouse at {position.x}, {position.y}</p>}
</MouseTracker>
This is largely superseded by custom hooks today — a useMousePosition()
hook (Week 4) gives the same reusable logic with less nesting and no function-as-prop
indirection. The main place render props still show up is in some UI libraries that
predate widespread hooks adoption, or where a library needs to control exactly
when your render logic runs (not just supply data to it).
4. Feature-Based Folder Structure
Organizing files by type (one giant components/,
hooks/, utils/ folder) scales badly — related code for one
feature ends up scattered across four unrelated top-level folders. A
feature-based structure groups everything a feature needs together:
src/
├── components/
│ ├── ProductCard.tsx
│ ├── CartBadge.tsx
│ └── UserAvatar.tsx
├── hooks/
│ ├── useCart.ts
│ └── useUser.ts
└── api/
├── products.ts
└── cart.ts
src/
├── features/
│ ├── cart/
│ │ ├── components/CartBadge.tsx
│ │ ├── useCart.ts
│ │ └── api.ts
│ └── products/
│ ├── components/ProductCard.tsx
│ ├── useProducts.ts
│ └── api.ts
└── shared/
├── components/ # Button, Card -- used across multiple features
└── hooks/ # useLocalStorage, useDebouncedValue -- generic, not feature-specific
Everything cart-related lives under features/cart/ — deleting the
feature means deleting one folder, not hunting across four. Only genuinely
cross-feature code (Week 4's useLocalStorage, a generic
Button) belongs in shared/; the moment something feels
feature-specific, it belongs inside that feature's own folder, not in a shared bucket
"just in case."
5. Slot-Style Props for Flexible Layout
Sometimes a component needs more than one flexible content area — not just the single
children slot. Passing whole elements as named props gives you that,
without inventing new configuration options for every possible layout variant:
interface PageHeaderProps {
title: string;
actions?: ReactNode; // a "slot" for arbitrary right-aligned content
breadcrumb?: ReactNode;
}
function PageHeader({ title, actions, breadcrumb }: PageHeaderProps) {
return (
<header className="page-header">
{breadcrumb}
<div className="page-header__row">
<h1>{title}</h1>
{actions}
</div>
</header>
);
}
// Usage -- each page supplies exactly what it needs, PageHeader stays generic
<PageHeader
title="Products"
breadcrumb={<Breadcrumb items={['Home', 'Products']} />}
actions={<button>Add Product</button>}
/>
PageHeader never needs to know what goes in actions — a
button, a group of buttons, nothing at all — it just renders whatever
ReactNode it's handed in that position. This scales far better than a
component with a dozen boolean props (showAddButton,
showDeleteButton...) trying to anticipate every possible combination in
advance.
6. Hands-on Exercise
Build a compound Accordion, and restructure a project by feature
Apply this week's two big ideas — compound components and feature-based structure — to real code.
Part 1 — Compound Accordion:
- Build
Accordion,Accordion.Item,Accordion.HeaderandAccordion.Panelfollowing theTabspattern from Section 2, using Context to share which item(s) are expanded. - Support two modes via a prop on
Accordion:allowMultiple(several items open at once) vs. single-open (opening one closes any other) — both driven by the same internal Context, no change needed toAccordion.Item/Header/Panel. - Add correct ARIA attributes (
aria-expandedon the header button, matching to the panel) so the pattern is accessible, not just visually functional.
Part 2 — Restructure by feature:
- Take any multi-component project you've built in a previous week (the Week 9 todo app is a good candidate) and reorganize its files into a
features/+shared/structure following Section 4. - Write one sentence per folder explaining what belongs there — this is the test of whether the boundaries are actually clear, not just theoretically tidy.
For single-open mode, storing expandedId: string | null in the context (rather than a set of open IDs) makes "opening one closes the others" the natural default — allowMultiple mode then needs a Set<string> instead. Consider whether both modes can share one internal shape, or genuinely need two.
7. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
Before reaching for Context or a global store to fix prop drilling, what should you check first?
Before reaching for Context or a global store to fix prop drilling, what should you check first?
Whether the intermediate components actually need the data at all, or are only forwarding it to a descendant. If they don't need it, restructuring the composition — constructing the final element higher up and passing it down as children — removes the drilling entirely, without introducing any new state-sharing mechanism.
Q2
In the Tabs compound component, how do Tabs.Tab and Tabs.Panel communicate which tab is active, without the consumer wiring anything up?
In the Tabs compound component, how do Tabs.Tab and Tabs.Panel communicate which tab is active, without the consumer wiring anything up?
They share a TabsContext created and provided by the parent Tabs component. Clicking a Tab calls setActiveTab from that shared context, and every Panel reads the same activeTab value to decide whether to render — all invisible to whoever is using <Tabs> in their own code.
Q3
What has largely replaced the render-props pattern in modern React code?
What has largely replaced the render-props pattern in modern React code?
Custom hooks (Week 4). Both patterns exist to share stateful logic across components, but a custom hook achieves it without the extra nesting and function-as-child indirection a render prop requires — const position = useMousePosition() is simpler to read and use than wrapping your JSX in a component that calls a render function.
Q4
In a feature-based folder structure, what's the test for whether something belongs in shared/ vs. inside a specific feature folder?
In a feature-based folder structure, what's the test for whether something belongs in shared/ vs. inside a specific feature folder?
Whether it's genuinely generic and used across multiple unrelated features (a Button, a useLocalStorage hook) versus specific to one feature's own concerns (a CartBadge, a useCart hook). Code that's only ever used by one feature belongs inside that feature's folder, even if it feels reusable in theory — moving it to shared/ only once a second feature actually needs it avoids a shared folder full of things nothing else uses.