React Design Patterns — Compound Components, Render Props, HOCs
In this tutorial, you'll learn about React Design Patterns. We cover key concepts, practical examples, and best practices.
What You'll Learn
Master four essential React design patterns — compound components, render props, higher-order components (HOCs), and custom hooks — so you can build reusable, composable, and maintainable user interfaces.
Why It Matters
React gives you the building blocks. Design patterns give you the blueprints. Without patterns, your code becomes tangled, components grow unmanageable, and reusability plummets. Teams that adopt patterns ship faster and debug less.
Real-World Use
A UI library like Chakra UI or Radix uses compound components for its Tabs and Accordion. React Router uses render props for route matching. Authentication wrappers across dashboards rely on HOCs to gate access.
What Are React Design Patterns?
React design patterns are proven, repeatable solutions to common UI architecture problems. They are not libraries. They are approaches — ways of structuring components so they stay flexible, testable, and composable as your app grows.
Think of them like design patterns in software engineering: the Singleton or Observer patterns you might know from JavaScript. React patterns solve frontend-specific problems like sharing state between components, reusing component logic, and keeping the API surface clean for consumers of your component library.
We will cover four major patterns:
| Pattern | Core Idea | Best For |
|---|---|---|
| Compound Components | Multiple components that share implicit state | Tabs, Accordions, Select menus |
| Render Props | A prop that is a function returning JSX | Cross-cutting concerns, flexibility |
| Higher-Order Components | A function that enhances a component | Auth guards, logging, data injection |
| Custom Hooks | A function that encapsulates stateful logic | Data fetching, form handling, browser APIs |
Compound Components
Compound components work like HTML's <select> and <option> — parent and children share implicit state through context, not props.
Tabs Example
import { createContext, useContext, useState } from "react";
const TabsContext = createContext();
function Tabs({ children, defaultTab }) {
const [activeTab, setActiveTab] = useState(defaultTab);
return (
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
{children}
</TabsContext.Provider>
);
}
function Tab({ label, children }) {
const { activeTab, setActiveTab } = useContext(TabsContext);
return (
<button
onClick={() => setActiveTab(label)}
style={{
padding: "8px 16px",
fontWeight: activeTab === label ? "bold" : "normal",
borderBottom: activeTab === label ? "2px solid #007bff" : "2px solid transparent",
background: "none",
cursor: "pointer",
}}
>
{children}
</button>
);
}
function TabPanel({ label, children }) {
const { activeTab } = useContext(TabsContext);
if (activeTab !== label) return null;
return <div style={{ padding: "16px 0" }}>{children}</div>;
}
// Usage
function App() {
return (
<Tabs defaultTab="profile">
<Tab label="profile">Profile</Tab>
<Tab label="settings">Settings</Tab>
<TabPanel label="profile">Your profile content here.</TabPanel>
<TabPanel label="settings">App settings here.</TabPanel>
</Tabs>
);
}
Expected output: Three clickable tabs. Only the active tab's panel shows. Clicking a different tab swaps the visible content and highlights the selected tab label.
Why This Works
The user of Tabs never sees setActiveTab or TabsContext. They just declare what they want — tabs and panels — and the wiring happens automatically. This is the hallmark of compound components: simple API, complex behavior.
Accordion Example
const AccordionContext = createContext();
function Accordion({ children }) {
const [openIndex, setOpenIndex] = useState(null);
return (
<AccordionContext.Provider value={{ openIndex, setOpenIndex }}>
<div style={{ border: "1px solid #ddd", borderRadius: "4px" }}>
{children}
</div>
</AccordionContext.Provider>
);
}
function AccordionItem({ index, title, children }) {
const { openIndex, setOpenIndex } = useContext(AccordionContext);
const isOpen = openIndex === index;
return (
<div style={{ borderBottom: "1px solid #eee" }}>
<button
onClick={() => setOpenIndex(isOpen ? null : index)}
style={{
width: "100%",
padding: "12px 16px",
textAlign: "left",
background: isOpen ? "#f5f5f5" : "#fff",
border: "none",
cursor: "pointer",
fontWeight: isOpen ? "bold" : "normal",
}}
>
{title}
</button>
{isOpen && (
<div style={{ padding: "12px 16px" }}>{children}</div>
)}
</div>
);
}
// Usage
function FaqSection() {
return (
<Accordion>
<AccordionItem index={0} title="What is React?">
React is a JavaScript library for building user interfaces.
</AccordionItem>
<AccordionItem index={1} title="What are hooks?">
Hooks are functions that let you use state and lifecycle in function components.
</AccordionItem>
</Accordion>
);
}
Expected output: A bordered accordion with two items. Clicking an item expands its content and collapses the other. Clicking the open item collapses it.
Render Props Pattern
A render prop is a prop that receives a function which returns JSX. The parent component calls that function and passes data into it, letting the child decide how to render.
function MouseTracker({ render }) {
const [position, setPosition] = useState({ x: 0, y: 0 });
function handleMouseMove(event) {
setPosition({ x: event.clientX, y: event.clientY });
}
return (
<div
onMouseMove={handleMouseMove}
style={{ height: "200px", border: "1px solid #ccc", padding: "16px" }}
>
{render(position)}
</div>
);
}
// Usage
function App() {
return (
<MouseTracker
render={({ x, y }) => (
<p>
Mouse position: {x}, {y}
</p>
)}
/>
);
}
Expected output: A bordered box. As you move the mouse inside it, the coordinates update in real time.
When Render Props Shine
Render props give the consumer full control over rendering. The MouseTracker does not care what you display — it only provides the mouse position. This makes it extremely reusable across projects.
Higher-Order Components (HOCs)
A higher-order component is a function that takes a component and returns an enhanced component.
function withAuth(Component) {
function AuthenticatedComponent(props) {
const isLoggedIn = !!localStorage.getItem("token");
if (!isLoggedIn) {
return (
<div style={{ textAlign: "center", padding: "40px" }}>
<h2>Access Denied</h2>
<p>Please log in to view this page.</p>
</div>
);
}
return <Component {...props} />;
}
return AuthenticatedComponent;
}
// Usage
function Dashboard() {
return <h1>Welcome to your dashboard</h1>;
}
const ProtectedDashboard = withAuth(Dashboard);
Expected output: If no token is in localStorage, the user sees an "Access Denied" message. Otherwise, they see the Dashboard.
HOCs for Data Injection
function withUser(Component) {
function UserWrapper(props) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch("/api/user")
.then(res => res.json())
.then(data => {
setUser(data);
setLoading(false);
});
}, []);
return <Component {...props} user={user} loading={loading} />;
}
return UserWrapper;
}
const ProfilePageWithUser = withUser(ProfilePage);
Note: HOCs can compose: withAuth(withUser(ProfilePage)). This chaining is powerful but can lead to "wrapper hell" if overused.
Custom Hooks vs HOCs
TypeScript and modern React prefer custom hooks over HOCs for most use cases.
// HOC approach
function withWindowWidth(Component) {
return function Enhanced(props) {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const handler = () => setWidth(window.innerWidth);
window.addEventListener("resize", handler);
return () => window.removeEventListener("resize", handler);
}, []);
return <Component {...props} windowWidth={width} />;
};
}
// Custom hook approach — cleaner
function useWindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const handler = () => setWidth(window.innerWidth);
window.addEventListener("resize", handler);
return () => window.removeEventListener("resize", handler);
}, []);
return width;
}
// Usage comparison
function MyComponent({ windowWidth }) {
return <p>Width: {windowWidth}</p>;
}
const MyComponentWithWidth = withWindowWidth(MyComponent);
function MyComponent() {
const width = useWindowWidth();
return <p>Width: {width}</p>;
}
| Aspect | HOC | Custom Hook |
|---|---|---|
| Naming collision risk | Yes (prop names) | No |
| Wrapper DOM nodes | Yes | No |
| Debugging | Wrapper hell | Flat tree |
| Composition | Chaining | Composition by calling |
| TypeScript inference | Tricky | Natural |
When to Use Each Pattern
Compound Components → Shared implicit state (Tabs, Accordion, Select)
Render Props → Consumer controls rendering (MouseTracker, DataProvider)
HOCs → Legacy codebases, class components, cross-cutting concerns
Custom Hooks → Everything else — data fetching, forms, browser APIs
flowchart TD
A[Need to share state between components?] -->|Yes| B[Compound Components]
A -->|No| C[Need to inject behavior into components?]
C -->|Yes| D[Is the consumer a class component?]
D -->|Yes| E[HOC]
D -->|No| F[Custom Hook]
C -->|No| G[Need flexible rendering control?]
G -->|Yes| H[Render Props]
G -->|No| I[Use basic props]
Learning Path
flowchart LR
A[React Basics] --> B[Hooks Guide]
B --> C[Context API]
C --> D[Custom Hooks]
D --> E[Design Patterns]
E --> F[Error Boundaries]
F --> G[API Integration]
G --> H[Final Project]
E -->|You are here| E
Common Errors
1. Mutating Context Directly in Compound Components
Always use state setters inside the provider. Mutating the context value object directly does not trigger re-renders.
2. Forgetting to Return from a Render Prop
If your render function does not return JSX, you get undefined rendered. Always include a return.
3. HOC Naming Collisions
An HOC that injects a user prop will silently overwrite a user prop passed from the parent. Use naming conventions or namespaced props.
4. Wrapper Hell
Composing five HOCs creates five nested wrappers in the React DevTools. Prefer custom hooks or reduce the number of HOCs.
5. Calling Hooks Inside HOCs
Hooks must be called inside a component, not inside the HOC factory function. Always put useState/useEffect inside the inner component.
6. Over-Engineering
Not every reusable piece of logic needs a pattern. If a simple prop works, use a simple prop. Patterns add abstraction cost.
7. Breaking the Rules of Hooks in Custom Hooks
Custom hooks must follow the same rules: only call hooks at the top level, never inside conditions or loops.
Practice Questions
Challenge
Build a compound <Stepper> component with <Step> and <StepPanel> children. Include next, back, and goTo(index) navigation. The consumer should be able to write:
<Stepper>
<Step label="Personal Info" />
<Step label="Payment" />
<Step label="Confirmation" />
<StepPanel>Personal details form</StepPanel>
<StepPanel>Payment form</StepPanel>
<StepPanel>Review and submit</StepPanel>
</Stepper>
Real-World Task
Refactor a component in your current project that passes five unrelated props through three levels of nesting. Choose the appropriate pattern (compound, render prop, or custom hook) to eliminate the prop drilling. Measure the reduction in lines of code and props passed.
This tutorial is built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro — tools that rely on well-architected component patterns for their desktop UI.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro