React Design Patterns That Earn Their Keep.
Patterns aren't decoration. Each one exists because not using it creates real, recurring pain: duplicated logic, props drilled through ten layers, components that know too much, components that know too little. This guide pairs every pattern with the bug it prevents.
The eight patterns
Composition via children
Build flexible components by letting consumers pass JSX in, not by configuring through props.
function Card({ title, subtitle, image, icon, badge, action1, action2 }) { return ( <div className="card"> {image && <img src={image} />} {icon && <Icon name={icon} />} <h3>{title}</h3> {subtitle && <p>{subtitle}</p>} {badge && <Badge>{badge}</Badge>} </div> ); }
function Card({ children }) { return <div className="card">{children}</div>; } Card.Header = ({ children }) => <div className="card-header">{children}</div>; Card.Body = ({ children }) => <div className="card-body">{children}</div>; // Usage — caller composes whatever they need: <Card> <Card.Header><Badge>New</Badge><h3>Product</h3></Card.Header> <Card.Body>Best one</Card.Body> </Card>
Pros
- Infinitely flexible — no prop explosion
- Easier to read at the call site
- Built into React, zero overhead
Cons
- Less rigid contract
- Harder to enforce specific structure
Use when: a component is a container, layout, modal, card, panel — anything that wraps content.
Container / Presentational
One component fetches and computes. Another renders. Each does one job.
// Presentational — pure, easy to test function UserListView({ users, loading, error }) { if (loading) return <Spinner />; if (error) return <Error msg={error.message} />; return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>; } // Container — knows about data function UserListContainer() { const { users, loading, error } = useUsers(); return <UserListView users={users} loading={loading} error={error} />; }
Pros
- Easy to test rendering with fixtures
- Designers can work on view in isolation
- Multiple containers can share one view
Cons
- Two files per feature
- Hooks made this less critical
Custom Hooks
Extract stateful logic into reusable functions. Markup stays in components; behavior lives in hooks.
function useFetch(url) { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { let cancelled = false; fetch(url).then(r => r.json()) .then(d => { if (!cancelled) setData(d); }) .catch(e => { if (!cancelled) setError(e); }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; }, [url]); return { data, loading, error }; } // Every consumer is one line: function UserList() { const { data, loading, error } = useFetch('/api/users'); }
Pros
- True logic reuse
- Independently testable
- Composable — hooks can use hooks
- Community standard since 2019
Cons
- Rules of hooks — no conditional calls
- Dependency arrays easy to get wrong
- Stale closure bugs are #1 React gotcha
Compound Components
A group of components that work together, sharing state through context — like select and option.
const TabsContext = createContext(); function Tabs({ defaultValue, children }) { const [active, setActive] = useState(defaultValue); return ( <TabsContext.Provider value={{ active, setActive }}> {children} </TabsContext.Provider> ); } Tabs.Tab = ({ value, children }) => { const { active, setActive } = useContext(TabsContext); return <button onClick={() => setActive(value)}>{children}</button>; }; Tabs.Panel = ({ value, children }) => { const { active } = useContext(TabsContext); return active === value ? <div>{children}</div> : null; }; // Usage reads naturally: <Tabs defaultValue="a"> <Tabs.Tab value="a">First</Tabs.Tab> <Tabs.Panel value="a"><FirstPanel /></Tabs.Panel> </Tabs>
Pros
- Declarative API — looks like HTML
- Used by Radix, Headless UI, Reach UI
- Each child styled independently
Cons
- More boilerplate to set up
- Hard to enforce required structure
- TypeScript types get fancy
Use when: building a design-system component with multiple related parts that share state — Tabs, Accordion, Menu, Select, Dialog, Toast.
Render Props
A component takes a function as a child and calls it with internal state. Mostly replaced by hooks.
function MouseTracker({ children }) { const [pos, setPos] = useState({ x: 0, y: 0 }); useEffect(() => { const h = (e) => setPos({ x: e.clientX, y: e.clientY }); window.addEventListener('mousemove', h); return () => window.removeEventListener('mousemove', h); }, []); return children(pos); // children is a function! } // Usage: <MouseTracker> {(pos) => <p>Mouse at {pos.x}, {pos.y}</p>} </MouseTracker>
Pros
- Maximum flexibility
- Works in class components
- Used by Formik, react-router v5
Cons
- "Wrapper hell" when composing
- Extra render overhead
- Hooks do it better in 90% of cases
Higher-Order Components
A function that takes a component and returns a new one with added behavior.
function withAuth(Component) { return function AuthenticatedComponent(props) { const user = useUserFromStore(); if (!user) return <LoginRedirect />; return <Component {...props} user={user} />; }; } const ProfilePage = withAuth(Profile);
Pros
- Works with class components
- Standardized cross-cutting concerns
- Composable
Cons
- Wrapper hell in devtools
- Prop name collisions
- TypeScript inference is painful
Provider / Context
Make values available to any descendant without passing them through every layer.
const ThemeContext = createContext(); function ThemeProvider({ children }) { const [theme, setTheme] = useState('dark'); const value = useMemo(() => ({ theme, setTheme }), [theme]); return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>; } // Deep in the tree, no props needed: function SidebarItem() { const { theme } = useContext(ThemeContext); return <div className={theme}>...</div>; }
Pros
- Eliminates prop drilling for global state
- Built into React
- Easy to add consumers without touching the chain
Cons
- Every consumer re-renders on value change
- Forgot to memoize value? Every render triggers downstream re-renders
- Don't use for everything
Controlled vs Uncontrolled
Who owns the input's value — the component itself or its parent?
function SearchInput() { const ref = useRef(); return <input ref={ref} defaultValue="" />; }
function SearchInput({ value, onChange }) { return <input value={value} onChange={(e) => onChange(e.target.value)} />; }
The "best of both" — controllable
function Input({ value, defaultValue, onChange }) { const isControlled = value !== undefined; const [internal, setInternal] = useState(defaultValue ?? ''); const currentValue = isControlled ? value : internal; const handleChange = (e) => { if (!isControlled) setInternal(e.target.value); onChange?.(e.target.value); }; return <input value={currentValue} onChange={handleChange} />; }
Controlled — pros
- Parent has full visibility
- Easy to derive other state
- Required for debounced search, master/detail
Controlled — cons
- Re-renders parent on every keystroke
- More boilerplate
- Forgot onChange? Read-only with warning
Choosing the right pattern
Most "patterns" are answers to a specific recurring pain. Memorize the pain, not the pattern.
| If you're feeling… | Reach for… |
|---|---|
| "This component has too many props" | Composition via children |
| "I keep copy-pasting this useEffect" | Custom hook |
| "Designers can't iterate without breaking data" | Container / Presentational |
| "Building Tabs / Accordion / Menu" | Compound components |
| "I'm drilling props through layers" | Context (or zustand) |
| "Parent needs to read or set this value" | Controlled component |
| "Working in legacy class components" | HOC or render props |
Staff-level perspective: the strongest patterns combine. A modern design system component is usually a compound component that uses context internally, exposes a custom hook for advanced consumers, and supports both controlled and uncontrolled modes.
Before you leave — how confident are you with this?
Your honest rating shapes when you'll see this again. No grades, no shame.
Comments
Loading comments…