Lego-Style React: Building Flexible APIs with Compound Components

Create components that work together seamlessly, like Tabs or Accordions, without prop drilling. Compound components give consumers a natural API. Ideal for reusable libraries where flexibility matters.

The Compound Component Pattern in React allows multiple components to work together under the hood while appearing to be a single cohesive API on the surface.

This pattern is ideal when a parent component needs to share implicit state with its children without prop-drilling. Think of UI patterns like Tabs, Accordions, Menus, or Forms — where related parts must communicate but should remain loosely coupled from an API standpoint.

👉 Think of it like Lego blocks — you give developers smaller pieces, and they assemble them the way they want.

When to Use It

When building reusable UI libraries, you often face this problem:

  • A Tabs component needs a list, tab items, and tab panels.
  • You could pass all this as props… but the API becomes messy.

Compound components solve this by letting developers write JSX that looks natural while the logic stays inside the parent.

Use the Compound Component Pattern when:

  • Multiple components need to work closely under shared state.
  • You want to create declarative APIs for consumers.
  • The UI requires nested behavior (e.g., <Tabs><TabList><Tab /></TabList></Tabs>).

Typical scenarios:

  • Tabs, Accordions, Menus
  • Wizards or Multi-Step Forms
  • Dropdowns, Carousels
  • Custom Selects

How to Achieve It

// Parent: Tabs
const TabsContext = React.createContext();

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

// Child: TabList
const TabList = ({ children }) => <div>{children}</div>;

// Child: Tab
const Tab = ({ index, children }) => {
const { active, setActive } = React.useContext(TabsContext);
return (
<button onClick={() => setActive(index)}>
{active === index ? <b>{children}</b> : children}
</button>
);
};

// Usage
<Tabs>
<TabList>
<Tab index={0}>Home</Tab>
<Tab index={1}>Profile</Tab>
</TabList>
</Tabs>;

👉 The parent holds the state.
👉 The children consume it, but developers write clean, declarative JSX.

Best Practices

  • Use React Context for internal state sharing.
  • Keep child components small and focused.
  • Provide sensible defaults (e.g., first tab active).
  • Don’t over-nest; too many compound layers hurt readability.

Real-World Examples

  1. Accordion<Accordion><AccordionItem>...</AccordionItem></Accordion>
  2. Form Components<Form><FormField><Label /><Input /></FormField></Form>
  3. Navigation Menus<Menu><MenuItem /><MenuItem /></Menu>

Advantages

  • Clean, intuitive APIs for consumers
  • High flexibility without tons of props
  • Great for reusable UI libraries (design systems, component kits)
  • Encourages composition over configuration

Disadvantages

  • Requires React Context, which may cause unnecessary re-renders if not optimized
  • More boilerplate compared to a single “prop-based” component
  • Harder for beginners to understand at first glance

Common Problems / Pitfalls

  • Forgetting to wrap children in the parent provider → breaks silently
  • Passing too many roles to compound children (keep them single-purpose)
  • Performance issues if context updates too frequently

When to Avoid It

  • When components are too independent to be logically grouped.
  • When performance might suffer due to unnecessary re-renders from context updates.
  • If the shared state is too complex and can be better handled via other state management tools (like Zustand, Redux).

Example: Custom Tabs Component

Here’s a simplified example using React Context to wire compound components:

// Context setup
const TabsContext = React.createContext();

// Tabs (Parent)
const Tabs = ({ children }) => {
const [active, setActive] = useState(0);

return (
<TabsContext.Provider value={{ active, setActive }}>
{children}
</TabsContext.Provider>
);
};

// TabList (Wrapper)
const TabList = ({ children }) => <div>{children}</div>;

// Tab (Individual Tab)
const Tab = ({ index, children }) => {
const { active, setActive } = useContext(TabsContext);

return (
<button onClick={() => setActive(index)}>
{active === index ? <strong>{children}</strong> : children}
</button>
);
};

// TabPanels
const TabPanels = ({ children }) => {
const { active } = useContext(TabsContext);
return <div>{children[active]}</div>;
};

// TabPanel
const TabPanel = ({ children }) => <div>{children}</div>;

Usage:

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


<TabPanels>
<TabPanel>Here’s the overview content.</TabPanel>
<TabPanel>Here’s the settings content.</TabPanel>
</TabPanels>
</Tabs>

Why This Pattern Rocks

  • Expressive API: Consumers can write JSX that matches the mental model of the UI structure.
  • 🧱 Encapsulation: Logic is encapsulated inside the parent and context.
  • 🔄 Reusable: Easily portable to other projects or use cases.
  • ⚙️ Customizable: Allow developers to inject styling, handlers, or even wrap children.

Best Practices

  • Local Context Only: Use React.createContext inside the module and export only the components — not the context. Keeps the context scoped.
  • Validation: Add safety checks to ensure compound components are used correctly inside the parent.
  • Dev Experience: Enhance debugging with DevTools names (e.g., displayName = "Tab").

Advanced Tips

  • Add compound registration dynamically: You can use React.Children.map() to inspect and manipulate children if needed.
  • Introduce useCompoundContext hooks to abstract the context usage inside each subcomponent.

Summary Recommendation

Use Compound Components when building reusable, flexible UI elements like Tabs, Accordions, or Menus. They provide a clean developer experience and make your components feel like a natural React API. For small, one-off components, stick to simple props to avoid overengineering.

The Compound Component Pattern is an elegant solution when building component libraries or custom UI kits. It enables you to create components that feel natural and intuitive to use — like designing your own domain-specific language within JSX.