Who is this for? Intermediate React developers who understand JSX, props, state, and
useEffect— and are ready to level up how they architect components for real-world, production-grade applications.
Introduction
Every React developer eventually hits a wall. You've built your components, they work great — until a new requirement comes in. The designer wants the <Dropdown> to render differently on mobile. The product manager wants the <Modal> to be reusable across three different feature areas with completely different content. The design system team wants to extract the <Tabs> component into a shared library.
Suddenly, your once-elegant component is drowning in props: renderHeader, showFooter, mobileVariant, onCloseCallback, iconLeft, iconRight… and the logic is tangled like headphone wires.
This is the moment where advanced React patterns become not just useful — but essential.
In this blog, we'll explore three of the most powerful architectural patterns in the React ecosystem:
- Compound Components — for expressive, flexible component APIs
- Render Props — for sharing logic without prescribing UI
- Custom Hooks — for extracting and reusing stateful logic cleanly
By the end, you'll have concrete, production-ready examples you can reach for the next time your components start getting unwieldy.
Pattern 1: Compound Components
What Are Compound Components?
Compound components are a pattern where multiple components work together to form a cohesive unit, sharing implicit state via React Context. Think of them like HTML's native <select> and <option> — they're separate elements, but they're meaningless without each other.
This pattern solves one of the most common React pain points: prop drilling and API sprawl. Instead of one monolithic component accepting twenty props to control every sub-part, you give consumers a set of composable sub-components.
The Problem It Solves
Here's what the "naïve" approach to a Tabs component often looks like:
// ❌ Rigid — the consumer has zero control over structure
<Tabs
items={[
{ label: "Overview", content: <Overview /> },
{ label: "Settings", content: <Settings /> },
]}
defaultActive={0}
variant="underline"
showIcons={true}
iconPosition="left"
/>
The consumer cannot reorder elements, inject custom wrappers, or change where the tab list renders relative to the content. It's a black box.
The Compound Component Solution
// ✅ Expressive — the consumer controls structure, we control behavior
<Tabs defaultValue="overview">
<Tabs.List>
<Tabs.Trigger value="overview">Overview</Tabs.Trigger>
<Tabs.Trigger value="settings">Settings</Tabs.Trigger>
</Tabs.List>
<Tabs.Panel value="overview">
<Overview />
</Tabs.Panel>
<Tabs.Panel value="settings">
<Settings />
</Tabs.Panel>
</Tabs>
This API is inspired by Radix UI — and for good reason. It reads like English, it's self-documenting, and the consumer has full structural control.
Building It with Context
// tabs.tsx
import React, { createContext, useContext, useState } from "react";
// --- Types ---
interface TabsContextValue {
activeValue: string;
setActiveValue: (value: string) => void;
}
// --- Context ---
const TabsContext = createContext<TabsContextValue | null>(null);
function useTabsContext() {
const ctx = useContext(TabsContext);
if (!ctx) {
throw new Error("Tabs sub-components must be used within a <Tabs> parent.");
}
return ctx;
}
// --- Root ---
interface TabsProps {
defaultValue: string;
children: React.ReactNode;
}
function Tabs({ defaultValue, children }: TabsProps) {
const [activeValue, setActiveValue] = useState(defaultValue);
return (
<TabsContext.Provider value={{ activeValue, setActiveValue }}>
<div className="tabs-root">{children}</div>
</TabsContext.Provider>
);
}
// --- List ---
function TabsList({ children }: { children: React.ReactNode }) {
return <div role="tablist" className="tabs-list">{children}</div>;
}
// --- Trigger ---
interface TabsTriggerProps {
value: string;
children: React.ReactNode;
}
function TabsTrigger({ value, children }: TabsTriggerProps) {
const { activeValue, setActiveValue } = useTabsContext();
const isActive = activeValue === value;
return (
<button
role="tab"
aria-selected={isActive}
onClick={() => setActiveValue(value)}
className={`tabs-trigger ${isActive ? "tabs-trigger--active" : ""}`}
>
{children}
</button>
);
}
// --- Panel ---
interface TabsPanelProps {
value: string;
children: React.ReactNode;
}
function TabsPanel({ value, children }: TabsPanelProps) {
const { activeValue } = useTabsContext();
if (activeValue !== value) return null;
return (
<div role="tabpanel" className="tabs-panel">
{children}
</div>
);
}
// --- Compose ---
Tabs.List = TabsList;
Tabs.Trigger = TabsTrigger;
Tabs.Panel = TabsPanel;
export { Tabs };
Key Design Decisions
- Context for implicit state sharing: Sub-components don't need
activeValuepassed manually — they read it from context. - Guard with
useTabsContext(): Throwing a descriptive error when sub-components are used outside their parent prevents confusing runtime failures. - Dot-notation API (
Tabs.List,Tabs.Trigger): Collocates the API, makes imports clean, and signals intent clearly.
When to Use Compound Components
- Building design system primitives (Accordion, Dialog, Select, Tabs)
- When consumers need structural flexibility
- When you want self-documenting, expressive APIs
- When a component manages shared state across multiple children
Pattern 2: Render Props
What Are Render Props?
A render prop is a technique where a component receives a function as a prop (or as children) and calls that function to render UI. The component handles the logic; the consumer decides the presentation.
This achieves a clean separation of concerns: behavior is encapsulated, but the UI remains completely in the consumer's hands.
The Classic Example: Mouse Tracking
// mouse-tracker.tsx
import { useState, useEffect, useCallback } from "react";
interface MousePosition {
x: number;
y: number;
}
interface MouseTrackerProps {
render: (position: MousePosition) => React.ReactNode;
}
function MouseTracker({ render }: MouseTrackerProps) {
const [position, setPosition] = useState<MousePosition>({ x: 0, y: 0 });
const handleMouseMove = useCallback((e: MouseEvent) => {
setPosition({ x: e.clientX, y: e.clientY });
}, []);
useEffect(() => {
window.addEventListener("mousemove", handleMouseMove);
return () => window.removeEventListener("mousemove", handleMouseMove);
}, [handleMouseMove]);
return <>{render(position)}</>;
}
// Usage
function App() {
return (
<MouseTracker
render={({ x, y }) => (
<div>
<p>Mouse is at ({x}, {y})</p>
<div
style={{
position: "fixed",
left: x,
top: y,
width: 12,
height: 12,
borderRadius: "50%",
background: "coral",
transform: "translate(-50%, -50%)",
pointerEvents: "none",
}}
/>
</div>
)}
/>
);
}
Render Props via Children
A more ergonomic variation uses children as the render function — sometimes called the "function-as-children" pattern:
interface ToggleProps {
children: (state: { on: boolean; toggle: () => void }) => React.ReactNode;
}
function Toggle({ children }: ToggleProps) {
const [on, setOn] = useState(false);
const toggle = useCallback(() => setOn((prev) => !prev), []);
return <>{children({ on, toggle })}</>;
}
// Usage — reads beautifully
<Toggle>
{({ on, toggle }) => (
<div>
<button onClick={toggle}>{on ? "Turn Off" : "Turn On"}</button>
{on && <p>The light is on! 💡</p>}
</div>
)}
</Toggle>
A Real-World Example: Data Fetcher
// data-fetcher.tsx
import { useState, useEffect } from "react";
type FetchState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: Error };
interface DataFetcherProps<T> {
url: string;
children: (state: FetchState<T>) => React.ReactNode;
}
function DataFetcher<T>({ url, children }: DataFetcherProps<T>) {
const [state, setState] = useState<FetchState<T>>({ status: "idle" });
useEffect(() => {
let cancelled = false;
setState({ status: "loading" });
fetch(url)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<T>;
})
.then((data) => {
if (!cancelled) setState({ status: "success", data });
})
.catch((error) => {
if (!cancelled) setState({ status: "error", error });
});
return () => {
cancelled = true;
};
}, [url]);
return <>{children(state)}</>;
}
// Usage
interface User {
id: number;
name: string;
email: string;
}
function UserProfile({ userId }: { userId: number }) {
return (
<DataFetcher<User> url={`/api/users/${userId}`}>
{(state) => {
if (state.status === "loading") return <Spinner />;
if (state.status === "error") return <ErrorBanner message={state.error.message} />;
if (state.status === "success") {
return (
<div>
<h2>{state.data.name}</h2>
<p>{state.data.email}</p>
</div>
);
}
return null;
}}
</DataFetcher>
);
}
Notice how the DataFetcher knows nothing about how to render a user — it only manages fetch lifecycle state. This makes it reusable for any data type.
When to Use Render Props
- When you need to share complex stateful behavior (drag-and-drop, animations, form state) without coupling it to any specific UI
- When the same logic needs many different visual representations
- When building headless/unstyled component libraries
- As an alternative to HOCs (Higher-Order Components) that's easier to reason about
Pattern 3: Custom Hooks
Why Custom Hooks?
Render props are powerful, but they can introduce "wrapper hell" — deeply nested JSX that's hard to follow. In 2019, React Hooks gave us a better tool for the same problem: custom hooks.
A custom hook is a function that starts with use and can call other hooks. It extracts stateful logic so you can share it between components without any component nesting at all.
Mental model: If Render Props share logic via component composition, custom hooks share logic via function composition.
Refactoring Render Props to a Hook
Let's take our DataFetcher and convert it:
// use-fetch.ts
import { useState, useEffect } from "react";
type FetchState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: Error };
function useFetch<T>(url: string): FetchState<T> {
const [state, setState] = useState<FetchState<T>>({ status: "idle" });
useEffect(() => {
let cancelled = false;
setState({ status: "loading" });
fetch(url)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<T>;
})
.then((data) => {
if (!cancelled) setState({ status: "success", data });
})
.catch((error) => {
if (!cancelled) setState({ status: "error", error });
});
return () => {
cancelled = true;
};
}, [url]);
return state;
}
// Usage — clean, flat, readable
function UserProfile({ userId }: { userId: number }) {
const state = useFetch<User>(`/api/users/${userId}`);
if (state.status === "loading") return <Spinner />;
if (state.status === "error") return <ErrorBanner message={state.error.message} />;
if (state.status === "success") {
return (
<div>
<h2>{state.data.name}</h2>
<p>{state.data.email}</p>
</div>
);
}
return null;
}
Much cleaner. No extra JSX nesting, no prop gymnastics.
A Powerful Real-World Hook: useLocalStorage
// use-local-storage.ts
import { useState, useCallback } from "react";
function useLocalStorage<T>(key: string, initialValue: T) {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
return item ? (JSON.parse(item) as T) : initialValue;
} catch {
return initialValue;
}
});
const setValue = useCallback(
(value: T | ((prev: T) => T)) => {
try {
const valueToStore =
value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.warn(`useLocalStorage: failed to set key "${key}"`, error);
}
},
[key, storedValue]
);
return [storedValue, setValue] as const;
}
// Usage
function ThemeToggle() {
const [theme, setTheme] = useLocalStorage<"light" | "dark">("theme", "light");
return (
<button onClick={() => setTheme((t) => (t === "light" ? "dark" : "light"))}>
Current theme: {theme}
</button>
);
}
Composing Hooks: useDebounce + useSearch
One of the most powerful features of hooks is composition — building complex behavior from simple building blocks:
// use-debounce.ts
import { useState, useEffect } from "react";
function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
// use-search.ts — composes useFetch + useDebounce
function useSearch<T>(query: string, endpoint: string) {
const debouncedQuery = useDebounce(query, 300);
const url = debouncedQuery
? `${endpoint}?q=${encodeURIComponent(debouncedQuery)}`
: "";
const state = useFetch<T[]>(url);
return state;
}
// Usage
function SearchBox() {
const [query, setQuery] = useState("");
const state = useSearch<User>(query, "/api/users/search");
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search users..."
/>
{state.status === "loading" && <p>Searching…</p>}
{state.status === "success" &&
state.data.map((user) => <UserCard key={user.id} user={user} />)}
</div>
);
}
Three concerns — debouncing, fetching, and searching — each encapsulated in a focused hook, composed together effortlessly.
Combining All Three Patterns
These patterns aren't mutually exclusive. In production codebases, you'll often combine them. Here's a Disclosure component that uses all three:
// use-disclosure.ts — Custom Hook for toggle state
function useDisclosure(initial = false) {
const [isOpen, setIsOpen] = useState(initial);
const open = useCallback(() => setIsOpen(true), []);
const close = useCallback(() => setIsOpen(false), []);
const toggle = useCallback(() => setIsOpen((s) => !s), []);
return { isOpen, open, close, toggle };
}
// disclosure.tsx — Compound Components + Context
const DisclosureContext = createContext<ReturnType<typeof useDisclosure> | null>(null);
function Disclosure({ children, defaultOpen = false }: { children: React.ReactNode; defaultOpen?: boolean }) {
const value = useDisclosure(defaultOpen);
return <DisclosureContext.Provider value={value}>{children}</DisclosureContext.Provider>;
}
function useDisclosureContext() {
const ctx = useContext(DisclosureContext);
if (!ctx) throw new Error("Must be inside <Disclosure>");
return ctx;
}
function DisclosureTrigger({ children }: { children: (props: { isOpen: boolean; toggle: () => void }) => React.ReactNode }) {
// Render Prop for maximum UI flexibility
const { isOpen, toggle } = useDisclosureContext();
return <>{children({ isOpen, toggle })}</>;
}
function DisclosurePanel({ children }: { children: React.ReactNode }) {
const { isOpen } = useDisclosureContext();
return isOpen ? <div>{children}</div> : null;
}
Disclosure.Trigger = DisclosureTrigger;
Disclosure.Panel = DisclosurePanel;
// Usage — clean, expressive, flexible
<Disclosure>
<Disclosure.Trigger>
{({ isOpen, toggle }) => (
<button onClick={toggle} aria-expanded={isOpen}>
{isOpen ? "▲ Hide Details" : "▼ Show Details"}
</button>
)}
</Disclosure.Trigger>
<Disclosure.Panel>
<p>Here are the details you were looking for.</p>
</Disclosure.Panel>
</Disclosure>
Best Practices
For Compound Components
- Always throw a descriptive error when sub-components are used outside their parent context. Silent failures are painful to debug.
- Use dot-notation (
Tabs.List) to collocate the API and signal that components are meant to be used together. - Export the context hook (
useTabsContext) if consumers need to build their own sub-components. - Provide sensible defaults — not every sub-component should be required.
For Render Props
- Memoize the render function with
useCallbackwhen passing it down to prevent unnecessary re-renders. - Prefer
childrenover a namedrenderprop when there's only one render slot — it's more idiomatic. - Keep the render prop's argument shape minimal — only expose what the consumer actually needs.
- Consider migrating to a custom hook if the render prop has no JSX concerns.
For Custom Hooks
- Follow the
useprefix convention — it's not just style, React's linting rules depend on it. - Return stable references using
useCallbackanduseMemoto avoid breaking downstream memoization. - Handle cleanup — always return a cleanup function from
useEffectwhen subscribing to events or external resources. - Keep hooks focused — one responsibility per hook. Compose for complexity.
- Test hooks in isolation using
@testing-library/react'srenderHook.
Common Mistakes
1. Creating Context Without a Default Value Guard
// ❌ Cryptic error: "Cannot read property 'activeValue' of undefined"
const TabsContext = createContext<TabsContextValue>({} as TabsContextValue);
// ✅ Explicit error: "Tabs sub-components must be used within <Tabs>"
const TabsContext = createContext<TabsContextValue | null>(null);
// + throw in the consumer hook
2. Recreating Functions on Every Render in Render Props
// ❌ New function reference every render → breaks React.memo on children
<DataFetcher url="/api" render={(state) => <MyUI state={state} />} />
// ✅ Stable reference with useCallback
const renderUI = useCallback((state) => <MyUI state={state} />, []);
<DataFetcher url="/api" render={renderUI} />
3. Forgetting Cleanup in Custom Hooks
// ❌ Memory leak — listener never removed
useEffect(() => {
window.addEventListener("resize", handleResize);
}, []);
// ✅ Cleanup on unmount
useEffect(() => {
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, [handleResize]);
4. Over-Engineering with Patterns
Not every component needs to be a compound component. A <Button> with a few props is fine. Reach for these patterns when you have genuine complexity — shared state across children, reusable logic across components, or UI flexibility requirements.
5. Exposing Internal Implementation Details
// ❌ Leaks internal state shape — hard to change later
function useCounter() {
const [state, setState] = useState({ count: 0, step: 1 });
return { state, setState }; // too much exposed
}
// ✅ Expose a clean, stable interface
function useCounter(initialCount = 0, step = 1) {
const [count, setCount] = useState(initialCount);
const increment = useCallback(() => setCount((c) => c + step), [step]);
const decrement = useCallback(() => setCount((c) => c - step), [step]);
const reset = useCallback(() => setCount(initialCount), [initialCount]);
return { count, increment, decrement, reset };
}
🚀 Pro Tips
-
Build a personal pattern library: Keep a
hooks/andpatterns/folder in your projects. Reusable hooks likeuseDebounce,useLocalStorage, anduseFetchwill pay dividends across every project. -
Study open-source design systems: Libraries like Radix UI, Headless UI, and React Aria are essentially a masterclass in compound components and render props. Read their source code.
-
Use TypeScript generics aggressively: Patterns like
useFetch<T>andDataFetcher<T>gain enormous value from TypeScript generics — you get type safety without sacrificing reusability. -
The Stale Closure Problem: When building custom hooks with
useEffect, always include every referenced value in the dependency array, or use refs for values that should be "read at call time" rather than "tracked for changes." Theeslint-plugin-react-hooksexhaustive-deps rule is your friend. -
Controlled vs. Uncontrolled: Design compound components to support both. Provide an uncontrolled API (
defaultValue) for convenience and a controlled one (value+onChange) for power users — just like native HTML inputs. -
Colocate related types: When exporting a compound component, export its context value type too. Consumers who want to extend the system (e.g., build a custom
Tabs.Trigger) will thank you.
📌 Key Takeaways
-
Compound Components shine when you need a flexible, expressive component API where multiple sub-components share implicit state. They're the backbone of design systems.
-
Render Props are ideal for sharing complex behavioral logic while giving consumers complete control over the rendered UI. Headless libraries live here.
-
Custom Hooks are the modern default for sharing stateful logic between components. They compose elegantly, test easily, and produce flat, readable component trees.
-
Use TypeScript with all three patterns — generics make them far more powerful and safe.
-
These patterns aren't mutually exclusive — the best component libraries blend all three, reaching for whichever tool fits the problem.
-
Don't over-engineer. Start simple. Extract a pattern when you feel the pain of repetition or rigidity. Patterns are solutions to problems, not prerequisites to code.
Conclusion
Advanced React patterns are fundamentally about respecting boundaries: keeping logic separate from presentation, keeping parent state separate from child layout, and keeping reusability separate from specificity.
Compound components let consumers own the structure. Render props let consumers own the presentation. Custom hooks let consumers own the logic reuse. Together, they give you an architectural toolkit capable of handling virtually any component design challenge you'll face in production.
The best way to internalize these patterns is to build something real with them. Take a complex component in a current project — a Modal, a Form, a Dropdown — and try refactoring it using one of the patterns above. The process of refactoring teaches you far more than any blog post can.
Happy building. 🛠️
References
- React Documentation — Hooks Reference
- Radix UI Primitives — Masterclass in compound component design
- Headless UI by Tailwind Labs — Render props and headless components in practice
- React Aria by Adobe — Accessibility-first headless components
- Kent C. Dodds — Advanced React Patterns — The original deep-dive series
- TkDodo's Blog — Exceptional custom hooks writing, particularly for data fetching
- TypeScript Handbook — Generics
- testing-library/react — renderHook