In modern React development, we often strive for the holy grail: components that are both flexible enough to handle diverse use cases and maintainable enough to not become a development bottleneck. But let's be honest, building a simple UI element like an Accordion often leads to tightly coupled, rigid code that's a pain to customize.
Today, we’re going to break that cycle. We’ll explore how to build a robust Accordion component using the powerful Compound Component Pattern combined with React's Context API. This approach delivers an exceptional developer experience, a clean API, and a beautiful separation of concerns.
What We're Building
Our end product will be a highly composable Accordion featuring:
- Compound Component Pattern for an intuitive, HTML-like API.
- Context API for clean, global state management within the component boundary.
- Smooth animations using CSS transforms.
- Accessibility features with proper ARIA roles.
- Tailwind CSS for rapid, utility-first styling.
The Problem with Traditional Approaches
Before diving into the solution, it’s worth highlighting why the traditional "prop-heavy" approach to components often fails:
JavaScript
// ❌ Traditional approach - rigid and inflexible
<Accordion
title="My Title"
content="My content"
isOpen={false}
onToggle={handleToggle}
/>
The Issues:
- Limited Customization: Want to put an icon or a button inside the title? Good luck passing a complex JSX tree through a simple
titleprop. - Tightly Coupled: The parent
Accordioncomponent owns all the structure, making it a monolithic block that's hard to refactor. - Poor Developer Experience: The component's internal structure is hidden, limiting the developer's control.
Our Solution: The Compound Component Pattern
The Compound Component Pattern is a game-changer for complex UI. It allows us to create components that implicitly work together while maintaining their independence. Think of it like native HTML elements: the <select> and <option> tags are separate but achieve nothing without each other.
Step 1: Setting Up the Context
The Context API is the glue that binds our separate components together. It allows us to share the essential state—specifically, whether the accordion is open or closed—without drilling props down the component tree.
JavaScript
import React, { useContext, useState, createContext } from "react"
const AccordianContext = createContext({ open: false })
Why Context?
- It eliminates prop drilling, keeping the component tree clean.
- It provides a single source of truth for state management within the Accordion.
- It enables composition, allowing any child component to tap into the state.
Step 2: The Main Accordian Component
The main Accordian component is primarily a Context Provider and a state manager. It defines the behavior for all its children.
JavaScript
function Accordian({ children }) {
const [open, setOpen] = useState(false)
return (
<AccordianContext.Provider value={{ open, setOpen }}>
<div className="relative overflow-y-hidden">
{children}
</div>
</AccordianContext.Provider>
)
}
Key Design Decisions:
- Local State: Simple
useStatemanages the open/closed state for this specific instance. - Container Styling: We use
relativepositioning andoverflow-y-hiddento properly contain the content, which is crucial for our animation strategy.
Step 3: The Title Component
The Title component is responsible for user interaction—it must toggle the state of the parent Accordian.
JavaScript
function Title({ children }) {
const { setOpen } = useContext(AccordianContext)
return (
<div
onClick={() => setOpen((prev) => !prev)}
role="button"
className="bg-green-400 min-w-64"
>
{children}
</div>
)
}
The Magic: It only consumes the setOpen function from the context. It doesn't know how the state is managed, only that it can toggle it.
Step 4: The Content Component
The Content component's job is to conditionally display its children based on the state. It reads the open value from the context.
JavaScript
function Content({ children }) {
const { open } = useContext(AccordianContext)
return (
<div className={`absolute bottom-0 left-0 bg-red-400 ${open ? "translate-y-full" : ""}`}>
{children}
</div>
)
}
Animation Strategy: We utilize CSS Transforms (translate-y-full) for smooth, GPU-accelerated movement. By positioning the content absolute and using a conditional class, we achieve a dynamic slide animation.
Step 5: Component Composition (The Final Touch)
To create a clean, namespaced API—like Accordian.Title—we attach the sub-components to the main Accordian function.
JavaScript
Accordian.Title = Title
Accordian.Content = Content
export default Accordian
Why This Pattern?
- Intuitive API: The nested structure (
<Accordian.Title>) makes perfect sense. - Namespace Protection: It prevents global naming conflicts.
- Discoverability: IDE autocomplete immediately suggests the available parts (
.Title,.Content).
Usage Example: A Beautiful API
The final usage is elegant and highly readable, delivering that "HTML-like" experience we were aiming for.
JavaScript
function App() {
return (
<div className="flex flex-col items-center justify-center w-screen h-screen bg-blue-100">
<Accordian>
<Accordian.Title>
<span>Accordion 1 Section Title</span>
</Accordian.Title>
<Accordian.Content>
<span>Accordion 1 content line 1</span>
<span>Accordion 1 content line 2</span>
<span>Accordion 1 content line 3</span>
</Accordian.Content>
</Accordian>
</div>
)
}
Why This Design Works So Well
1. Flexibility is Baked In
The children prop in Title and Content allows for unlimited customization. You are not restricted by simple string props.
JavaScript
// Easy to customize each part
<Accordian>
<Accordian.Title>
{/* Full control over the Title's inner HTML and classes */}
<h2 className="text-xl font-bold text-indigo-700">Custom Title with Icon</h2>
</Accordian.Title>
<Accordian.Content>
<div className="p-4 border-l-4 border-indigo-500">
<p>Rich content with any HTML structure, including lists or forms!</p>
</div>
</Accordian.Content>
</Accordian>
2. Effortless Composability
Since each Accordian component manages its own isolated state, you can drop multiple instances anywhere in your application, and they will all function independently.
JavaScript
// Multiple accordions work independently
<Accordian><Accordian.Title>Section 1</Accordian.Title><Accordian.Content>Content 1</Accordian.Content></Accordian>
<Accordian><Accordian.Title>Section 2</Accordian.Title><Accordian.Content>Content 2</Accordian.Content></Accordian>
3. High Maintainability
- Single Responsibility: Each sub-component has one clear job (manage state, render title, render content).
- Simple to Test: You can unit test the logic of
Titlewithout worrying about howContentis rendered. - Easy to Extend: Adding a new feature, like a custom "icon" component, is as simple as creating
Accordian.Iconand having it consume the same context.
Advanced Features & Performance Considerations
While our initial implementation is functional, let's look at how we'd productionize it.
Animation and Accessibility
We can dramatically improve the user experience by adding a CSS transition property and proper ARIA attributes:
JavaScript
// 1. Add transition classes for smoother animations
<div className={`absolute bottom-0 left-0 bg-red-400 **transition-transform duration-300 ease-in-out** ${open ? "translate-y-full" : ""}`}>
// ...
// 2. Add ARIA attributes for screen readers
<div
role="button"
**aria-expanded={open}**
**aria-controls="accordion-content"** // Requires generating a unique ID
tabIndex={0}
>
// ...
Performance Considerations
Context often gets a bad rap for performance due to unnecessary re-renders. We can mitigate this with standard React optimizations:
JavaScript
// Memoize context value to prevent unnecessary re-renders
const contextValue = useMemo(() => ({ open, setOpen }), [open])
// ...
// Or, memoize components that don't need frequent updates
const Title = React.memo(({ children }) => {
// Component implementation
})
Testing Strategy: Confidence in Composition
Testing the Compound Component Pattern is straightforward because of the clean separation of concerns.
1. Unit Tests (Focusing on Behavior)
We test the Title component's core behavior—does it toggle the state?
JavaScript
test('Title toggles accordion state', () => {
render(
<Accordian>
<Accordian.Title>Test Title</Accordian.Title>
</Accordian>
)
fireEvent.click(screen.getByRole('button'))
// Assert that the state has changed (e.g., check for an accessibility attribute)
})
2. Integration Tests (Focusing on the Full Flow)
We test the full interaction flow to ensure the components work together as intended.
JavaScript
test('Accordion opens and closes content', () => {
render(
<Accordian>
<Accordian.Title>Title</Accordian.Title>
<Accordian.Content>Content</Accordian.Content>
</Accordian>
)
// 1. Assert content is initially hidden
// 2. Click the title
// 3. Assert content is now visible
})
View / Download the complete solution from github
Conclusion
The Compound Component Pattern, fueled by React's Context API, is a robust pattern for building reusable, flexible components that truly scale. Our Accordion implementation serves as a perfect demonstration of its power:
- Clean API Design: Intuitive and easy for developers to grasp.
- Separation of Concerns: Each part is simple to understand, test, and maintain.
- Flexibility: Content can be entirely customized without touching the core logic.
This pattern is a foundational tool for building professional-grade design systems. It achieves a balance between exposing internal controls and maintaining component integrity, resulting in a developer experience that feels as natural and intuitive as working with native HTML.