Building an Accordion with React Hooks.
February 26, 2020 ∙ 📓 7 min
According to Sematic UI, an accordion allows users to toggle the display of sections of content. In this post, we’ll build a highly reusable accordion component from scratch. We will use React and its hooks api.
An accordion component can be broken-down in following components:
- A toggle component to show and hide the content.
- A collapse component to wrap the content.
- And, a root component to glue everything.
Let’s name the root component <Accordion />
. Accordion
will be responsible for rendering its children with necessary data. It can be implemented using render props or compound components. We’ll use the compound component pattern because it makes Accordion
easy to reason about. Also, with the compound component pattern, our JSX is semantically more meaningful and beautiful 🙂.
The sample accordion JSX.
<Accordion>
<Accordion.Toggle>Click Me</Accordion.Toggle>
<Accordion.Collapse>Some collapsable text</Accordion.Collapse>
</Accordion>
Let’s design each of the accordion components.
<Accordion />
The Accordion
component acts as a container. The element
prop is used as the container element/component. The default value of the element
is set to div
. Accordion
also receives a few other props: onToggle
, activeEventKey
, etc.
Note: PropTypes package is used for type checking.
const Accordion = ({
element: Component,
activeEventKey,
onToggle,
children,
...otherProps
}) => {
return <Component {...otherProps}>{children}</Component>;
};
Accordion.propTypes = {
// Element or Component to be rendered as a parent for accordion.
element: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
// `eventKey` of the accordion/section which is active/open
activeEventKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
// onToggle callback. (eventKey) => void
onToggle: PropTypes.func
};
Accordion.defaultProps = {
// default render as div
element: 'div'
};
<Accordion.Toggle />
As the name suggests, Accordion.Toggle
toggles the content. It also receives the element
prop just like the Accordion
component. It takes an eventKey
prop mapped to a Accordion.Collapse
component.
const Toggle = ({
element: Component,
eventKey,
onClick,
children,
...otherProps
}) => {
return <Component {...otherProps}>{children}</Component>;
};
Toggle.propTypes = {
// Element or Component to be rendered as a toggle.
element: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
// `eventKey` of the content to be controlled.
eventKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number])
};
Toggle.defaultProps = {
element: 'div'
};
<Accordion.Collapse />
The Accordion.Collapse
component conditionally renders the content. Just like other components, it also receives an element
prop.
const Collapse = ({
element: Component,
eventKey,
children,
...otherProps
}) => {
return <Component {...otherProps}>{children}</Component>;
};
Collapse.propTypes = {
// Wrapper for target content.
element: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
// Event key for the content.
eventKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number])
};
Collapse.defaultProps = {
element: 'div'
};
We’ll export both Toggle
and Collapse
with Accordion
namespace.
// ...
// Accordion's code
// ...
Accordion.Toggle = Toggle;Accordion.Collapse = Collapse;
We have laid out the foundation. Let’s introduce other concepts.
useAccordionContext
Each accordion component needs some data from the Accordion
component. eg. Accordion.Collapse
needs to know the activeEventKey
to conditionally display content. Accordion.Toggle
needs to know the activeEventKey
to invoke the toggle callback with appropriate params.
We can manually pass the data as props
to each accordion component. But that will require the knowledge of their positions in the Accordion
component tree. React provides a better alternative for such uses cases: React Context. Context APIs help us to pass data through the component tree without having to pass props down manually at every level.
Let’s create a context for accordion.
AccordionContext.js
import React from 'react';
export default React.createContext(null);
Hook to access the accordion context.
import { useContext } from 'react';
import AccordionContext from '../AccordionContext';
const useAccordionContext = () => {
const context = useContext(AccordionContext);
if (!context) {
throw new Error(
'Accordion components are compound component. Must be used inside Accordion.'
);
}
return context;
};
export default useAccordionContext;
Accordion
initializes the AccordionContext
with an activeEventKey
attribute and an onToggle
method.
const Accordion = ({
element: Component,
activeEventKey,
onToggle,
children,
...otherProps
}) => {
const context = useMemo(() => { return { activeEventKey, onToggle }; }, [activeEventKey, onToggle]);
return ( <AccordionContext.Provider value={context}> <Component {...otherProps}>{children}</Component>
</AccordionContext.Provider> );
};
Accordion.Collapse
reads the activeEventKey
from the context. It renders its content if activeEventKey
equals to eventKey
.
import { useAccordionContext } from '../hooks';
const Collapse = ({
element: Component,
eventKey,
children,
...otherProps
}) => {
const { activeEventKey } = useAccordionContext(); return activeEventKey === eventKey ? ( <Component {...otherProps}>{children}</Component>
) : null;};
Accordion.Toggle
invokes the onToggle
function from context on click of the toggle component/element.
import { useAccordionContext } from '../hooks';
const useAccordionClick = (eventKey, onClick) => { const { onToggle, activeEventKey } = useAccordionContext(); return event => { onToggle(eventKey === activeEventKey ? null : eventKey); if (onClick) { onClick(event); } };};
const Toggle = ({
element: Component,
eventKey,
onClick,
children,
...otherProps
}) => {
const accordionClick = useAccordionClick(eventKey, onClick); return ( <Component onClick={accordionClick} {...otherProps}> {children} </Component> );};
After plugging everything together in App
with a Card
component:
import Accordion from './Accordion';
import Card from './Card';
const content = [
// items in {question, answer} format
// ...
// ...
];
export default function App() {
const [activeEventKey, setActiveEventKey] = useState(0);
return (
<div className="App">
<Accordion activeEventKey={activeEventKey} onToggle={setActiveEventKey}>
{content.map(({ question, answer }, index) => (
<Card key={index}>
<Accordion.Toggle element={Card.Header} eventKey={index}>
{question}
{activeEventKey !== index && <span>👇🏻</span>}
{activeEventKey === index && <span>👆🏻</span>}
</Accordion.Toggle>
<Accordion.Collapse eventKey={index} element={Card.Body}>
{answer}
</Accordion.Collapse>
</Card>
))}
</Accordion>
</div>
);
}
Controllability
The Accordion
component is a controlled component. It doesn’t own any state. It receives needed data as props(eg. active section key activeEventKey
, toggle handler onToggle
, etc). The controlled nature of Accordion
makes it very flexible but it has a downside. Consumer components have to explicitly manage their Accordion
’s state even when they don’t need it. Explicit state management can be tedious sometimes and may cause boilerplate.
We’ll add uncontrolled behavior to the accordion. In real-world apps, you might want to use uncontrollable.
To make Accordion
uncontrollable/controllable, we’ll introduce a local state in the accordion. We’ll keep it in sync with activeEventKey
prop. We’ll use useLayoutEffect instead of useEffect for syncing to avoid flickering(useEffect vs useLayoutEffect).
Enhanced Accordion
component with local state.
const useEventKey = (eventKey, onToggle) => { const [activeEventKey, setActiveEventKey] = useState(eventKey); useLayoutEffect(() => { setActiveEventKey(eventKey); }, [eventKey, onToggle]); return [activeEventKey, setActiveEventKey];};
const Accordion = ({
element: Component,
activeEventKey,
onToggle,
children,
...otherProps
}) => {
const [eventKey, setEventKey] = useEventKey(activeEventKey, onToggle); const handleToggle = useCallback( eventKey => { if (activeEventKey !== undefined) { onToggle(eventKey); return; } setEventKey(eventKey); }, [activeEventKey, onToggle, setEventKey] );
const context = useMemo(() => {
return { activeEventKey: eventKey, onToggle: handleToggle }; }, [eventKey, handleToggle]);
return (
<AccordionContext.Provider value={context}>
<Component {...otherProps}>{children}</Component>
</AccordionContext.Provider>
);
};
Accordion.defaultProps = {
// default render as div
element: 'div',
onToggle: () => {}};
Finally, App
component with both controlled and uncontrolled forms of the Accordion
.
export default function App() {
const [activeEventKey, setActiveEventKey] = useState(0);
return (
<div className="App">
<h3>Uncontrolled Accordion</h3> <Accordion> {content.map(({ question, answer }, index) => ( <Card key={index}> <Accordion.Toggle element={Card.Header} eventKey={index}> {index + 1}. {question} </Accordion.Toggle> <Accordion.Collapse eventKey={index} element={Card.Body}> {answer} </Accordion.Collapse> </Card> ))} </Accordion> <h3>Controlled Accordion</h3> <Accordion activeEventKey={activeEventKey} onToggle={setActiveEventKey}>
{content.map(({ question, answer }, index) => (
<Card key={index}>
<Accordion.Toggle element={Card.Header} eventKey={index}>
{index + 1}. {question}
{activeEventKey !== index && <span>👇🏻</span>}
{activeEventKey === index && <span>👆🏻</span>}
</Accordion.Toggle>
<Accordion.Collapse eventKey={index} element={Card.Body}>
{answer}
</Accordion.Collapse>
</Card>
))}
</Accordion>
</div>
);
}
Thanks for the reading 🙏🏻.
Hi, I am Hitesh.