Building Reusable UI in React: Compound Components, Render Props, and API Design
Source: Dev.to
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([]);
const toggleItem = (id: string) => { /* … */ };
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.
- The logic is often spread across the component’s internals while the consumer must manage state, turning the component into a “black box” that is hard to tweak.
- Small changes break everything.
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
Old model (Prop‑Driven)
“I configure a component.”
- Component dictates layout.
- Limited to predefined props.
- Complexity grows with every new 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.
What Are Compound Components?
Compound Components expose a set of related components that:
- Share state implicitly.
- Work only inside a specific parent.
- Form a declarative, readable API.
They feel natural because they mirror HTML patterns.
Example usage
<Accordion>
<Accordion.Item>
<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
Accordion (state owner)
├─ Item (scopes state)
│ ├─ Header (reads + triggers)
│ └─ Body (reads)
- Accordion owns the global state.
- Item narrows that state to a single item.
- Header / Body consume only what they need.
State flows down, events flow up — but only within a narrow scope. Each layer reduces responsibility, making every component understandable in isolation. This isolation 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.
The 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;
};
The context is used internally to share state and actions between Accordion, Item, Header, and Body without exposing the implementation details to the consumer.
Public vs Internal API
Public API
-------------------
<Accordion>
<Accordion.Item>
<Accordion.Header />
<Accordion.Body />
</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.
Key point: Consumers compose — they don’t manage state.
Creating 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 ctx = useContext(AccordionContext);
if (!ctx) {
throw new Error('useAccordion must be used within <Accordion>');
}
return ctx;
};
// Accordion Item Context
type AccordionItemContextValue = {
id: string;
isActive: boolean;
};
export const AccordionItemContext = createContext<AccordionItemContextValue | null>(null);
export const useAccordionItem = () => {
const ctx = useContext(AccordionItemContext);
if (!ctx) {
throw new Error('useAccordionItem must be used within <Accordion.Item>');
}
return ctx;
};
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>
);
}
Scoping with Accordion.Item
type ItemProps = {
children: React.ReactNode;
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?
- Header and Body only need to know about their own item.
- Prevents abstraction leakage.
- Keeps each sub‑component focused, reducing coupling and complexity.
Accordion Component Implementation
Header Component
type HeaderProps = {
children: Children<{ isActive: boolean; onClick: () => void }>;
className?: string;
};
function AccordionHeader({ children, className }: HeaderProps) {
const { toggleItem } = useAccordion();
const { id, isActive } = useAccordionItem();
const handleClick = () => toggleItem(id);
const { element } = renderChildren(children, {
isActive,
onClick: handleClick,
});
return <div className={className}>{element}</div>;
}
Render‑prop support
export type Children<T = any> = 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 };
};
Body Component
type BodyProps = {
children: Children;
};
function AccordionBody({ children }: BodyProps) {
const { isActive } = useAccordionItem();
const { element } = renderChildren(children, { isActive });
return <div>{element}</div>;
}
Exporting the Compound Component
Accordion.Item = AccordionItem;
Accordion.Header = AccordionHeader;
Accordion.Body = AccordionBody;
export { Accordion };
Benefits
- Import a single component and use all its sub‑components.
- TypeScript understands the structure, giving you full type safety.
Example Usage
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;
Additional Use‑Cases
Modal Component with Open/Close Sub‑Components
function App() {
return (
<>
<Modal>
<Modal.Trigger>Open Modal</Modal.Trigger>
<Modal.Window>
<Modal.Close>Close Modal</Modal.Close>
</Modal.Window>
</Modal>
</>
);
}
- Render the window anywhere and open/close it from any descendant via the Modal context.
- Export a
useModalhook for programmatic control:
function App() {
const { openWindow, closeWindow } = useModal();
const someHandler = () => {
// Do some logic
openWindow('window-1');
};
}
Mental Model: One state owner, many declarative consumers.
File Input Component with Trigger, Preview, Dropzone, and Error
function App() {
return (
<FileInput>
<FileInput.Trigger>Upload Image</FileInput.Trigger>
<FileInput.Dropzone />
<FileInput.Preview>
{(files, removeFile) => (
<>
{/* Render each file with a delete button */}
</>
)}
</FileInput.Preview>
</FileInput>
);
}
The render‑prop pattern shines here, allowing full control over the preview layout while keeping the component API clean.
Improving the Accordion: Performance & Architecture
Improvement 1 – Split Contexts for Fewer Re‑renders
Current situation
- Any change in
activeItemsre‑renders all consumers.
Proposed change
type AccordionState = {
allowMultiple?: boolean;
activeItems: Set<string>;
};
const AccordionStateContext = createContext<AccordionState | null>(null);
type AccordionActions = {
toggleItem: (id: string) => void;
};
const AccordionActionsContext = createContext<AccordionActions | null>(null);
Result
- Reduces unnecessary re‑renders when combined with memoization.
- Keeps action functions stable.
These optimizations matter most when components are widely reused or when you have many accordion items open simultaneously (e.g., 100 items).
⚠️ Warning: Adding extra contexts increases complexity. Measure before optimizing.
Improvement 2 – Extract a Headless Hook
function useAccordionState({
defaultActive,
allowMultiple,
}: { defaultActive?: string[]; allowMultiple?: boolean }) {
// state + logic only
}
Benefits
- Testable, reusable logic separated from UI.
- Can be shared across different compound components (e.g., tabs, collapsible panels).