The Pitfalls of React Context

The Pitfalls of React Context
Photo by il vano / Unsplash

React’s context API is awesome. As someone who's worked extensively with Redux in the past, learning about context and its simpler implementation of state management was a relief. I used it in my apps, quickly forgot about Redux, and never looked back.

That is, until I heard about the supposed performance problems with the Context API. Now, the big names in the React community will tell you not to worry about performance unless you start seeing issues. And yet, I keep hearing about context problems from other developers. One fellow even mentioned his boss banning the use of Context on their project.

Let’s review the Context API in case you’re unfamiliar before we talk about its problems.

Why use the Context API?

The Context API is useful for sharing state between components that you can’t easily share with props. Here’s an example of a button component that needs to set the state of a distant ancestor:

const { useState } = React;

function CountDisplay({ count }) {
  return <h2>The Count is: {count}</h2>;
}

function CountButton({ setCount }) {
  return (
    <button onClick={() => setCount((count) => count + 1)}>Increment</button>
  );
}

const OuterWrapper = ({ setCount }) => <InnerWrapper setCount={setCount} />;

const InnerWrapper = ({ setCount }) => <CountButton setCount={setCount} />;

function App() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <CountDisplay count={count} />
      <OuterWrapper setCount={setCount} />
    </div>
  );
}

The button component is within a few other components further down the tree, but still needs to access state from higher up in the app. So we have to pass setCount down to each component to finally get it to our CountButton component. This is affectionately known as “prop-drilling”, and used to be a huge pain point in React.

Thankfully, the Context API makes short work of situations like this.

How to use the Context API

Kent C. Dodds has a fantastic blog post on how to use the Context API. If you don’t have time to read that, here’s the short version: Context is a way to share state between unrelated or distant components. All you have to do is wrap your components in a Context.Provider and then call useContext(Context) inside that component to access your state and helper functions.

Here’s our counter example with context:

const { useContext, useState, createContext } = React;

const AppContext = createContext();

function AppProvider(props) {
  const [count, setCount] = useState(0);
  const value = { count, setCount };
  return (
    <AppContext.Provider value={value}>
      {props.children}
    </AppContext.Provider>
  );
}

function CountDisplay() {
  const { count } = useContext(AppContext);
  return <h2>The Count is: {count}</h2>;
}

function CountButton() {
  const { setCount } = useContext(AppContext);
  return (
    <button onClick={() => setCount((count) => count + 1)}>Increment</button>
  );
}

const OuterWrapper = () => <InnerWrapper />;

const InnerWrapper = () => <CountButton />;

function App() {
  return (
    <div>
      <AppProvider>
        <CountDisplay />
        <OuterWrapper />
      </AppProvider>
    </div>
  );
}

Here we have CountDisplay and CountButton components that both need to interact with the higher-level count state in our context. We start by making a context with createContext, then a provider component in AppProvider to wrap our dependent components, and finally call useContext in each component to pull out the values we need. It doesn’t matter how far apart the components are as long as they’re wrapped in a provider.

Pretty neat, huh?

We can improve on this a little bit by implementing some stuff that Kent has in his article on state management. Let’s take a look:

const { useContext, useState, createContext, useMemo } = React;

const AppContext = createContext();

// instead of calling useContext directly in our components,
// we make our own hook that throws an error if we try to
// access context outside of the provider
function useAppContext() {
  const context = useContext(AppContext);
  if (!context) throw new Error("AppContext must be used with AppProvider!");
  return context;
}

function AppProvider(props) {
  const [count, setCount] = useState(0);
  // here we use useMemo for... reasons.
  // this says don't give back a new count/setCount unless count changes
  const value = useMemo(() => ({ count, setCount }), [count]);
  return <AppContext.Provider value={value} {...props} />;
}

function CountDisplay() {
  const { count } = useAppContext();
  return <h2>The Count is: {count}</h2>;
}

function CountButton() {
  const { setCount } = useAppContext();
  return (
    <button onClick={() => setCount((count) => count + 1)}>Increment</button>
  );
}

const OuterWrapper = () => <InnerWrapper />;

const InnerWrapper = () => <CountButton />;

function App() {
  return (
    <div>
      <AppProvider>
        <CountDisplay />
        <OuterWrapper />
      </AppProvider>
    </div>
  );
}

The first thing we do is throw an error if we try to access the context outside of our provider. This is a great idea to improve the developer experience of your app (aka: make the console scream at you when you forget how context works).

The second thing is to memoize our context value to only re-render if the count changes. Now, useMemo is a difficult thing to wrap your head around, but the basic gist is that when you memoize something, you’re saying that you won’t return that value again unless your specified value changes. Kent has a great article on that too if you want to read more.

I can’t discern a difference between using useMemo and not using it, but I would venture that if you’re doing some heavy lifting in your Context Provider, it might be beneficial to apply memoization. If you read Kent’s article on useMemo and useCallback he cautions against using them unless you start to see performance hits. (Full Disclosure: I’ve never needed to use either one.)

Kent also spreads his props on the provider instead of using props.children, which is a neat trick, so I included that as well.

So what's wrong with the Context API?

Boy, the Context API sure is great. It’s super easy to use compared to Redux and requires a lot less code, so why wouldn’t you use it?

The problem with context is simple: Everything that consumes a context re-renders everytime that context’s state changes.

That means that if you’re consuming your context all over the place in your app, or worse, using one context for your entire app’s state, you’re causing a ton of re-renders all over the place!

Let’s visualize this with a simple app. Let’s make a context with a counter and a message. The message will never change, but be consumed by three components that display the message in a random color on each render. The count will be consumed by one component, and be the only value that changes:

const { useContext, useState, createContext } = React;

const AppContext = createContext();

function useAppContext() {
  const context = useContext(AppContext);
  if (!context)
    throw new Error("useAppContext must be used within AppProvider!");
  return context;
}

function AppProvider(props) {
  // the count for our counter component
  const [count, setCount] = useState(0);
  // this message never changes!
  const [message, setMessage] = useState("Hello from Context!");
  const value = {
    count,
    setCount,
    message,
    setMessage,
  };
  return <AppContext.Provider value={value} {...props} />;
}

function Message() {
  const { message } = useAppContext();
  // the text will render to a random color for
  // each instance of the Message component
  const getColor = () => Math.floor(Math.random() * 255);
  const style = { color: rgb(`${getColor()},${getColor()},${getColor()}`) };
  return (
    <div>
      <h4 style={style}>{message}</h4>
    </div>
  );
}

function Count() {
  const { count, setCount } = useAppContext();
  return (
    <div>
      <h3>Current count from context: {count}</h3>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}

Everything re-renders when we click increment!

The message components don’t even use the count from our context, but they re-render anyway. Oh dear!

What about components that don’t consume Context, do they re-render?

The good news is that only components that call useContext re-render whenever the context's state is changed.

Still, this is bad news for our app. We don’t want to trigger a bunch of unnecessary re-renders everywhere we use a context.

Should we stop using Context?

I'd say no, this is not a reason to stop using context. However, we must be wary of the pitfalls that come with using Context and adjust accordingly. Here are some ways we can deal with this re-render business.

Option 1: Don’t worry at all. Keep using Context at the top level.

If the size of your app is not huge, it's normally fine to keep Context at the top level. I’ve basically used Context a whole bunch in a lot of various apps without memoization, at the top level of my app, and consumed by a bunch of components without noticing any performance hits at all.

Like I said before, a lot of the React folks say you shouldn’t even worry about performance optimizations until you see performance impacts.

However, this strategy ain't gonna work for everyone. You might have already seen performance issues in your app, or if your app processes a lot of logic or animations, you may see performance issues as your app grows and end up doing some serious refactoring down the road.

Option 2: Use Redux or Mobx

Redux and Mobx both use the context API, so how do they help? The store that’s shared by these state management libraries with context is a little different from sharing state directly with context. When you’re using Redux and Mobx, there’s a diffing algorithm at work that makes sure to only re-render the components that actually need to re-render.

Still, context was supposed to save us from having to learn Redux and Mobx! There’s a lot of abstractions and boilerplate involved in using a state management library, making it an unattractive solution to some folks.

Plus, isn’t keeping all of our state in global state a bad practice?

Option 3: Use Multiple Contexts, and keep state close to its Dependent Components

This solution takes the most finesse to pull off, but gives you the best performance without reaching for Redux and Mobx. It relies on being smart about your state management choices, and only passing state up to a context if you need to share it between distant components.

There are a few tenants to this strategy:

  1. Allow the component to manage its own state if it can. This is a good practice to follow regardless of your choice of state management. For example, if you have a modal that needs to keep track of an open/closed state, but no other components need to know if that modal is open, keep that open/closed state in the modal. Don’t push state into context (or Redux) if you don’t have to!
  2. If your state is shared between a parent and a few children, just prop it down. This is the old school method of sharing state. Just pass it as props to the children components that need it. Passing props or “Prop-drilling” can be miserable with deeply nested components, but if you’re only passing things down a few levels, you should probably just do it.
  3. Keep the context close to the components that depend on it. This means if you need to share some state, like a form for instance with multiple components, go ahead and make a separate context for just the form and wrap the form components in your provider.
  4. Memoize descendants of the Context consumers. This goes without saying, but memoization is the key difference between a good performing React app and a bad one. Memoizing should be the first thing you do if you notice your performances start to take a hit, so memoize whenever you can!

Here is an example of keeping the context close to components that depend on it.

const { useContext, useState, createContext } = React;
const CountContext = createContext();

// Now count context only worries about count!
function useCountContext() {
  const context = useContext(CountContext);
  if (!context)
    throw new Error("useCountContext must be used within CountProvider!");
  return context;
}

function CountProvider(props) {
  const [count, setCount] = useState(0);
  const value = { count, setCount };
  return <CountContext.Provider value={value} {...props} />;
}

// And message context only worries about message!
const MessageContext = createContext();

function useMessageContext() {
  const context = useContext(MessageContext);
  if (!context)
    throw new Error("useMessageContext must be used within MessageProvider!");
  return context;
}

function MessageProvider(props) {
  const [message, setMessage] = useState("Hello from Context!");
  const value = { message, setMessage };
  return <MessageContext.Provider value={value} {...props} />;
}

function Message() {
  const { message } = useMessageContext();
  const getColor = () => Math.floor(Math.random() * 255);
  const style = {
    color: rgb(`${getColor()},${getColor()},${getColor()}`),
  };
  return (
    <div>
      <h4 style={style}>{message}</h4>
    </div>
  );
}

function Count() {
  const { count, setCount } = useCountContext();
  return (
    <div>
      <h3>Current count from context: {count}</h3>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}

Now our state is only shared with the components that care about that state. When we increment, the colors of our message components stay the same because count lives outside of messageContext.