Skip to content

React Composition with Context – Clean State Sharing for Compound Components (Part 3)

Welcome to Part 3 of the React Composition series!

When your UI components start having multiple nested pieces (e.g. AccordionItemHeader + Content + Icon), passing props or cloning children everywhere becomes painful.
Context solves this beautifully by letting the root component provide state and behavior to any descendant — no drilling, no magic cloning.

This pattern is exactly what powers most modern headless UI libraries (Radix UI, Headless UI, Ark UI, shadcn/ui accordion, etc.).

Why Context + Composition Is So Powerful

  • Share state/behavior deeply without prop drilling
  • Sub-components remain independent and reusable
  • Usage stays clean and declarative (feels like native HTML)
  • Easy to extend (add multiple-open, keyboard navigation, animations…)
  • Matches how most professional component libraries are built today

Building a Clean, Modern Accordion with Context

Let’s evolve the accordion from Part 1 into a Context-based version with better accessibility and future-proofing.

1. Context & Provider (AccordionContext.jsx)

AccordionContext.jsx
import { createContext, useContext, useState } from 'react';
const AccordionContext = createContext(undefined);
export function AccordionProvider({ children, defaultOpen = null, allowMultiple = false }) {
const [openItems, setOpenItems] = useState(allowMultiple ? new Set() : new Set(defaultOpen ? [defaultOpen] : []));
const toggle = (id) => {
setOpenItems((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
if (!allowMultiple) next.clear();
next.add(id);
}
return next;
});
};
return (
<AccordionContext.Provider value={{ openItems, toggle }}>
{children}
</AccordionContext.Provider>
);
}
export function useAccordion() {
const context = useContext(AccordionContext);
if (context === undefined) {
throw new Error('useAccordion must be used within an <Accordion />');
}
return context;
}

2. Root Accordion Component

Accordion.jsx
import { AccordionProvider } from './AccordionContext';
export default function Accordion({ children, defaultOpen, allowMultiple = false }) {
return (
<AccordionProvider defaultOpen={defaultOpen} allowMultiple={allowMultiple}>
<div
role="presentation"
style={{
border: '1px solid #e2e8f0',
borderRadius: '8px',
overflow: 'hidden',
background: 'white',
}}
>
{children}
</div>
</AccordionProvider>
);
}

3. AccordionItem – Self-contained & Accessible

AccordionItem.jsx
import { useAccordion } from './AccordionContext';
import { useId } from 'react';
export function AccordionItem({ title, children, defaultOpen = false }) {
const { openItems, toggle } = useAccordion();
const baseId = useId(); // unique & stable across renders
const itemId = `accordion-item-${baseId}`;
const headerId = `${itemId}-header`;
const panelId = `${itemId}-panel`;
const isOpen = openItems.has(itemId);
// Support defaultOpen (useful when controlled externally)
// In real apps you might manage this at Accordion level
return (
<div>
<h3 style={{ margin: 0 }}>
<button
id={headerId}
aria-expanded={isOpen}
aria-controls={panelId}
onClick={() => toggle(itemId)}
style={{
width: '100%',
padding: '16px 20px',
background: isOpen ? '#f1f5f9' : 'transparent',
border: 'none',
borderBottom: '1px solid #e2e8f0',
fontSize: '1.125rem',
fontWeight: isOpen ? 600 : 500,
textAlign: 'left',
cursor: 'pointer',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
transition: 'background-color 0.2s ease',
}}
>
{title}
<span style={{ fontSize: '0.9em', opacity: 0.7 }}>
{isOpen ? '' : ''}
</span>
</button>
</h3>
<div
id={panelId}
role="region"
aria-labelledby={headerId}
hidden={!isOpen}
style={{
padding: isOpen ? '20px' : '0 20px',
background: '#f8fafc',
transition: 'padding 0.3s ease-out',
overflow: 'hidden',
}}
>
{children}
</div>
</div>
);
}

4. Beautiful, Declarative Usage

import Accordion from './Accordion';
import { AccordionItem } from './AccordionItem';
function FAQSection() {
return (
<Accordion allowMultiple defaultOpen="accordion-item-:r1:">
<AccordionItem title="What is React Context best for?">
Sharing global-ish state across component sub-trees without prop drilling — especially useful in compound component patterns.
</AccordionItem>
<AccordionItem title="When is Context better than cloneElement?">
<ul style={{ paddingLeft: '1.5rem', margin: '1rem 0' }}>
<li>Deeply nested components</li>
<li>Independent, reusable sub-components</li>
<li>Design systems & headless UI libraries</li>
<li>When you want to avoid cloning magic</li>
</ul>
</AccordionItem>
<AccordionItem title="Is this accessible out of the box?">
Yes! We use <code>useId()</code>, proper ARIA roles, heading structure, and button semantics — ready for screen readers.
</AccordionItem>
</Accordion>
);
}

Benefits at a Glance

  • No more cloneElement or manual index management
  • Any child (even deeply nested) can consume context with one hook
  • Easy to add allowMultiple, animations, keyboard nav later
  • Familiar pattern used by Radix, Headless UI, shadcn/ui, etc.
  • Clear error when used incorrectly

Comparison Table – Which Pattern to Choose?

PatternBest ForBoilerplateDeep NestingAccessibilityScalability
children onlySimple wrappersVery lowNo stateManualLow
cloneElementShallow compound componentsMediumGets messyManualMedium
Context (this part)Deep trees, reusable sub-parts, libsMediumExcellentEasyHigh