Have you ever built a React component that started clean — and a couple of days later had a billion props, conflicting booleans, and a README no one trusts?
Each new boolean doubles the number of possible UI states. By the time you have six booleans, you’re supporting 64 UI variants — most of them undocumented.
Reusable UI is not about writing less code — it’s about designing APIs that survive change.
Most React components don’t fail because of bugs; they fail because their APIs collapse under real-world requirements.
This article is for developers building shared components, design systems, or complex UI primitives.
We’ll walk through how and why to build reusable UI using:
Compound Components
Render Props
A real Accordion implementation
Trade-offs, alternatives, and improvements
The Core Problem: Prop-Driven APIs Don’t Scale
Most components start with something like this:
const [activeItems, setActiveItems] = useState<string[]>([])
const toggleItem = (id: string)=> { ... }
<Accordion
activeItems={activeItems}
onToggle={toggleItem}
allowMultiple
showIcon
className='...'
classNames={{
header:'...',
body:'...',
icon:'...',
}}
/>
At first, it feels fine, then requirements grow:
Different layouts
Custom headers
Conditional behavior
Design system constraints
Suddenly:
- Props explode
Accordion
├── activeItems
├── allowMultiple
├── showIcon
├── animated
├── collapsible
├── headerLayout
└── onToggle
↓
Interdependent logic
spread across consumers
Each prop looks innocent in isolation, but together they create hidden coupling and undocumented behavior.
Usually, the logic is spread across the component’s internals, while the consumer has to manage the state. So the component becomes a “black box” that is hard to tweak.
Small changes break everything.
The root issue: you’re encoding layout and behavior into props.
We’ll refactor this into a compound component by the end.
The Mental Model Shift
Before writing code, we need the right mental model.
Old model (Prop-Driven)
“I configure a component.”
Component dictates layout.
Limited to predefined props.
Increases complexity with every feature.
New model (Composition-Driven)
“I assemble behavior from parts.”
Consumer dictates layout.
High flexibility with wrapping and reordering elements.
Complexity stays flat; parts are isolated.
Instead of telling a component what it should look like, we give it state and rules, and let the consumer decide the structure. This is where Compound Components and Render Props shine.
This shift changes how you design components — not just how you implement them.
What Are Compound Components?
Compound Components allow you to expose a set of related components that:
Share state implicitly
Work only inside a specific parent
Form a declarative, readable API
This feels natural because it mirrors how we already think about HTML: select /option and similar HTML pairs, behave like compound components.
Example usage:
<Accordion>
<Accordion.Item itemId="one">
<Accordion.Header>Title</Accordion.Header>
<Accordion.Body>Content</Accordion.Body>
</Accordion.Item>
</Accordion>
No prop drilling.
No configuration hell.
The structure explains itself.
The Mental Model of Compound Components
Think of it like this:
Accordion (state owner)
├── Item (scopes state)
│ ├── Header (reads + triggers)
│ └── Body (reads)
State flows down, events flow up — but only within a narrow scope.
Accordion owns the global state
Item narrows that state to a single item
Header / Body consume only what they need
Each layer reduces responsibility and knows less — that’s the point, and each component can be understood in isolation.
This is what enables compound components to be maintainable at scale.
Building the Accordion from Scratch
1. State Design
const [activeItems, setActiveItems] = useState<Set<string>>(...);
const toggleItem = useCallback(
(itemId: string) => {
if (allowMultiple) {
setActiveItems(prev => {
const newItems = new Set(prev);
if (newItems.has(itemId)) {
newItems.delete(itemId);
} else {
newItems.add(itemId);
}
return newItems;
});
return;
}
setActiveItems(prev =>
prev.has(itemId) ? new Set([]) : new Set([itemId])
);
},
[allowMultiple]
);
The accordion state needs to answer one question efficiently: Is this item open?
Why Set?
O(1) lookups
Prevents duplicate IDs automatically
Natural fit for “multiple open items.”
Clear semantic intent
This decision alone enables reuse across multiple UX patterns — and makes it easy to support:
Both controlled and uncontrolled usage.
-
Both single and multiple open items.
This example focuses on uncontrolled usage; a controlled version would accept value and onChange and derive activeItems externally.
2. Context as an Internal Contract
type AccordionContextValue = {
activeItems: Set<string>;
toggleItem: (id: string) => void;
};
Context is used internally — not as a public API. Developers should never need to think about context to use your component.
Public API
-------------------
<Accordion>
<Accordion.Item />
</Accordion>
Internal API
----------------------
AccordionContext
├── activeItems
└── toggleItem
Consumers depend on components, not on how those components work internally.
If consumers depend on your context shape, you’ve leaked your internals.
Compound components enable you to update everything behind the scenes without breaking the consumers.
This is key:
Consumers compose — they don’t manage state.
3. Creating contexts
First, let’s create the contexts
import { createContext, useContext } from 'react';
// Accordion Context
type AccordionContextValue = {
allowMultiple?: boolean;
activeItems: Set<string>;
toggleItem: (id: string) => void;
};
export const AccordionContext = createContext<AccordionContextValue | null>(
null
);
export const useAccordion = () => {
const accordionContext = useContext(AccordionContext);
if (!accordionContext) {
throw new Error('useAccordion must be used within <Accordion />');
}
return accordionContext;
};
// Accordion Item Context
type AccordionItemContextValue = {
id: string;
isActive: boolean;
};
export const AccordionItemContext =
createContext<AccordionItemContextValue | null>(null);
export const useAccordionItem = () => {
const accordionItemContext = useContext(AccordionItemContext);
if (!accordionItemContext) {
throw new Error('useAccordionItem must be used within <AccordionItem />');
}
return accordionItemContext;
};
Using AccordionContext.Provider
const getInitialValue = (defaultValue?: string | string[]): string[] => {
if (!defaultValue) return [];
if (Array.isArray(defaultValue)) return defaultValue;
return [defaultValue];
};
function Accordion({ children, allowMultiple, defaultValue }: Props) {
const [activeItems, setActiveItems] = useState<Set<string>>(
() => new Set(getInitialValue(defaultValue))
);
const toggleItem = useCallback(
(itemId: string) => {
if (allowMultiple) {
setActiveItems(prev => {
const newItems = new Set(prev);
if (newItems.has(itemId)) {
newItems.delete(itemId);
} else {
newItems.add(itemId);
}
return newItems;
});
return;
}
setActiveItems(prev =>
prev.has(itemId) ? new Set([]) : new Set([itemId])
);
},
[allowMultiple]
);
const value = useMemo(
() => ({ allowMultiple, activeItems, toggleItem }),
[allowMultiple, activeItems, toggleItem]
);
return (
<AccordionContext.Provider value={value}>
{children}
</AccordionContext.Provider>
);
}
4. Scoping with Accordion.Item
type ItemProps = {
children: Children<{ isActive: boolean }>;
itemId: string;
};
function AccordionItem({ children, itemId }: ItemProps) {
const { activeItems } = useAccordion();
const isActive = activeItems.has(itemId);
const { element } = renderChildren(children, { isActive });
return (
<AccordionItemContext.Provider value={{ id: itemId, isActive }}>
{element}
</AccordionItemContext.Provider>
);
}
Why a second context?
Because the Header and Body should not care about the entire accordion.
They only care about this item.
Headers don’t know about other items, and don’t need to.
Bodies don’t know how toggling works.
This prevents abstraction leakage.
Without this layer, every subcomponent would need to understand the global accordion state.
That’s how abstractions leak — and complexity spreads.
This dramatically reduces coupling.
5. Render Props — Without Forcing Them
Render Props aren’t outdated — they’re just specialized.
They shine when you want escape hatches without committing to new APIs.
<Accordion.Header>
{({ isActive }) => <button>{isActive ? 'Open' : 'Closed'}</button>}
</Accordion.Header>
And:
<Accordion.Header>Title</Accordion.Header>
Internally, Accordion.Header checks whether children is a function and calls it if so.
This hybrid approach is powerful because:
Beginners get clean JSX
Advanced users get full control
No API duplication
How does that work? With a simple utility, we can support Render Prop while preserving normal children.
export type Children<T> = React.ReactNode | ((props: T) => React.ReactNode);
export const renderChildren = <T>(children: Children<T>, props: T) => {
if (typeof children === 'function')
return {
element: children(props),
isRenderProp: true,
};
return {
element: children,
isRenderProp: false,
};
};
This avoids two APIs.
Consumers opt in to power only when needed.
Default usage stays clean.
Usage in Accordion.Header :
type HeaderProps = {
children: Children<{ isActive: boolean; onClick: () => void }>;
className?: string;
};
function AccordionHeader({ children, className }: HeaderProps) {
const { isActive, id } = useAccordionItem();
const { toggleItem } = useAccordion();
const handleToggle = useCallback(() => toggleItem(id), [id, toggleItem]);
const { element, isRenderProp } = renderChildren(children, {
isActive,
onClick: handleToggle,
});
if (isRenderProp) return element;
return (
<button
onClick={handleToggle}
type="button"
aria-expanded={isActive}
aria-controls={`panel-${id}`}
id={`button-${id}`}
className={clsx(
'flex items-center justify-between w-full cursor-pointer px-3 py-2',
!isActive && 'border-b border-gray-400',
className
)}
>
{element}
<ChevronDown
className={clsx('transition-all h-5 w-5', isActive && 'rotate-180')}
/>
</button>
);
}
Note that using Render Prop gives “Total Control” to the user, meaning the user is responsible for the interaction element.
clsx is a small utility for constructing className strings conditionally.
Consider adding aria- attributes for better accessibility (A11y), and type='button' to prevent form submission.
You should also handle keyboard navigation (Arrow keys, Home/End).
We didn’t add a new prop.
We didn’t branch the API.
-
We didn’t break existing users.
Render Props are just specialized.
6. The Body: Consuming the State
type BodyProps = {
children: Children<{ isActive: boolean }>;
};
function AccordionBody({ children }: BodyProps) {
const { isActive, id } = useAccordionItem();
const { element } = renderChildren(children, { isActive });
return (
<div
id={`panel-${id}`}
aria-labelledby={`button-${id}`}
className={clsx(
'overflow-hidden',
isActive ? 'h-fit border-b border-gray-400' : 'h-0'
)}
>
{element}
</div>
);
}
The body consumes the isActive state to decide whether to collapse or show content.
7. Wrapping it all together
After building each component Accordion Root, Item, Header, Body We can export each component alone, or attach them to the Root component and export it alone with simple lines
Accordion.Item = AccordionItem;
Accordion.Header = AccordionHeader;
Accordion.Body = AccordionBody;
export { Accordion };
The nice thing about this implementation:
You import a single component and use all its sub-components.
TypeScript understands it well, and you get full type-safe components.
Example Usage
So far, we’ve talked about principles and partial snippets.
Let’s ground this in an example:
import { Accordion } from './components/accordion';
import { FAQs } from './data/faqs';
function App() {
return (
<Accordion allowMultiple>
{FAQs.map(faq => (
<Accordion.Item key={faq.id} itemId={faq.id}>
<Accordion.Header>{faq.question}</Accordion.Header>
<Accordion.Body>{faq.answer}</Accordion.Body>
</Accordion.Item>
))}
</Accordion>
);
}
export default App;
Another example use-case
- Building a Modal component with Open and Close components > These examples illustrate the pattern — not complete implementations.
function App() {
return (
<Modal>
<Modal.Window name="window-1">{...}</Modal.Window>
<Modal.Open name="window-1">
<Button>Open Modal</Button>
</Modal.Open>
<Modal.Close name="window-1">
<Button>Close Modal</Button>
</Modal.Close>
</Modal>
);
}
What’s powerful here is that
You can render the window in one component and open or close it from anywhere inside the Modal context.
You can export a hook useModal and control the modal state programmatically.
function App() {
const { openWindow, closeWindow } = useModal();
const someHandler = () => {
// Do some logic
openWindow('window-1');
};
}
Notice how the mental model is identical: One state owner. Many declarative consumers.
2. Building a File input component with Trigger, Preview, Dropzone, and even Upload Error components
Building a FileInput Component is a nightmare with standard props, especially if you need to include dropzone, preview, and remove files. And it becomes unmaintainable if the preview layout needs to vary across the app.
See this example with Compound Components and Render Prop.
Here we can see how the Render Prop shines.
function App() {
return (
<FileInput>
<FileInput.Trigger>
<Button>Upload Image</Button>
</FileInput.Trigger>
<FileInput.Dropzone>{/**
* Add a styled dropzone
* You can add The `FileInput.Trigger` here, it's OK!
*/}
</FileInput.Dropzone>
<FileInput.Preview>
{(files, removeFile) => (
<div>
{/**
* Control how each file is displayed and styled
* Add delete button any where you want
*/}
</div>
)}
</FileInput.Preview>
</FileInput>
);
}
Improving the Accordion: Performance & Architecture
At this point, the API is already solid. These improvements are about scale, not correctness, and matter in libraries — not in every app.
Don’t cargo-cult them.
Improvement 1: Split Contexts for Fewer Re-renders
Currently:
- Any change in activeItems re-renders all consumers
We can split contexts:
type AccordionValue = {
allowMultiple?: boolean;
activeItems: Set<string>;
};
const AccordionStateContext = createContext<AccordionValue | null>(null);
type AccordionActions = { toggleItem: (id: string) => void };
const AccordionActionsContext = createContext<AccordionActions | null>(null);
Now:
This can reduce unnecessary re-renders when combined with memoization.
Actions can be kept stable.
These optimizations matter most when your components are widely reused, or if you have 100 accordion items open at once, for example.
Warning: This adds complexity — measure before optimizing.
Improvement 2: Extract a Headless Hook
function useAccordionState({ defaultActive, allowMultiple }) {
// state + logic only
}
Benefits:
Testable without UI
Reusable in different components
Cleaner separation of concerns
This turns the Accordion into a headless + styled hybrid
Improvement 3: Memoize Subcomponents Strategically
const AccordionHeader = memo(function AccordionHeader(...) { ... });
Not everywhere — only where re-renders are costly.
Trade-offs
Pros
Extremely flexible API.
Clear separation of concerns.
Scales well in design systems.
Excellent developer experience.
Simplifying parts of Accessibility (A11y), because we control the id in the Accordion.Item context, we can automatically link aria-controls and aria-labelledby without the consumer ever having to manually pass IDs.
Cons
More code upfront.
Potential context-based re-render cost.
Harder debugging than simple prop-based components.
Not suitable for trivial components.
When Should You Use This Pattern?
Use Compound Components when:
Layout varies
Consumers need composition freedom
You’re building a shared UI library
API longevity matters
Avoid them when:
The component is simple
Performance is extremely sensitive
There’s only one valid layout
Limits of Compound Components
Compound components are powerful — but they have limits. Understanding them is crucial.
- Too much freedom can hurt consistency.
Consumers can reorder or omit subcomponents, breaking UX or accessibility. Design systems must define safe composition boundaries.
Unlike props, requirements (e.g., which subcomponents are required) aren’t explicit. Clear documentation is essential.
Even TypeScript cannot fully enforce:
Correct composition order
Required subcomponents
Semantic correctness
2. Debugging & performance
Logic is spread across context, hooks, and Render Props, which can make bugs hard to trace. Heavy children or large lists may cause unexpected re-renders.
3. Not every component deserves it
The biggest mistake is overusing compound components. They are a poor fit when:
The layout is fixed
There’s only one valid structure
The component is trivial
The API is unlikely to evolve
For example:
Buttons
Icons
Badges
Avatars
In these cases, compound components add:
Unnecessary abstraction
More files
Harder onboarding
Avoid them for trivial or fixed-layout components — here, simple props are simpler and safer.
In short: compound components trade explicit configuration for compositional flexibility. Use deliberately, not by default.
Final Thought
Compound Components and Render Props are tools — not goals.
Reusable UI is not about clever or trendy patterns; it’s about respecting change.
The compound components pattern is a very powerful pattern, used in many UI libraries, like:
Radix UI: see Radix UI Tabs
MUI: see MUI React Stepper
You can find the full Accordion implementation on GitHub.
If this article was useful, consider starring the repo and connecting with me on LinkedIn.
Top comments (0)