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
- Define the types
- Define Context
- Create a reusable hook
- Create the accordion panel
- Setup and add some text to panels
- 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 asaccordionPanels
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