← Back to Blogs

Mastering Compound Components: Building Flexible and Reusable UI Systems in React

Tue Jul 07 20264 min read

Hey everyone, Prashuk here.

If you’ve been working with React for a while, you’ve probably hit that wall where a single component starts growing into a monster. You know the one—it takes about 15 different props just to handle a simple dropdown or a modal, and half of those props are just being passed down to child elements that don't even use them.

I’m a big fan of keeping things clean and practical. I don’t care much for high-level theory unless I can actually use it to make my apps faster or my code easier to read. That’s why I want to talk about Compound Components.

In one of my recent projects, I was building a complex dashboard with a lot of custom tabs and dropdowns. At first, I tried the "one-size-fits-all" component approach, but it became a nightmare to maintain. Here is how I fixed it using the Compound Component pattern.

1. The "Prop Soup" Problem

I ran into this issue when I was building a custom Sidebar. Initially, I had a single <Sidebar /> component where I passed an array of items, a theme, a toggle state, and three different callback functions.

The code looked something like this: <Sidebar items={data} isOpen={true} onToggle={handleToggle} variant="dark" />

The problem? Every time I wanted to change the UI of just one item or add an icon to a specific link, I had to add another prop to the parent. It was messy, and it felt like I was fighting the framework rather than using it.

2. Why Compound Components?

The idea is simple: instead of one giant component trying to do everything, you break it down into smaller pieces that "talk" to each other behind the scenes.

Think of it like an HTML <select> and <option> tag. You don't pass an array of strings to the select tag; you nest the options inside it. It’s intuitive. What worked for me was using React Context to share state between the parent and its children without making the end-user (the person using the component) deal with it.

3. Real-World Implementation (The Tabs Example)

Let's look at a practical example. Say we’re building a Tab system. Instead of passing a huge object of tab titles and content, we want a flexible UI where we can put icons or badges anywhere we want.

Here’s a simplified version of how I usually structure this:

import React, { useState, createContext, useContext } from 'react';

// 1. Create the Context
const TabContext = createContext();

// 2. Parent Component
export function Tabs({ children, defaultValue }) {
  const [activeTab, setActiveTab] = useState(defaultValue);

  return (
    <TabContext.Provider value={{ activeTab, setActiveTab }}>
      <div className="tabs-container">{children}</div>
    </TabContext.Provider>
  );
}

// 3. Child Components
Tabs.List = function List({ children }) {
  return <div className="tab-buttons">{children}</div>;
};

Tabs.Trigger = function Trigger({ value, children }) {
  const { activeTab, setActiveTab } = useContext(TabContext);
  return (
    <button 
      className={activeTab === value ? 'active' : ''} 
      onClick={() => setActiveTab(value)}
    >
      {children}
    </button>
  );
};

Tabs.Content = function Content({ value, children }) {
  const { activeTab } = useContext(TabContext);
  return activeTab === value ? <div className="tab-panel">{children}</div> : null;
};

Now, when I use this in a project, I have total control over the UI:

<Tabs defaultValue="account">
  <Tabs.List>
    <Tabs.Trigger value="account">Account</Tabs.Trigger>
    <Tabs.Trigger value="settings">Settings</Tabs.Trigger>
  </Tabs.List>
  
  <Tabs.Content value="account">
    <h2>Update your profile here.</h2>
  </Tabs.Content>
  <Tabs.Content value="settings">
    <h2>Privacy and security settings.</h2>
  </Tabs.Content>
</Tabs>

4. Why This Wins for UI and Performance

When I moved my UI library to this pattern, a few things happened:

  • Zero Prop Drilling: I stopped passing setActiveTab through five layers of components.
  • Flexibility: If a designer tells me, "Hey, put a 'New' badge on the Settings tab," I don't have to change the component logic. I just drop a <Badge /> inside the <Tabs.Trigger>.
  • Clean Code: The implementation looks like actual HTML. It’s readable for anyone else on the team.

From a performance standpoint, since the state is localized within the Context Provider, you aren't re-rendering your entire page just because a tab changed.

5. Best Practices I Follow

  • Don't overdo it: If a component is simple (like a basic button), don't turn it into a compound component. It’s overkill.
  • Namespace your components: I like using the Tabs.Trigger syntax. It clearly shows that these components belong together.
  • Handle "Out of Bounds": Always check if the context exists. If someone tries to use Tabs.Trigger outside of a Tabs parent, throw a helpful error message. It saves hours of debugging later.

Conclusion

Mastering this pattern was a game-changer for me. It took my components from being "rigid blocks" to "flexible systems."

In my experience, the best UI is the one that’s easy to change when the requirements inevitably shift two days before a deadline. Compound components give you that breathing room.

Give it a try in your next project. It might feel like a bit more code at first, but the time you save on maintenance is 100% worth it.

Catch you in the next one! — Prashuk