How to use React Context like a pro
September 13, 2021 • 9 min read
Introduction
Using React's Context API is often very useful. I've found that there are several patterns that you should regularly use in combination with contexts in React, and these patterns aren't that well known.
I'm sharing them in this article so you can start using React Contexts like a pro!
As a quick reminder before we start, here's the vanilla hook based implementation of a context (that we'll use as an example for the different patterns):
import React, { useContext, createContext, useState, useEffect } from 'react';
// create context
const UserContext = createContext();
const App = () => {
// the value that will be given to the context
const [user, setUser] = useState(null);
// fetch a user from a fake backend API
useEffect(() => {
const fetchUser = () => {
// this would usually be your own backend, or localStorage
// for example
fetch('https://randomuser.me/api/')
.then((response) => response.json())
.then((result) => setUser(result.results[0]))
.catch((error) => console.log('An error occurred');
};
fetchUser();
}, []);
return (
// the Provider gives access to the context to its children
<UserContext.Provider value={user}>
<Page />
</UserContext.Provider>
);
};
const Page = () => {
// access the context value
const user = useContext(UserContext);
if (user?.login?.username) {
return <p>You are logged in as {user?.login.username}</p>;
} else {
return <p>You are not logged in</p>;
}
};
export default App;
In this example, the context is used to provide the logged-in user
object to the app. This context is then consumed by the Page
component that conditionally renders based on the user
value.
This is a very common use case in real-life React applications.
Let's see how we can improve it.
Extract the React Context logic in another file
One thing I don't like in the code above is that the context logic is mixed in with the App
code when both have little to do with each other. The App
only wants to provide the context to its children and doesn't care about how this context is made.
So let's extract all of this logic to an external file.
Use React Context with a custom Provider
First, we'll create a UserContextProvider
component inside of a new file called UserContext.jsx
.
This component is the one that will hold the logic for getting the value of the context (user
) and giving it to the UserContext.Provider
:
import React, { createContext, useState, useEffect } from "react";
// create context
const UserContext = createContext();
const UserContextProvider = ({ children }) => {
// the value that will be given to the context
const [user, setUser] = useState(null);
// fetch a user from a fake backend API
useEffect(() => {
const fetchUser = () => {
// this would usually be your own backend, or localStorage
// for example
fetch("https://randomuser.me/api/")
.then((response) => response.json())
.then((result) => setUser(result.results[0]))
.catch((error) => console.log("An error occured"));
};
fetchUser();
}, []);
return (
// the Provider gives access to the context to its children
<UserContext.Provider value={user}>
{children}
</UserContext.Provider>
);
};
export { UserContext, UserContextProvider };
Now that we're removed the above from our App
component, it's way cleaner:
import React, { useContext } from "react";
import { UserContext, UserContextProvider } from "./UserContext";
const App = () => {
return (
<UserContextProvider>
<Page />
</UserContextProvider>
);
};
const Page = () => {
// access the context value
const user = useContext(UserContext);
if (user?.login?.username) {
return <p>You are logged in as {user?.login.username}</p>;
} else {
return <p>You are not logged in</p>;
}
};
export default App;
Isn't it much nicer?
Use React Context with a custom hook
Unfortunately, there's still something bothering me in the code above.
In the Page
component, we are accessing the context by using the useContext
hook directly. But what if the component is not actually inside a UserContextProvider
?
Then the value would default to undefined
without us knowing. Of course, we could do a check for that in the Page
component, but that means we would have to do it in every context consumer, which would get annoying.
It's much simpler to extract the useContext
line to a custom hook, and we will do the check there.
Of course, you could argue that as our UserContextProvider
is at the top-level of our app it's unlikely that a component would live outside of it.
Fair, but keep in mind that contexts aren't always at the top level. It's quite common for contexts to only be available in a section of the app, and in those cases it's quite easy to use a context where it's not available.
Another benefit to doing that is that it saves us an import. Instead of having to import both the useContext
hook and the actual context itself (UserContext
), we now only have to import the custom consumer hook. Fewer lines to write! 😄
Here's the resulting custom consumer hook:
// context consumer hook
const useUserContext = () => {
// get the context
const context = useContext(UserContext);
// if `undefined`, throw an error
if (context === undefined) {
throw new Error("useUserContext was used outside of its Provider");
}
return context;
};
And to use it, simply import the hook and use it in the Page
component:
const Page = () => {
// access the context value
const user = useUserContext();
if (user?.login?.username) {
return <p>You are logged in as {user?.login.username}</p>;
} else {
return <p>You are not logged in</p>;
}
};
If you ask me, our context usage now seems very nice! All of the logic related to the UserContext
sits in one file, the context is very simple to access using the useUserContext
hook and we will be warned whenever we try to access the context outside of the right provider.
The code above is usually enough for most purposes, but sometimes you need to go further, usually for performance and optimization reasons.
The next two sections explore ways to optimize your context. Bear in mind that it should only be used if you are indeed having performance and optimization issues. Otherwise it's safe to go with the simpler option from above.
Be careful about updating context values, and memoize them
Imagine our UserContext
in a big app. Presumably, a lot of components are using the context.
Now imagine that we are polling our backend every 15 seconds to see if the user
value changed somehow. For example we could be storing the number of credits a user has left in his account directly in the user
object.
If we do this naively, it means that every single component which uses that context will re-render every 15 seconds. Not great.
Let's see how to avoid that.
Memoize values in your context with useMemo
and useCallback
It's usually a good idea to wrap context values with memoizing functions like useMemo
and useCallback
.
Context values are often used in dependency arrays in context consumers. If you don't memoize context values, you can end up with unwanted behaviors like useEffect
triggering unnecessarily.
A change in those values could trigger dependency arrays in every context consumer, so it can have a sizeable impact on the affected components. And memoizing the value in the context is very effective since you only have to memoize the value once and it will work for all the components consuming the context.
For example, let's say you have a signout
function inside the context. It's best to wrap it in a useCallback
hook:
const UserContextProvider = ({ children }) => {
// the value that will be given to the context
const [user, setUser] = useState(null);
// sign out the user, memoized
const signout = useCallback(() => {
setUser(null);
}, []);
// fetch a user from a fake backend API
useEffect(() => {
const fetchUser = () => {
// this would usually be your own backend, or localStorage
// for example
fetch("https://randomuser.me/api/")
.then((response) => response.json())
.then((result) => setUser(result.results[0]))
.catch((error) => console.log("An error occured"));
};
fetchUser();
}, []);
// memoize the full context value
const contextValue = useMemo(() => ({
user,
signout
}), [user, signout])
return (
// the Provider gives access to the context to its children
<UserContext.Provider value={contextValue}>
{children}
</UserContext.Provider>
);
};
Keep in mind that memoizing won't always prevent unnecessary triggers. For example, the user
variable is an object. If you change that object through a setState
, as far as useMemo
is concerned the object is a new one (even if all the keys and the values are the same). This is because React is only doing a shallow equality test in dependency arrays.
In that case, you should do the check yourself and only update the context value if necessary. To do that you could for example use Lodash's isEqual
function that deeply compares two javascript objects.
It's also important to note that it's useful to memoize both the individual variables and functions inside the context value (in this case: user
and signout
) and the overall contextValue
(when the context value is made out of multiple pieces like here).
Thank you to reddit user /u/tharrison4815 for pointing out some confusing wording in that section and helping me improve it.
Separate state and state setters (if necessary)
To be clear, you usually don't need to do this. If you're careful about updating context values and they are memoized, you're very probably fine.
But sometimes you might run into issues that will be solved by separating context state and context state setters.
Here's what I mean by "context state" and "context state setter".
In our last example you have the user
object, which is the "context state", and the signout
function, which is a "context state setter": it's used to change the "context state".
Both don't need to be in the same provider. For example, a log-out button might only need the signout
function without caring about the current state of authentication.
In the default case, that button would update every time the user
object changes, because a change in the user
object means a change in the context value which means an update to every consumer of the context.
In situations where you care about this (and only in those), you can separate your state and your state setters in two different contexts.
I believe this idea was first introduced by Kent C. Dodds in this blog post.
The implementation of that pattern is the following:
import React, {
createContext,
useContext,
useState,
useEffect,
useCallback
} from "react";
// create contexts
const UserContextState = createContext();
const UserContextUpdater = createContext();
// context consumer hook
const useUserContextState = () => {
// get the context
const context = useContext(UserContextState);
// if `undefined`, throw an error
if (context === undefined) {
throw new Error("useUserContextState was used outside of its Provider");
}
return context;
};
// context consumer hook
const useUserContextUpdater = () => {
// get the context
const context = useContext(UserContextUpdater);
// if `undefined`, throw an error
if (context === undefined) {
throw new Error("useUserContextUpdater was used outside of its Provider");
}
return context;
};
const UserContextProvider = ({ children }) => {
// the value that will be given to the context
const [user, setUser] = useState(null);
const signout = useCallback(() => {
setUser(null);
}, []);
// fetch a user from a fake backend API
useEffect(() => {
const fetchUser = () => {
// this would usually be your own backend, or localStorage
// for example
fetch("https://randomuser.me/api/")
.then((response) => response.json())
.then((result) => setUser(result.results[0]))
.catch((error) => console.log("An error occured"));
};
fetchUser();
}, []);
return (
// the Providers gives access to the context to its children
<UserContextState.Provider value={user}>
<UserContextUpdater.Provider value={signout}>
{children}
</UserContextUpdater.Provider>
</UserContextState.Provider>
);
};
export { UserContextProvider, useUserContextState, useUserContextUpdater };
The usage is very similar to before, as you can guess. You just have to choose to access the state or the state setters (or both). Of course if you often need both you can also create a hook that provides both out of the box, thus reproducing the previous behavior.
···
const Page = () => {
// access the context value
const user = useUserContextState();
if (user?.login?.username) {
return <p>You are logged in as {user?.login.username}</p>;
} else {
return <p>You are not logged in</p>;
}
};
···
Only use React Context if you really need it
React Context is a great tool, but it can also be dangerous. As it's usually shared between a bunch of components, it can cause performance issues when abused and used for the wrong kind of state.
Most of the time, useState
is enough for your needs. It's important to know when to use useState
and when to use useContext
. And it's not a clear division either; sometimes both work well.
You want useState
to be your default option for state and only switch to useContext
if it's necessary.
A good reason to switch to contexts is if the state is accessed by a lot of components.
Bear in mind that to solve the "prop drilling" issue where you are passing props through layers of components, there are other strategies you can also use.
Examples of good purposes of context:
- Share the authentication state across your app
- Share a theme across your app
- Share a value that is used by a lot of components in a part of your app (for example the current balance of a user in a dashboard where a lot of components are using that balance)
Wrap up
Voilà! You're now equipped to improve your usage of React Contexts. As you saw, there are a lot of different ways to go about it, and the one you choose really depends on your exact circumstances.
So part of getting good at using React Contexts is also just building experience and reflecting on your code once in a while, to see if you should have done things differently.
Good luck!