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. AccordionItem → Header + 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)
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
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
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
cloneElementor 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?
| Pattern | Best For | Boilerplate | Deep Nesting | Accessibility | Scalability |
|---|---|---|---|---|---|
| children only | Simple wrappers | Very low | No state | Manual | Low |
| cloneElement | Shallow compound components | Medium | Gets messy | Manual | Medium |
| Context (this part) | Deep trees, reusable sub-parts, libs | Medium | Excellent | Easy | High |