Devtrium

How to use React Context like a pro

September 13, 20219 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):

App.jsx
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:

UserContext.jsx
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:

App.jsx
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:

UserContext.jsx
// 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:

App.jsx
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.

Info

Our check context === undefined to know whether useUserContext is used outside of the context provider relies on the fact that the context was created without any default value.

If we had provided a default value, then the value of the context outside of the provider would be that default value. It's a bit counter-intuitive if you ask me, but it's the way it works.

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.

Tip

Want to know how to do something every 15 seconds in a React app? You do it using intervals!

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.

Info

You might say that polling your backend every 15 seconds for this info and storing it in the user object isn't the best way to go about doing this.

And you would be right. However, this situation and others like it frequently happen in production, where things aren't always ideal.

This exact issue happened in my team a few months ago. Knowing how to avoid that problem is an important part of knowing how to effectively use context API in React.

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.

Tip

Here is more information on dependency arrays if you are not sure about how they work.

For example, let's say you have a signout function inside the context. It's best to wrap it in a useCallback hook:

UserContext.jsx
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.

Info

Be careful about deeply comparing large objects: it is an expensive operation. If you're doing this too frequently it might slow down your app.

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:

USerContext.jsx
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.

App.jsx
···
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!

Package versions at the time of writing

Did you enjoy this article?

If so, a quick share on Twitter could really help out!