Week 11: Component Architecture & Composition Patterns

Week 1 introduced children as a way to wrap arbitrary content. This week goes further: patterns for building components that are genuinely flexible and reusable across a growing codebase, plus the folder structure that keeps hundreds of components navigable instead of a flat, tangled mess.

Module 8 of 13 Week 11 of 20 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Recognize when prop drilling signals a composition problem, not a state one
  • Build a compound component using Context internally
  • Organize a growing codebase by feature instead of by file type

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.

the drilling problem
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
}
fixed with composition
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 childrenLayout 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.

Tabs.tsx
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;
usage — reads like the DOM's own tag relationships
<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.

Where you've already seen this shape

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.

MouseTracker.tsx
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:

by type — scales badly
src/
├── components/
│   ├── ProductCard.tsx
│   ├── CartBadge.tsx
│   └── UserAvatar.tsx
├── hooks/
│   ├── useCart.ts
│   └── useUser.ts
└── api/
    ├── products.ts
    └── cart.ts
by feature — scales well
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:

PageHeader.tsx
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

Hands-on

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:

  1. Build Accordion, Accordion.Item, Accordion.Header and Accordion.Panel following the Tabs pattern from Section 2, using Context to share which item(s) are expanded.
  2. 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 to Accordion.Item/Header/Panel.
  3. Add correct ARIA attributes (aria-expanded on the header button, matching to the panel) so the pattern is accessible, not just visually functional.

Part 2 — Restructure by feature:

  1. 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.
  2. Write one sentence per folder explaining what belongs there — this is the test of whether the boundaries are actually clear, not just theoretically tidy.
Hint

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?

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?

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?

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?

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.