React Component Patterns: Composition and Reusability
March 5, 2025 · 9 min read
React, Design Patterns, JavaScript, Architecture, Interview Prep
Introduction
Component patterns are what separate junior from senior React developers. While anyone can build components, designing reusable, flexible component APIs requires understanding proven patterns. This article covers the patterns that appear in every mature React codebase.
Composition Over Inheritance
React's component model is built on composition. You combine simple components to build complex UIs, rather than inheriting from base classes.
// Specialization through composition
function Dialog({ title, children }) {
return (
<div className="dialog">
<h2>{title}</h2>
<div className="dialog-content">{children}</div>
</div>
);
}
function ConfirmDialog({ title, message, onConfirm, onCancel }) {
return (
<Dialog title={title}>
<p>{message}</p>
<div className="dialog-actions">
<button onClick={onCancel}>Cancel</button>
<button onClick={onConfirm}>Confirm</button>
</div>
</Dialog>
);
}
function DeleteConfirmDialog({ itemName, onConfirm, onCancel }) {
return (
<ConfirmDialog
title="Confirm Delete"
message={`Are you sure you want to delete "${itemName}"?`}
onConfirm={onConfirm}
onCancel={onCancel}
/>
);
}
Why Composition Wins
Inheritance creates tight coupling and rigid hierarchies. Composition lets you mix and match behaviors freely. Need a modal that's also a form? Compose them. Need different button styles? Compose with different className props.
Compound Components
Compound components are a group of components that work together to form a complete UI. Think of <select> and <option> in HTML - they're meaningless alone but powerful together.
import { createContext, useContext, useState } from 'react';
const TabsContext = createContext(null);
function Tabs({ children, defaultTab }) {
const [activeTab, setActiveTab] = useState(defaultTab);
return (
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
<div className="tabs">{children}</div>
</TabsContext.Provider>
);
}
function TabList({ children }) {
return <div className="tab-list" role="tablist">{children}</div>;
}
function Tab({ value, children }) {
const { activeTab, setActiveTab } = useContext(TabsContext);
const isActive = activeTab === value;
return (
<button
role="tab"
aria-selected={isActive}
className={`tab ${isActive ? 'active' : ''}`}
onClick={() => setActiveTab(value)}
>
{children}
</button>
);
}
function TabPanels({ children }) {
return <div className="tab-panels">{children}</div>;
}
function TabPanel({ value, children }) {
const { activeTab } = useContext(TabsContext);
if (activeTab !== value) return null;
return (
<div role="tabpanel" className="tab-panel">
{children}
</div>
);
}
// Attach sub-components
Tabs.List = TabList;
Tabs.Tab = Tab;
Tabs.Panels = TabPanels;
Tabs.Panel = TabPanel;
// Usage - clean, declarative API
function Settings() {
return (
<Tabs defaultTab="general">
<Tabs.List>
<Tabs.Tab value="general">General</Tabs.Tab>
<Tabs.Tab value="security">Security</Tabs.Tab>
<Tabs.Tab value="notifications">Notifications</Tabs.Tab>
</Tabs.List>
<Tabs.Panels>
<Tabs.Panel value="general">
<GeneralSettings />
</Tabs.Panel>
<Tabs.Panel value="security">
<SecuritySettings />
</Tabs.Panel>
<Tabs.Panel value="notifications">
<NotificationSettings />
</Tabs.Panel>
</Tabs.Panels>
</Tabs>
);
}
Benefits of Compound Components
- Flexible layout - Users control the structure
- Implicit state sharing - Context handles communication
- Clean API - No prop drilling through intermediate components
- Extensible - Easy to add new sub-components
Compound Components in Practice
Compound components transformed how I built complex UIs in Glucoplate. Instead of prop drilling through multiple levels, related components share state implicitly. The code became dramatically cleaner and easier to test.
Render Props
Render props let a component delegate rendering to its parent. The component provides data/behavior; the parent decides how to render it.
function MouseTracker({ render }) {
const [position, setPosition] = useState({ x: 0, y: 0 });
useEffect(() => {
const handleMouseMove = (e) => {
setPosition({ x: e.clientX, y: e.clientY });
};
window.addEventListener('mousemove', handleMouseMove);
return () => window.removeEventListener('mousemove', handleMouseMove);
}, []);
return render(position);
}
// Usage - you decide how to render
function App() {
return (
<MouseTracker
render={({ x, y }) => (
<div>
<p>Mouse position: {x}, {y}</p>
<div
className="cursor-follower"
style={{ left: x, top: y }}
/>
</div>
)}
/>
);
}
Children as Render Prop
The same pattern using children:
function DataFetcher({ url, children }) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch(url)
.then(res => res.json())
.then(data => {
setData(data);
setLoading(false);
})
.catch(err => {
setError(err);
setLoading(false);
});
}, [url]);
return children({ data, loading, error });
}
// Usage
function UserList() {
return (
<DataFetcher url="/api/users">
{({ data, loading, error }) => {
if (loading) return <Spinner />;
if (error) return <Error message={error.message} />;
return (
<ul>
{data.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}}
</DataFetcher>
);
}
Render Props vs Hooks
Render props were more common before hooks. Today, most render prop use cases are better served by custom hooks. However, render props still shine when you need to share behavior AND control the rendered output (like in animation libraries).
Higher-Order Components (HOCs)
HOCs are functions that take a component and return an enhanced component. They wrap functionality around existing components.
function withLoading(WrappedComponent) {
return function WithLoadingComponent({ isLoading, ...props }) {
if (isLoading) {
return <LoadingSpinner />;
}
return <WrappedComponent {...props} />;
};
}
// Usage
const UserListWithLoading = withLoading(UserList);
<UserListWithLoading isLoading={loading} users={users} />
Authentication HOC
function withAuth(WrappedComponent) {
return function WithAuthComponent(props) {
const { user, isAuthenticated } = useAuth();
if (!isAuthenticated) {
return <Navigate to="/login" />;
}
return <WrappedComponent {...props} user={user} />;
};
}
// Protect routes
const ProtectedDashboard = withAuth(Dashboard);
Composing Multiple HOCs
// Multiple HOCs can be composed
const EnhancedComponent = withAuth(
withLoading(
withErrorBoundary(BaseComponent)
)
);
// Or use compose utility
import { compose } from 'lodash';
const enhance = compose(
withAuth,
withLoading,
withErrorBoundary
);
const EnhancedComponent = enhance(BaseComponent);
HOC Gotchas
HOCs have drawbacks: wrapper hell in DevTools, prop name collisions, and difficulty with refs. Custom hooks often provide cleaner solutions. Use HOCs when you need to wrap JSX, not just share logic.
Container/Presentational Pattern
Separate data-fetching/logic from presentation. This pattern improves testability and reusability.
// Container - handles data and logic
function UserListContainer() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [sortOrder, setSortOrder] = useState('name');
useEffect(() => {
fetchUsers().then(data => {
setUsers(data);
setLoading(false);
});
}, []);
const sortedUsers = useMemo(() => {
return [...users].sort((a, b) =>
a[sortOrder].localeCompare(b[sortOrder])
);
}, [users, sortOrder]);
const handleDelete = async (userId) => {
await deleteUser(userId);
setUsers(users.filter(u => u.id !== userId));
};
return (
<UserList
users={sortedUsers}
loading={loading}
sortOrder={sortOrder}
onSortChange={setSortOrder}
onDelete={handleDelete}
/>
);
}
// Presentational - pure rendering
function UserList({ users, loading, sortOrder, onSortChange, onDelete }) {
if (loading) return <Spinner />;
return (
<div className="user-list">
<SortDropdown value={sortOrder} onChange={onSortChange} />
{users.map(user => (
<UserCard
key={user.id}
user={user}
onDelete={() => onDelete(user.id)}
/>
))}
</div>
);
}
Benefits
- Testability - Presentational components are pure; test with props
- Reusability - Presentational components work with any data source
- Separation of concerns - Logic and UI evolve independently
Controlled vs Uncontrolled Components
Controlled
Parent owns the state; component is a "dumb" input:
function ControlledInput({ value, onChange }) {
return (
<input
value={value}
onChange={e => onChange(e.target.value)}
/>
);
}
// Parent controls everything
function Form() {
const [name, setName] = useState('');
return (
<ControlledInput
value={name}
onChange={setName}
/>
);
}
Uncontrolled
Component manages its own state; parent accesses via ref:
function UncontrolledInput({ defaultValue }) {
const inputRef = useRef(null);
return (
<input
ref={inputRef}
defaultValue={defaultValue}
/>
);
}
// Parent reads value when needed
function Form() {
const inputRef = useRef();
const handleSubmit = () => {
console.log(inputRef.current.value);
};
return <UncontrolledInput ref={inputRef} />;
}
Hybrid: Controlled with Internal State
function SearchInput({ value, onChange, onSearch }) {
// Internal state for immediate feedback
const [localValue, setLocalValue] = useState(value);
// Sync with controlled value
useEffect(() => {
setLocalValue(value);
}, [value]);
const handleChange = (e) => {
setLocalValue(e.target.value);
// Debounce before notifying parent
debouncedOnChange(e.target.value);
};
return (
<input
value={localValue}
onChange={handleChange}
onKeyDown={e => e.key === 'Enter' && onSearch(localValue)}
/>
);
}
Questions Worth Considering
Q1: What are compound components and when would you use them?
Answer: Compound components are related components that share implicit state through context. Use them when building complex UI widgets like tabs, accordions, or dropdown menus where:
- Components are meaningless alone but powerful together
- You want flexible layout control
- State should be shared without prop drilling
Q2: Render props vs custom hooks - when to use each?
Answer:
- Custom hooks for sharing stateful logic (data, loading states, handlers)
- Render props when you need to control the rendered output along with shared logic
Hooks are generally preferred for logic reuse. Render props are still valuable in animation libraries or when the rendering decision depends on the shared state in complex ways.
Q3: What problems do HOCs solve and what are their drawbacks?
Answer: HOCs solve cross-cutting concerns: auth, logging, error boundaries, data fetching wrappers.
Drawbacks:
- Wrapper hell in React DevTools
- Prop name collisions
- Static composition (can't use hooks conditionally)
- Refs don't pass through automatically
For most cases, custom hooks provide cleaner solutions.
Q4: Explain the Container/Presentational pattern.
Answer: Separate components into two types:
- Containers handle data fetching, state management, and business logic
- Presentational components receive props and render UI
Benefits: testability (presentational are pure), reusability (same presentation with different data sources), separation of concerns.
Summary
- Composition over inheritance - combine components, don't extend them
- Compound components share implicit state via context for flexible APIs
- Render props delegate rendering decisions to consumers
- HOCs wrap components to add behavior (but consider hooks first)
- Container/Presentational separates logic from UI
- Controlled vs Uncontrolled determines who owns the state
Quick Reference: When to Use Each Pattern
| Pattern | Use When | Example |
|---|---|---|
| Composition | Building specialized versions of components | ConfirmDialog wrapping Dialog |
| Compound Components | Related components share state, user controls layout | Tabs, Accordion, Dropdown Menu |
| Render Props | Share behavior AND let consumer control rendering | Mouse tracking, animation libraries |
| HOCs | Add wrapper behavior to existing components | Auth guards, error boundaries |
| Container/Presentational | Need testable, reusable presentation layer | Data tables, user lists |
| Controlled | Parent needs to validate/transform input | Forms with validation |
| Uncontrolled | Simple inputs, parent only reads on submit | Quick forms, file inputs |
Modern React Preference
In 2025, the pattern hierarchy is: Custom Hooks > Composition > Render Props > HOCs. Start with hooks for logic sharing. Use compound components for complex UI widgets. Reserve HOCs for true wrapper scenarios like route protection.
These patterns are essential for building maintainable React applications.
Part 3 of the React Developer Reference series.