Five years of React. Hundreds of components. Dozens of refactors. And the lesson I keep re-learning? How you structure your components matters more than what's inside them.
In this post, I'll walk through four patterns I use regularly in production today — with real examples, their trade-offs, and when not to use them.
1. Compound Components
This is the pattern that changed how I think about component APIs entirely.
The Problem
You're building a <Select> or a <Tabs> component. You start with props:
<Tabs
items={['Overview', 'Details', 'Reviews']}
activeIndex={0}
onSelect={handleSelect}
renderContent={(index) => <div>{content[index]}</div>}
/>
Two weeks later, a designer wants custom icons in the tab labels. Then someone needs a tab to be disabled. Then a badge count. Before long you have 15 props, a messy renderX prop API, and a component nobody wants to touch.
The Pattern
Compound components let the consumer control structure while the parent manages state:
// Usage — clean, readable, extensible
<Tabs defaultIndex={0}>
<Tabs.List>
<Tabs.Tab>Overview</Tabs.Tab>
<Tabs.Tab disabled>Details <Badge>New</Badge></Tabs.Tab>
<Tabs.Tab>Reviews</Tabs.Tab>
</Tabs.List>
<Tabs.Panels>
<Tabs.Panel><Overview /></Tabs.Panel>
<Tabs.Panel><Details /></Tabs.Panel>
<Tabs.Panel><Reviews /></Tabs.Panel>
</Tabs.Panels>
</Tabs>
Here's the implementation using Context:
const TabsContext = createContext(null);
function Tabs({ defaultIndex = 0, children }) {
const [activeIndex, setActiveIndex] = useState(defaultIndex);
return (
<TabsContext.Provider value={{ activeIndex, setActiveIndex }}>
<div className="tabs">{children}</div>
</TabsContext.Provider>
);
}
function TabList({ children }) {
return <div role="tablist" className="tab-list">{children}</div>;
}
function Tab({ children, disabled = false }) {
const { activeIndex, setActiveIndex } = useContext(TabsContext);
const index = useTabIndex(); // tracks position via cloneElement or Context counter
return (
<button
role="tab"
aria-selected={activeIndex === index}
disabled={disabled}
onClick={() => !disabled && setActiveIndex(index)}
>
{children}
</button>
);
}
function TabPanels({ children }) {
return <div className="tab-panels">{children}</div>;
}
function TabPanel({ children }) {
const { activeIndex } = useContext(TabsContext);
const index = useTabIndex();
return activeIndex === index ? <div role="tabpanel">{children}</div> : null;
}
// Attach sub-components
Tabs.List = TabList;
Tabs.Tab = Tab;
Tabs.Panels = TabPanels;
Tabs.Panel = TabPanel;
Why It Works
- Zero prop-drilling — Context handles shared state transparently.
-
Fully composable — consumers can put anything inside
<Tabs.Tab>. -
Open for extension, closed for modification — adding a
<Tabs.Tab>variant doesn't touch the core. ### When NOT to Use It
Compound components shine for UI components with shared state (Tabs, Accordion, Dropdown, Modal). They're overkill for simple stateless components. Don't reach for this pattern reflexively.
2. The State Reducer Pattern
Kent C. Dodds popularised this one, and once you get it, you'll see the use case everywhere.
The Problem
You've built a polished useToggle hook. Then a consumer says:
"Can we prevent the toggle from turning off if a certain condition is met?"
You could add an allowToggleOff prop. Then they ask for disableOnMax. And now your hook has 6 options covering every edge case someone dreamed up — and your tests look like a horror movie.
The Pattern
Give consumers control over how state transitions happen by accepting a custom reducer:
// The hook
function useToggle({ reducer = toggleReducer } = {}) {
const [state, dispatch] = useReducer(reducer, { on: false });
const toggle = () => dispatch({ type: 'TOGGLE' });
const reset = () => dispatch({ type: 'RESET' });
return { on: state.on, toggle, reset };
}
// Default reducer — normal behaviour
function toggleReducer(state, action) {
switch (action.type) {
case 'TOGGLE': return { on: !state.on };
case 'RESET': return { on: false };
default: throw new Error(`Unhandled action: ${action.type}`);
}
}
Now a consumer can override specific transitions without touching the hook:
function App() {
const [timesToggled, setTimesToggled] = useState(0);
function customReducer(state, action) {
if (action.type === 'TOGGLE' && timesToggled >= 3) {
// Prevent toggling after 3 times — no hook changes needed
return state;
}
return toggleReducer(state, action); // Delegate everything else
}
const { on, toggle } = useToggle({ reducer: customReducer });
return (
<div>
<p>Toggle is {on ? 'ON' : 'OFF'}</p>
<p>Times toggled: {timesToggled}</p>
<button onClick={() => { toggle(); setTimesToggled(c => c + 1); }}>
Toggle
</button>
</div>
);
}
Why It Works
- Inversion of control — the hook owns what can happen; the consumer controls when.
- No breaking changes — adding new behaviour doesn't change the hook's public API.
- Testable in isolation — you can test the default reducer separately from custom overrides. ### Real Production Use Case
I used this in a form library to let consumers block submission during async validation, throttle re-renders on fast inputs, or add optimistic updates — all without forking the core hook.
3. Headless Components (a.k.a. Renderless Components)
This is the pattern behind Radix UI, Headless UI, and React Aria. And once you use it, you'll never go back to shipping styled components in a shared library.
The Problem
Your design system ships a <Dropdown> component. Works great. Then a team needs a dark-mode version. Then mobile needs a full-screen overlay instead of a floating menu. Now you're maintaining three variants of the same logic — open/close state, keyboard navigation, accessibility roles — duplicated across all three.
The Pattern
Separate behaviour and accessibility from visual rendering:
// Headless hook — pure logic, zero opinions on markup
function useDropdown(items) {
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const triggerRef = useRef(null);
const listRef = useRef(null);
const open = () => setIsOpen(true);
const close = () => { setIsOpen(false); setActiveIndex(-1); };
const toggle = () => (isOpen ? close() : open());
const handleKeyDown = (e) => {
if (!isOpen) return;
if (e.key === 'ArrowDown') setActiveIndex(i => Math.min(i + 1, items.length - 1));
if (e.key === 'ArrowUp') setActiveIndex(i => Math.max(i - 1, 0));
if (e.key === 'Escape') close();
if (e.key === 'Enter' && activeIndex >= 0) {
items[activeIndex].onSelect();
close();
}
};
// Returns prop getters — the key to this pattern
return {
isOpen,
activeIndex,
getTriggerProps: () => ({
ref: triggerRef,
onClick: toggle,
'aria-haspopup': 'listbox',
'aria-expanded': isOpen,
}),
getMenuProps: () => ({
ref: listRef,
role: 'listbox',
onKeyDown: handleKeyDown,
}),
getItemProps: (index) => ({
role: 'option',
'aria-selected': index === activeIndex,
onClick: () => { items[index].onSelect(); close(); },
}),
};
}
Now any team can render their own markup with full keyboard support and ARIA:
// Team A — desktop floating menu
function DesktopDropdown({ items, label }) {
const { isOpen, getTriggerProps, getMenuProps, getItemProps } = useDropdown(items);
return (
<div className="dropdown">
<button {...getTriggerProps()}>{label}</button>
{isOpen && (
<ul className="dropdown-menu" {...getMenuProps()}>
{items.map((item, i) => (
<li key={item.id} className="dropdown-item" {...getItemProps(i)}>
{item.label}
</li>
))}
</ul>
)}
</div>
);
}
// Team B — mobile bottom sheet, same hook
function MobileDropdown({ items, label }) {
const { isOpen, getTriggerProps, getMenuProps, getItemProps } = useDropdown(items);
return (
<>
<button className="mobile-trigger" {...getTriggerProps()}>{label}</button>
{isOpen && (
<div className="bottom-sheet">
<ul {...getMenuProps()}>
{items.map((item, i) => (
<li key={item.id} className="sheet-item" {...getItemProps(i)}>
<span>{item.icon}</span> {item.label}
</li>
))}
</ul>
</div>
)}
</>
);
}
The Prop Getter Pattern
Notice the getTriggerProps() / getItemProps() approach — this is the secret sauce. Instead of passing individual ARIA props, you return an object the consumer spreads. This means:
- You can add new ARIA attributes to the getter without breaking consumers.
- Consumers can merge their own handlers:
{...getTriggerProps(), onFocus: myHandler}. ### When to Use It
Build headless when: you're building a shared library used across multiple teams or design languages, or when you're wrapping a complex interaction (date pickers, comboboxes, drag-and-drop).
Don't headless-ify everything. A one-off <UserCard> in your app doesn't need this.
4. Composition Over Configuration
This isn't a single pattern — it's a mindset shift that ties everything together.
The Configuration Trap
// This is a configuration component. It will never stop growing.
<DataTable
data={rows}
columns={columns}
sortable
filterable
paginated
pageSize={10}
onRowClick={handler}
rowClassName={(row) => row.urgent ? 'urgent' : ''}
emptyState={<EmptyIllustration />}
headerRenderer={(col) => <Tooltip title={col.hint}>{col.label}</Tooltip>}
footerRenderer={() => <ExportButton />}
// ...and it keeps going
/>
Every new requirement becomes a new prop. The component's internals become a maze of conditionals. Onboarding a new engineer? Good luck.
The Composition Alternative
<Table>
<Table.Header>
{columns.map(col => (
<Table.Column key={col.id} sortable={col.sortable}>
<Tooltip title={col.hint}>{col.label}</Tooltip>
</Table.Column>
))}
</Table.Header>
<Table.Body data={rows}>
{(row) => (
<Table.Row key={row.id} onClick={() => handleRowClick(row)} className={row.urgent ? 'urgent' : ''}>
{columns.map(col => (
<Table.Cell key={col.id}>{col.render(row)}</Table.Cell>
))}
</Table.Row>
)}
</Table.Body>
<Table.Footer>
<Pagination total={total} pageSize={10} onChange={setPage} />
<ExportButton data={rows} />
</Table.Footer>
</Table>
More verbose? Yes. But:
- Adding a sticky footer doesn't require a
stickyFooterprop. - Custom row grouping doesn't require a
groupByAPI. - Every piece is individually testable. ### The Rule of Thumb
If you catch yourself adding more than 3 render props or 5 boolean flags to a component, it's time to decompose. Composition is usually the answer.
Putting It All Together
These four patterns aren't independent — they complement each other:
| Pattern | Best For | Core Trade-off |
|---|---|---|
| Compound Components | Shared-state UI (Tabs, Accordion) | More verbose usage, but infinitely composable |
| State Reducer | Custom hooks with complex state logic | Requires consumers to understand reducer shape |
| Headless Components | Shared libraries across design systems | More code up-front; massive long-term flexibility |
| Composition | Any component that's growing too many props | Verbose JSX; worth it at scale |
None of these are silver bullets. The best engineers I've worked with aren't the ones who know the most patterns — they're the ones who know when patterns are the wrong tool and keep things simple.
But when your component starts growing a config API that rivals a webpack config? That's your signal. Reach for these.
Thanks for reading. If you found this useful, I write about real-world React architecture, performance, and engineering trade-offs. Drop a comment if you've used any of these patterns — or if you've been burned by them. Both stories are worth hearing.
Top comments (0)