Skip to main content

Command Palette

Search for a command to run...

Component Design Patterns Every React Dev Should Know (Compound Components, Render Props & Custom Hooks)

Published
6 min read
K
Full Stack Developer with 3+ years of experience building scalable SaaS web applications using the MERN stack (MongoDB, Express.js, React.js, Node.js). Skilled in developing responsive frontends with React/Next.js and TypeScript, designing RESTful APIs, and optimizing database performance. Experienced in CI/CD pipelines, Docker, and agile practices.

First, a decision diagram — the question you should ask before picking a pattern:

I used to build components in one big blob. Everything in one file — state, logic, markup, API calls. It worked. Until someone asked me to reuse just part of it somewhere else, and I realised I had no idea how to do that cleanly.

That's what component design patterns solve. They're not fancy abstractions for the sake of it — they're answers to real problems you will hit as your codebase grows. And they come up in frontend system design interviews all the time, so worth knowing properly.

Three patterns I think every React dev should have in their toolkit: custom hooks, compound components, and render props. Let's go through each one with real examples.

Pattern 2 — Custom hooks

The problem: You have the same stateful logic copy-pasted across 5 components. A fetch call, a debounce, a form handler. Every time you fix a bug in one, you forget to fix the other four.

The solution: Extract it into a custom hook.

// hooks/useFetch.ts
function useFetch<T>(url: string) {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    fetch(url)
      .then(res => res.json())
      .then(setData)
      .catch(setError)
      .finally(() => setLoading(false));
  }, [url]);

  return { data, loading, error };
}

Now any component can just:

const { data, loading, error } = useFetch<User[]>('/api/users');

The component has no idea how fetching works. It just gets the result. Change the fetch logic once in useFetch and every component using it gets the fix automatically.

Rule of thumb: If you're copying stateful logic (anything using useState or useEffect) between components, that's a custom hook waiting to be extracted.

Pattern 2 — Compound components

The problem: You're building a <Tabs> component. You want the API to feel natural and flexible — consumers should be able to control what tabs show up, what order they're in, maybe add custom content between them. But you also don't want to pass 15 props into one giant component.

The solution: Compound components — a parent that owns shared state, and child components that consume it via context.

// The parent holds state and provides context
const TabsContext = createContext(null);

function Tabs({ children }) {
  const [active, setActive] = useState(0);
  return (
    <TabsContext.Provider value={{ active, setActive }}>
      <div>{children}</div>
    </TabsContext.Provider>
  );
}

// Children consume the context
function Tab({ index, children }) {
  const { active, setActive } = useContext(TabsContext);
  return (
    <button
      onClick={() => setActive(index)}
      style={{ fontWeight: active === index ? 600 : 400 }}
    >
      {children}
    </button>
  );
}

function TabPanel({ index, children }) {
  const { active } = useContext(TabsContext);
  return active === index ? <div>{children}</div> : null;
}

// Attach children as properties (optional but common)
Tabs.Tab = Tab;
Tabs.Panel = TabPanel;

Usage feels completely natural:

<Tabs>
  <Tabs.Tab index={0}>Overview</Tabs.Tab>
  <Tabs.Tab index={1}>Settings</Tabs.Tab>

  <Tabs.Panel index={0}>Overview content here</Tabs.Panel>
  <Tabs.Panel index={1}>Settings content here</Tabs.Panel>
</Tabs>

You've seen this pattern in the wild — Radix UI, Headless UI, Reach UI all use it heavily. It's why their component APIs feel so clean.

Rule of thumb: If you're building a UI component where the consumer should control structure and composition, reach for compound components.

Pattern 3 — Render props

The problem: You have logic that produces some data or state, and you want to let the consumer decide how to render it. Not just what data to show, but how to show it — completely different UI in different contexts.

The solution: Render props — pass a function as a prop, call it with the data, let the consumer return whatever JSX they want.

function MouseTracker({ render }) {
  const [position, setPosition] = useState({ x: 0, y: 0 });

  return (
    <div onMouseMove={e => setPosition({ x: e.clientX, y: e.clientY })}>
      {render(position)}
    </div>
  );
}

Consumer decides the UI completely:

<MouseTracker render={({ x, y }) => (
  <p>Mouse is at {x}, {y}</p>
)} />

// Or somewhere else, totally different UI:
<MouseTracker render={({ x, y }) => (
  <Cursor style={{ left: x, top: y }} />
)} />

Same logic, completely different rendering. That's the power of render props.

These days, custom hooks have largely replaced render props because hooks are cleaner. But render props still show up in older codebases and in some specific scenarios — like when you need the rendered output to be tightly coupled to DOM events. Worth knowing.

Rule of thumb: If you need to share logic where the consumer needs full control over what gets rendered, render props (or a custom hook returning data) work well.

When to use which

Scenario Pattern
Sharing fetch/form/timer logic Custom hook
Building flexible UI components (Tabs, Accordion, Select) Compound components
Sharing logic where rendering differs completely Render props / hook
Just need data from a hook rendered differently Custom hook returning state

The honest answer is — most of the time, you'll reach for custom hooks. They're the cleanest, most composable, and easiest to test. Compound components are your go-to for complex UI components. Render props are worth understanding for interviews and legacy code.


Why this matters for system design interviews

When you're designing a component library or a design system in an interview, the interviewer wants to see that you think about API design — not just "does it work" but "is it easy to use, flexible, and maintainable."

Compound components show you've thought about the consumer's experience. Custom hooks show you separate logic from UI. These signals require senior-level thinking.

What's next

Day 4 is going into performance patterns — virtualization, code splitting, lazy loading, and how to think about bundle size. Stuff that actually moves the needle in production apps.

If you've used any of these patterns in production and run into edge cases, drop them in the comments — I'm genuinely curious what breaks in the real world.

3 views