Skip to main content

A Complete Guide To The Compound Component Pattern in React

Β· 7 min read
Pere Pages
Software Engineer

The Compound Component Pattern has become a popular approach in React for building flexible, reusable components. You'll often see it in two flavors: separate exports (<SelectTrigger />) or dot notation (<Select.Trigger />). But like any pattern, it comes with significant trade-offs. Let's explore both sides.


The Good πŸ‘β€‹

1. Excellent API Flexibility​

Compound components give consumers control over structure and composition without prop drilling. Instead of passing a dozen configuration props, you let developers compose the UI:

// Dot notation variant
<Select.Container>
<Select.Trigger>Choose an option</Select.Trigger>
<Select.Content>
<Select.Item value="1">Option 1</Select.Item>
<Select.Item value="2">Option 2</Select.Item>
</Select.Content>
</Select.Container>

// Separate exports variant
<SelectContainer>
<SelectTrigger>Choose an option</SelectTrigger>
<SelectContent>
<SelectItem value="1">Option 1</SelectItem>
</SelectContent>
</SelectContainer>

This is far more flexible than a monolithic component that takes arrays of options.

2. Separation of Concerns​

Each sub-component handles its own responsibility. The trigger manages interaction, the content handles display, and items manage selection. This aligns perfectly with React's compositional nature and makes code easier to reason about.

3. Implicit State Sharing​

Using React Context, compound components share state seamlessly without explicit prop passing. This keeps the parent-child relationship clean while maintaining internal coordination.

4. Reduced Prop Drilling​

No need for deeply nested props like triggerClassName, contentProps, itemStyles. Each component accepts its own props directly where it's used.

5. Clear Component Relationships (Dot Notation)​

When using dot notation, the namespace immediately signals which components belong together. You can see at a glance that Select.Trigger is part of the Select family, not a standalone component.

6. Reduced Import Clutter (Dot Notation)​

One import instead of four or five. Your import statements stay clean and manageable:

// Dot notation - one import
import { Select } from './Select';

// vs. Separate exports - multiple imports
import {
SelectContainer,
SelectTrigger,
SelectContent,
SelectItem
} from './Select';

7. Better Autocomplete Experience (Dot Notation)​

Type Select. and your IDE shows all available sub-components. This acts as built-in documentation and makes discovery easier for developers.

8. Easy to Extend​

Need a custom variant? Just add it as another sub-component. The pattern naturally supports extension without modifying the core implementation.

The Bad πŸ‘Žβ€‹

1. Hidden Complexity​

The magic of implicit state sharing through Context can be confusing for developers unfamiliar with the pattern. Debugging becomes harder when state flows through invisible channels.

// Where does 'value' come from? Not obvious at first glance
const SelectItem = ({ value, children }) => {
const { selected, onChange } = useSelectContext();
// ...
};

2. Rigid Component Structure​

Compound components often expect a specific structure. Move something outside the provider, and things break. This can be frustrating when you need more layout flexibility:

// This might break if Header expects to be direct child of Card
<Card.Container>
<div className="wrapper">
<Card.Header>Title</Card.Header> {/* Context lost? */}
</div>
</Card.Container>

3. Type Safety Challenges​

TypeScript struggles with compound patterns. Ensuring correct component nesting, required children, and proper prop combinations is difficult. You often lose the autocomplete and type checking benefits you'd get with simple props.

With dot notation, this gets even more complex:

// More complex type definitions required
interface SelectComponent extends React.FC<SelectProps> {
Container: React.FC<ContainerProps>;
Trigger: React.FC<TriggerProps>;
Content: React.FC<ContentProps>;
Item: React.FC<ItemProps>;
}

// Awkward assignment syntax
const Select = (props: SelectProps) => {
// implementation
} as SelectComponent;

Select.Container = SelectContainer;
Select.Trigger = SelectTrigger;
// ... etc

4. Learning Curve​

New team members need to understand Context, the specific pattern implementation, and which components must be used together. A simple props-based component is immediately understandable.

5. Performance Considerations​

Context-based state sharing means all consuming components re-render when context changes. While usually not a problem, it can cause performance issues in complex components with many children.

6. Testing Complexity​

Testing individual sub-components in isolation becomes harder since they depend on parent context. You often need to wrap them in the provider, making unit tests more complex.

With dot notation: Mocking Select.Trigger in tests can be trickier than mocking a standalone SelectTrigger export. Some testing libraries handle namespaced components differently.

7. Default Export Confusion (Dot Notation)​

What does Select itself render? Often it's just a container, but this isn't obvious. Some libraries make the main export a provider-only component, others make it renderable. This inconsistency can confuse users.

// Is this valid?
<Select>
<Select.Trigger />
</Select>

// Or do you need Container?
<Select.Container>
<Select.Trigger />
</Select.Container>

8. Tree-Shaking Concerns (Dot Notation)​

Some bundlers may struggle to tree-shake unused sub-components when they're attached as properties. Separate named exports are more reliably tree-shakeable.

9. Destructuring Limitations (Dot Notation)​

You can't destructure sub-components easily if you want to maintain the namespace:

// This loses the namespace context
const { Trigger, Content } = Select;

// Less clear what these are
<Trigger />
<Content />

Dot Notation vs. Separate Exports​

Choose Dot Notation When:​

  • Building a component library or design system
  • You have many related sub-components (4+)
  • The component family is cohesive and always used together
  • Developer experience and discoverability are priorities
  • You're willing to invest in proper TypeScript setup

Choose Separate Exports When:​

  • You have only 2-3 related components
  • Sub-components might be used independently
  • TypeScript complexity is a concern
  • Tree-shaking and bundle size are critical
  • The team prefers explicit imports

When to Use Compound Components βœ…β€‹

Use compound components when:

  • Building highly reusable UI libraries (dropdowns, tabs, accordions)
  • You need significant layout flexibility
  • The component has complex internal state coordination
  • You want to provide an intuitive, declarative API

Avoid when:

  • The component is simple with few configuration options
  • You need strict structure enforcement
  • The team is unfamiliar with the pattern
  • Type safety is critical and complex
  • Bundle size and tree-shaking are top priorities

Implementation Example (Dot Notation)​

// Select.tsx
import { createContext, useContext, useState } from 'react';
import type { ReactNode } from 'react';

interface SelectContextValue {
value: string;
onChange: (value: string) => void;
}

const SelectContext = createContext<SelectContextValue | null>(null);

const useSelectContext = () => {
const context = useContext(SelectContext);
if (!context) {
throw new Error('Select components must be used within Select.Container');
}
return context;
};

// Sub-components
const Container = ({
children,
defaultValue = ''
}: {
children: ReactNode;
defaultValue?: string;
}) => {
const [value, setValue] = useState(defaultValue);

return (
<SelectContext.Provider value={{ value, onChange: setValue }}>
<div className="select">{children}</div>
</SelectContext.Provider>
);
};

const Trigger = ({ children }: { children?: ReactNode }) => {
const { value } = useSelectContext();
return <button className="select-trigger">{children || value}</button>;
};

const Content = ({ children }: { children: ReactNode }) => {
return <div className="select-content">{children}</div>;
};

const Item = ({
value,
children
}: {
value: string;
children: ReactNode;
}) => {
const { value: selectedValue, onChange } = useSelectContext();
const isSelected = selectedValue === value;

return (
<div
className={`select-item ${isSelected ? 'selected' : ''}`}
onClick={() => onChange(value)}
>
{children}
</div>
);
};

// Type-safe composition
interface SelectNamespace {
Container: typeof Container;
Trigger: typeof Trigger;
Content: typeof Content;
Item: typeof Item;
}

export const Select: SelectNamespace = {
Container,
Trigger,
Content,
Item,
};
  • Radix UI: Separate exports (<SelectTrigger />, <SelectContent />)
  • Chakra UI: Dot notation (<Accordion.Item />, <Accordion.Panel />)
  • Reach UI: Separate exports (<TabList />, <TabPanels />)
  • Headless UI: Separate exports (<ListboxButton />, <ListboxOptions />)

Both approaches are valid! The choice often comes down to team preference and the scale of your component library.

The Verdict​

The compound component pattern is a powerful tool, particularly for design system components where flexibility and composability are paramount. However, it's not a silver bullet.

For modular, scalable, and maintainable code:

  • Small-to-medium projects: Use separate exports with clear naming (SelectTrigger). You get simpler TypeScript, better tree-shaking, and easier testingβ€”all aligned with maintainability goals.

  • Large design systems: Consider dot notation (Select.Trigger). Better organization and clearer relationships justify the initial setup cost when you have many component families.

For simple components or teams prioritizing type safety and explicit data flow, traditional prop-based approaches might serve you better. The key is recognizing when the pattern's benefits outweigh its complexity.

Start with simpler patterns first, and graduate to compound components when the use case truly demands the flexibility. In a modern React codebase with TypeScript, good documentation, and developers familiar with advanced patterns, compound components can create beautiful, maintainable APIsβ€”but they should be a deliberate architectural choice, not a default.