Using useContext for reusable components in React
useContext
is one of the standard ways to manage state and data in a React
application. Usually, it allows you to create kind-of global variables so that
you don’t have to import and/or pass properties around all the time. If you
want a comprehensive guide on why it exists and how to use it, you can consult
the excellent official docs.
Here is the speed-run version:
Suppose you have this React app (for some reason):
export default function App() {
return <A msg="world" />;
}
function A({ msg }) {
return <B msg={msg} />;
}
function B({ msg }) {
return <C msg={msg} />;
}
function C({ msg }) {
return <D msg={msg} />;
}
function D({ msg }) {
return `Hello ${msg}`;
}
Here we are passing the msg
prop from App
to A
, from A
to B
etc until
it reaches component D
which actually uses it. You can simplify this code
slightly by doing:
import { createContext, useContext } from 'react';
const MyContext = createContext();
export default function App() {
return (
<MyContext.Provider value={{ msg: 'world' }}>
<A />
</MyContext.Provider>
);
}
function A() {
return <B />;
}
function B() {
return <C />;
}
function C() {
return <D />;
}
function D() {
const { msg } = useContext(MyContext);
return `Hello ${msg}`;
}
So, now we are passing the variable directly from App
to D
. The MyContext
variable is like a handle that lets both know that they are referring to the
same context; so long as they both have access to it, App
can provide the
value and D
can use it, regardless of whether the intermediate components
know about it.
This can be very useful for cases when your main App handles authentication and a descendant component, possibly rendered by a router, wants access to the authentication information:
// AuthContext.js
import { createContext } from 'react';
const AuthContext = createContext({});
export default AuthContext;
// App.jsx
import { useState, useEffect } from 'react';
import { RouterProvider } from 'react-router-dom';
import AuthContext from './AuthContext';
import router from './router';
export default function App() {
const [authInfo, setAuthInfo] = useState({ loggedIn: false });
useEffect(() => {
async function _login() {
const response = await fetch('/identity');
const data = await response.json();
setAuthInfo(data);
}
_login();
}, []);
return (
<AuthContext.Provider value={{ authInfo }}>
<RouterProvider router={router} />
</AuthContext.Provider>
);
}
// routes/about.jsx
import { useContext } from 'react';
import { Redirect } from 'react-router-dom';
import AuthContext from '../AuthContext';
export default function About() {
const { authInfo } = useContext(AuthContext);
if (! authInfo.loggedIn) { return <Redirect to="/signin" />; }
// ...
}
But enough of that. This is well-known or at least well-documented. Here we
will use useContext
to build something that would otherwise be difficult to
do without it. A reusable Accordion component. Here is this component’s
interface:
export default function App() {
return (
<Accordion>
<Accordion.Item trigger="trigger 1">Content 1</Accordion.Item>
<Accordion.Item trigger="trigger 2">Content 2</Accordion.Item>
<Accordion.Item trigger="trigger 3">Content 3</Accordion.Item>
<Accordion.Item trigger="trigger 4">Content 4</Accordion.Item>
</Accordion>
);
}
We could have passed the triggers and contents as props and, had we done that,
it would have been pretty easy to implement without needing useContext
at
all, like this:
export default function App() {
return (
<Accordion items={[
{ trigger: 'trigger 1', content: 'Content 1' },
{ trigger: 'trigger 2', content: 'Content 2' },
{ trigger: 'trigger 3', content: 'Content 3' },
{ trigger: 'trigger 4', content: 'Content 4' },
]} />
);
}
The reason we will go with the first approach is that we may want to intersect custom JSX code inbetween the items, which is something we cannot do with the props approach:
export default function App() {
return (
<Accordion>
<h3>Primary items</h3>
<Accordion.Item trigger="trigger 1">Content 1</Accordion.Item>
<Accordion.Item trigger="trigger 2">Content 2</Accordion.Item>
<h3>Secondary items</h3>
<Accordion.Item trigger="trigger 3">Content 3</Accordion.Item>
<Accordion.Item trigger="trigger 4">Content 4</Accordion.Item>
</Accordion>
);
}
What we want to achieve is to present a list of buttons (or other clickable
element) for each item that will act as a trigger. Every time we click one,
its content, and only its content, will be revealed, hiding everything else.
The state that describes which item is to be shown lives in the parent
Accordion
component (you could say that the parent component defines the
context of which item is currently visible).
The problem is that the parent component does not know what accordion items live beneath it so it doesn’t have direct control of them. The situation looks like this:
<Accordion> // Library code
<h3>... // User's JSX
<Accordion.Item... // Library code
Anyway, lets start:
export default function Accordion({ children }) {
return children;
}
function AccordionItem({ trigger, children }) {
return (
<>
<div><button>{trigger}</button></div>
<div>{children}</div>
</>
);
}
Accordion.Item = AccordionItem;
Lets assume each accordion item has its own id
(somehow). Which item is open
will be defined by a state variable that lives inside Accordion
:
+import { useState } from 'react';
export default function Accordion({ children }) {
+ const [openId, setOpenId] = useState(null);
return children;
}
function AccordionItem({ trigger, children }) {
return (
<>
<div><button>{trigger}</button></div>
<div>{children}</div>
</>
);
}
Accordion.Item = AccordionItem;
Lets use a context to make sure the required information is passed down to all items:
-import { useState } from 'react';
+import { createContext, useState, useContext } from 'react';
+const AccordionContext = createContext();
export default function Accordion({ children }) {
const [openId, setOpenId] = useState(null);
- return children;
+ return (
+ <AccordionContext.Provider value={{ openId, setOpenId }}>
+ {children}
+ </AccordionContext.Provider>
+ );
}
function AccordionItem({ trigger, children }) {
+ const { openId, setOpenId } = useContext(AccordionContext);
return (
<>
<div><button>{trigger}</button></div>
<div>{children}</div>
</>
);
}
Accordion.Item = AccordionItem;
So now, even though we don’t know the content of children
of the main
Accordion
component in advance, we can be certain that all items that are
or will be in it will have access to our context.
Now we can use the useId
hook to
give each item its own unique ID:
-import { createContext, useState, useContext } from 'react';
+import { createContext, useState, useContext, useId } from 'react';
const AccordionContext = createContext();
export default function Accordion({ children }) {
const [openId, setOpenId] = useState(null);
return (
<AccordionContext.Provider value={{ openId, setOpenId }}>
{children}
</AccordionContext.Provider>
);
}
function AccordionItem({ trigger, children }) {
const { openId, setOpenId } = useContext(AccordionContext);
+ const id = useId();
return (
<>
<div><button>{trigger}</button></div>
<div>{children}</div>
</>
);
}
Accordion.Item = AccordionItem;
And now we can bring everything together:
import { createContext, useState, useContext, useId } from 'react';
const AccordionContext = createContext();
export default function Accordion({ children }) {
const [openId, setOpenId] = useState(null);
return (
<AccordionContext.Provider value={{ openId, setOpenId }}>
{children}
</AccordionContext.Provider>
);
}
function AccordionItem({ trigger, children }) {
const { openId, setOpenId } = useContext(AccordionContext);
const id = useId();
return (
<>
- <div><button>{trigger}</button></div>
+ <div><button onClick={() => setOpenId(id)}>{trigger}</button></div>
- <div>{children}</div>
+ {openId === id && <div>{children}</div>}
</>
);
}
Accordion.Item = AccordionItem;
It would be slightly better if the item was not aware of the internals of
setOpenId
. To that end, we can do:
import { createContext, useState, useContext, useId } from 'react';
const AccordionContext = createContext();
export default function Accordion({ children }) {
const [openId, setOpenId] = useState(null);
+ function toggle(id) {
+ setOpenId(id);
+ }
return children;
return (
- <AccordionContext.Provider value={{ openId, setOpenId }}>
+ <AccordionContext.Provider value={{ openId, toggle }}>
{children}
</AccordionContext.Provider>
);
}
function AccordionItem({ trigger, children }) {
- const { openId, setOpenId } = useContext(AccordionContext);
+ const { openId, toggle } = useContext(AccordionContext);
const id = useId();
return (
<>
- <div><button onClick={() => setOpenId(id)}>{trigger}</button></div>
+ <div><button onClick={() => toggle(id)}>{trigger}</button></div>
{openId === id && <div>{children}</div>}
</>
);
}
Accordion.Item = AccordionItem;
Now we have a solid foundation for adding features. First we are going to make it so that if you click the trigger of the already open element, it will close:
import { createContext, useState, useContext, useId } from 'react';
const AccordionContext = createContext();
export default function Accordion({ children }) {
const [openId, setOpenId] = useState(null);
function toggle(id) {
- setOpenId(id);
+ setOpenId(id === openId ? null : id);
}
return (
<AccordionContext.Provider value={{ openId, toggle }}>
{children}
</AccordionContext.Provider>
);
}
function AccordionItem({ trigger, children }) {
// ...
}
Accordion.Item = AccordionItem;
Next we are going to optionally make the accordion a “multi-accordion”, meaning that we will allow multiple items to be open at the same time. But before that, we are going to convert our state variable from a null/string value to a Set value:
import { createContext, useState, useContext, useId } from 'react';
const AccordionContext = createContext();
export default function Accordion({ children }) {
- const [openId, setOpenId] = useState(null);
+ const [openIds, setOpenIds] = useState(new Set([]));
function toggle(id) {
- setOpenId(id === openId ? null : id);
+ setOpenIds(new Set(openIds.has(id) ? [] : [id]));
}
return (
<AccordionContext.Provider value={{ openId, toggle }}>
{children}
</AccordionContext.Provider>
);
}
function AccordionItem({ trigger, children }) {
const { openId, toggle } = useContext(AccordionContext);
const id = useId();
return (
<>
<div><button onClick={() => toggle(id)}>{trigger}</button></div>
- {openId === id && <div>{children}</div>}
+ {openIds.has(id) && <div>{children}</div>}
</>
);
}
Accordion.Item = AccordionItem;
Remember, when setting state variables, we have to set copies of our previous values instead of mutating them.
And now we can go ahead with implementing the multi functionality:
import { createContext, useState, useContext, useId } from 'react';
const AccordionContext = createContext();
-export default function Accordion({ children }) {
+export default function Accordion({ multi = false, children }) {
const [openIds, setOpenIds] = useState(new Set([]));
function toggle(id) {
- setOpenIds(new Set(openIds.has(id) ? [] : [id]));
+ setOpenIds((prev) => {
+ if (multi) {
+ const result = new Set(prev);
+ (result.has(id) ? result.delete : result.add).call(result, id);
+ return result;
+ } else {
+ return new Set(openIds.has(id) ? [] : [id]);
+ }
+ });
}
return (
<AccordionContext.Provider value={{ openId, toggle }}>
{children}
</AccordionContext.Provider>
);
}
function AccordionItem({ trigger, children }) {
// ...
}
Accordion.Item = AccordionItem;
And our final trick will be to allow “expand all”/”collapse all” buttons to be
possible. We are going to start by making the parent Accordion
component
aware of all the IDs defined by the items:
-import { createContext, useState, useContext, useId } from 'react';
+import { createContext, useState, useContext, useId, useEffect } from 'react';
const AccordionContext = createContext();
export default function Accordion({ multi = false, children }) {
const [openIds, setOpenIds] = useState(new Set([]));
+ const [allIds, setAllIds] = useState(new Set([]));
function toggle(id) {
// ...
}
+ function registerId(id) {
+ setAllIds((prev) => new Set([...prev, id]));
+ }
+ function unregisterId(id) {
+ setAllIds((prev) => {
+ const result = new Set(prev);
+ result.delete(id);
+ return result;
+ });
+ }
return (
- <AccordionContext.Provider value={{ openId, toggle }}>
+ <AccordionContext.Provider value={{ openId, toggle, registerId, unregisterId }}>
{children}
</AccordionContext.Provider>
);
}
function AccordionItem({ trigger, children }) {
- const { openId, toggle } = useContext(AccordionContext);
+ const { openId, toggle, registerId, unregisterId } = useContext(AccordionContext);
const id = useId();
+ useEffect(() => {
+ registerId(id);
+ return () => unregisterId(id);
+ }, []);
return (
<>
<div><button onClick={() => toggle(id)}>{trigger}</button></div>
{openIds.has(id) && <div>{children}</div>}
</>
);
}
Accordion.Item = AccordionItem;
We use an effect to make sure each item registers its ID when it is mounted to the DOM and unregisters it when it is unmounted. This will work even for accordion items that are conditionally or dynamically generated.
And, in order to allow exposing the new buttons to the user of our library, we
are going to allow optionally accepting the Accordion
’s children
property
as a function that will receive proper callbacks:
import { createContext, useState, useContext, useId, useEffect } from 'react';
const AccordionContext = createContext();
export default function Accordion({ multi = false, children }) {
const [openIds, setOpenIds] = useState(new Set([]));
const [allIds, setAllIds] = useState(new Set([]));
function toggle(id) {
// ...
}
function registerId(id) {
// ...
}
function unregisterId(id) {
// ...
}
return (
<AccordionContext.Provider value={{ openId, toggle, registerId, unregisterId }}>
- {children}
+ {typeof children !== 'function' && children}
+ {typeof children === 'function' && children({
+ collapseAll: () => setOpenIds(new Set([])),
+ expandAll: () => setOpenIds(new Set([...allIds])),
+ })}
</AccordionContext.Provider>
);
}
function AccordionItem({ trigger, children }) {
// ...
}
Accordion.Item = AccordionItem;
So now the user can do this:
import Accordion from 'Accordion';
export default function App() {
return (
<Accordion multi>
{({ collapseAll, expandAll }) => (
<>
<button onClick={collapseAll}>Collapse all</button>
<button onClick={expandAll}>Expand all</button>
<Accordion.Item trigger="trigger 1">Content 1</Accordion.Item>
<Accordion.Item trigger="trigger 2">Content 2</Accordion.Item>
<Accordion.Item trigger="trigger 3">Content 3</Accordion.Item>
<Accordion.Item trigger="trigger 4">Content 4</Accordion.Item>
</>
)}
</Accordion>
);
}