Smelly Code

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.

Accordion Image
A sample accordion ( Codesandbox )

An accordion component can be broken-down in following components:

  1. A toggle component to show and hide the content.
  2. A collapse component to wrap the content.
  3. 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.

|