Skip to main content
Back to Blog
ReactJavaScriptTypeScriptDesign PatternsFrontendHooksComponent ArchitectureWeb Development

Advanced React Patterns: Compound Components, Render Props, and Hooks

Master advanced React patterns including Compound Components, Render Props, and custom Hooks to build highly reusable, maintainable component libraries. A practical deep-dive for intermediate React developers.

April 30, 202616 min readNiraj Kumar

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:

  1. Compound Components — for expressive, flexible component APIs
  2. Render Props — for sharing logic without prescribing UI
  3. 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 activeValue passed 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 useCallback when passing it down to prevent unnecessary re-renders.
  • Prefer children over a named render prop 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 use prefix convention — it's not just style, React's linting rules depend on it.
  • Return stable references using useCallback and useMemo to avoid breaking downstream memoization.
  • Handle cleanup — always return a cleanup function from useEffect when subscribing to events or external resources.
  • Keep hooks focused — one responsibility per hook. Compose for complexity.
  • Test hooks in isolation using @testing-library/react's renderHook.

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/ and patterns/ folder in your projects. Reusable hooks like useDebounce, useLocalStorage, and useFetch will 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> and DataFetcher<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." The eslint-plugin-react-hooks exhaustive-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

All Articles
ReactJavaScriptTypeScriptDesign PatternsFrontendHooksComponent ArchitectureWeb Development

Written by

Niraj Kumar

Software Developer — building scalable systems for businesses.