An Accordion Story + React Context API

An Accordion Story + React Context API

An accordion is a perfect case where you have a centralized state that needs to be shared across each of the accordions panels

Picked this case ๐Ÿ˜, because is a good one where we can get the power of Context API to have access to a central state and modify the other components that are subscribed to the same context.

Using Context API you are not restricted to share state or values, you can also share functions ๐ŸŽ‰ down the tree instead of drilling props.

This Accordion example is not a perfect solution but demonstrates how common UI patterns (as it is) are possible to rely on Context API.

Content

  1. Define the types
  2. Define Context
  3. Create a reusable hook
  4. Create the accordion panel
  5. Setup and add some text to panels
  6. Conclusions

Define types

First, we create a folder called accordion and there create types.ts, where are going to define our accordion API types, I'm calling it API because they will be types to communicate from the app to the accordion, vice-versa or between each of them internally:

accordion/types.ts

export type AccordionPanelIndex = number | string;

export type AccordionPanel = {
  index: AccordionPanelIndex;
  expanded: boolean;
};

export type AccordionContextInterface = {
  accordionPanels: AccordionPanel[];
  registerAccordionPanel(index: AccordionPanelIndex, expanded?: boolean): void;
  unregisterAccordionPanel(index: AccordionPanelIndex): void;
  toggle(index: AccordionPanelIndex, force?: boolean): void;
};

export type AccordionPanelProps = {
  index: string | number;
  title: string;
};
  • AccordionPanelIndex will represent the key for each of the panels, is unique, and can be a number or a string.
  • AccordionPanel, this one will keep the reference in the state of each of the panels, which one is expanded and index.
  • AccordionContextInterface the value context will provide to children. Will provide the state as accordionPanels and API methods to register panel, toggle panel and so...
  • AccordionPanelProps, finally the panel props.

Define Context

Now, it's time to create our context code, on the AccordionContext.tsx:

First the imports we'll need from React and from the types we created:

accordion/AccordionContext.tsx

import { createContext, FC, useCallback, useContext, useEffect, useState } from "react";
import {
  AccordionContextInterface,
  AccordionPanel,
  AccordionPanelIndex
} from "./types";

1) The Context

In this section, we create the context using the type already defined in types.ts, which needs extra non-null assertion to let know typescript, the value provided will never be null.

2) Provider Component

Also, we'll define the AccordionContextProvider, responsible for sharing the context to the descendant's components. Here we initialize the state that will be also part of the context we are sharing, state contains the state of each of the accordion panels registered. However also will share some API methods like registerAccordionPanel or unregisterAccordionPanel, when panel components are initialized or destroyed.

As we are returning the mentioned before methods as part of the context, we have to memorize them with useCallback in order to avoid unnecessary re-renders. useCallback receives the second parameter for dependencies, and the callback version will only change if dependencies change, you can give a try removing the dependencies array and see how it re-renders indefinitely.

3) The toggle method

The toggle method does exactly that, expand or collapses the panel and updates the state. Later on, we can read that flag from the component to switch the style. In this case, we use a display block or none for the panel content, keeping only the panel title visible or not.

4) Finally we render the provider.

Return the AccordionContext.Provider in the render, wrapping the children prop, in the value prop we are passing down our accordion state API methods (registerAccordionPanel, unregisterAccordionPanel and toggle) and state accordionPanels.

accordion/AccordionContext.tsx

// 1) The Context
const AccordionContext = createContext<AccordionContextInterface>(null!);

// 2) Provider Component
export const AccordionContextProvider: FC = ({ children }) => {
  const [accordionPanels, setAccordionPanels] = useState<AccordionPanel[]>([]);

  const registerAccordionPanel = useCallback(
    (index: AccordionPanelIndex, expanded = false) => {
      setAccordionPanels((state) => [...state, { index, expanded }]);
    },
    [setAccordionPanels]
  );

  const unregisterAccordionPanel = useCallback(
    (index: AccordionPanelIndex) => {
      setAccordionPanels((state) => state.filter((ap) => ap.index !== index));
    },
    [setAccordionPanels]
  );

// 3) The toggle method
  const toggle = (index: AccordionPanelIndex, force = false) => {
    const foundIndex = accordionPanels.findIndex((ap) => ap.index === index); // remove number 3
    const currentPanel = accordionPanels[foundIndex];
    const flushedPanels = accordionPanels.map((ap) => ({
      ...ap,
      expanded: false
    }));
    setAccordionPanels([
      ...flushedPanels.slice(0, foundIndex),
      {
        ...currentPanel,
        expanded: force ? force : !currentPanel.expanded
      },
      ...flushedPanels.slice(foundIndex + 1)
    ]);
  };

// 4) Finally we render the provider.

  return (
    <AccordionContext.Provider
      value={{
        accordionPanels,
        registerAccordionPanel,
        unregisterAccordionPanel,
        toggle
      }}
    >
      {children}
    </AccordionContext.Provider>
  );
};

Create a reusable hook

With this hook, we can register the accordion panel and obtain the state of it, also we receive the toggle method for the panel we are registering. Later we'll use this from the AccordionPanel component. But one awesome fact of context hook is that you can use it from anywhere else under the same context, let's say you have CustomAccordionPanel, then same hook can be used there.

accordion/AccordionContext.tsx

export const useAccordionContextProvider = ({
  index
}: {
  index: AccordionPanelIndex;
}) => {
  const {
    accordionPanels,
    registerAccordionPanel,
    unregisterAccordionPanel,
    toggle
  } = useContext(AccordionContext);
  const currentAccordionPanel = accordionPanels.find(
    (ap: AccordionPanel) => ap.index === index
  );
  useEffect(() => {
    registerAccordionPanel(index);
    return () => {
      unregisterAccordionPanel(index);
    };
  }, [index, registerAccordionPanel, unregisterAccordionPanel]);
  return {
    expanded: currentAccordionPanel ? currentAccordionPanel.expanded : false,
    toggle: (force = false) => toggle(index, force)
  };
};

Create the accordion panel

Last but not least, the component that consumes the hook, as mentioned before, is optional, is a layer between the UI and the state transferred from the context to the hook. We pass unique index, can be number or string, to differentiate from the other panels, as props also the title and children (panel content). There you see, is just a presentational component:

accordion/AccordionPanel.tsx

import React from "react";
import { useAccordionContextProvider } from "./AccordionContext";
import { AccordionPanelProps } from "./types";

export const AccordionPanel: React.FC<AccordionPanelProps> = ({
  index,
  title,
  children
}) => {
  const { expanded, toggle } = useAccordionContextProvider({ index });
  return (
    <div>
      <h3 onClick={(_) => toggle()}>{title}</h3>
      <div className={expanded ? "" : "hidden"}>{children}</div>
    </div>
  );
};

Setup and add some text to panels

Finally, wrap the app where we going to use the panels, defining specific indexes, titles and contents, ...and the provider on this example:

App.tsx

import { AccordionContextProvider } from "./accordion/AccordionContext";
import { AccordionPanel } from "./accordion/AccordionPanel";
import "./styles.css";

export default function App() {
  return (
    <div className="App">
      <AccordionContextProvider>
        <AccordionPanel index={"panel-1"} title={"Panel 1"}>
          Lorem ipsum dolor sit amet
        </AccordionPanel>
        <AccordionPanel index={"panel-2"} title={"Panel 2"}>
          Sed ut perspiciatis unde omnis iste natus error sit voluptatem
          accusantiu
        </AccordionPanel>
        <AccordionPanel index={"panel-3"} title={"Panel 3"}>
          At vero eos et accusamus et iusto odio dignissimos ducimus qui
          blanditiis p
        </AccordionPanel>
      </AccordionContextProvider>
    </div>
  );
}

Conclusions

Ok, we saw a common scenario where Context API can be implemented, but also we can apply the same approach for other UI scenarios such as handle navigation dropdowns states, tabs panels, shared state across deeply nested components, and many more.

If you read this, thanks and hope was helpful โœŒ๏ธ.

Set up a sample on codesanbox where you can see this solution working