Writing effective React Contexts

In React we can share data through different components using props, they can be interpreted as function parameters. When you have a state at the top of the application and need to make it accessible in a component down the component tree, the simplest way is to pass down the state via props.

However, passing down a prop through multiple components can get annoying fast and makes refactoring harder since the components start receiving a lot of props. This problem is called prop drilling and one of the solutions for it is the Context API.

While the Context API itself is simple, a bad implementation could make the maintenance of the code harder or degrade the performance of the app. I am going to share a few tips to help you organize and write more efficient contexts.

The basics of Context

A context can be created with React.createContext(), this function will return a context object which includes the provider and can be used to read the context value.

import { createContext, useState } from 'react';
 
const CounterContext = createContext(0);
 
function App() {
  // We create the state that we want to share through the app
  const [counter, setCounter] = useState(0);
 
  /// ...
 
  return (
    // Everything inside the provider will be able to read
    // the value of the context
    <CounterContext.Provider value={counter}>
      ...
    </CounterContext.Provider>
  );
}

To read the value of context in the components down the tree you can use the useContext hook. If a provider is not found up in the component tree the default value for the context will be returned (in this case it is 0).

function Display() {
  const counter = useContext(CounterContext);
 
  return <div>Counter value: {counter}</div>;
}

Create the Provider file

Currently, the logic of the provider in the examples above (the counter state) is directly inside the App component. This is not ideal because as the context gets more complex you will start having a lot of unrelated code in the App component, which usually contains a lot more stuff.

Just like other components the first improvement we can make is to move it to its own file.

// CounterProvider.js
import { createContext, useState } from 'react';
 
export const CounterContext = createContext(0);
 
export function CounterProvider({ children }) {
  const [counter, setCounter] = useState(0);
 
  return (
    <CounterContext.Provider value={counter}>
      {children}
    </CounterContext.Provider>
  );
}
 
// App.js
import { CounterProvider } from './CounterProvider';
 
function App() {
  return <CounterProvider>...</CounterProvider>;
}

Create a custom hook to consume the context

We need to import two different things every time we want to read the counter value: the context object (CounterContext) and the useContext hook.

Instead of exporting our context object, we can create a custom hook that calls useContext with the context object:

// We are removing the `export` from the context so the consumer
// has to use the hook instead
const CounterContext = createContext(0);
 
export function useCounter() {
  return useContext(CounterContext);
}

Now all the consumer components have to import is the custom hook:

import { useCounter } from './CounterProvider';
 
function Display() {
  const counter = useCounter();
 
  return <div>Counter value: {counter}</div>;
}

Don't leak implementation details of the provider

Our provider is only passing the counter value but we would like to allow the components to increase it too. The provider value can be anything so it is perfectly valid to make it an object that contains both the value and a function to change it.

Here comes a common mistake: changing the context value to be an object like { counter, setCounter }. Why is it bad?

Consider this code:

export function CounterProvider({ children }) {
  const [counter, setCounter] = useState(0);
 
  return (
    <CounterContext.Provider value={{ counter, setCounter }}>
      {children}
    </CounterContext.Provider>
  );
}

To increase the counter, this is what the consumer should do:

function CounterButton() {
  const { counter, setCounter } = useCounter();
 
  return (
    <button onClick={() => setCounter((prev) => prev + 1)}>
      {counter}
    </button>
  );
}

There are a few issues here:

  1. We only wanted the consumers to be able to increase our counter, but by exposing the setCounter function they can mess with the provider state and break the context.
  2. The consumer contains the logic to increase the counter. If later on we want to change the counter to only be incremented by 2, we would need to change this logic in all components.
  3. The setCounter method is directly tied to the implementation of the state with useState. If the state grows into a bigger object and we decide to use useReducer to manage it, then all consumers should be changed.

Instead of exposing the setter functions directly, consider creating small functions that abstract the state and allow the consumer to correctly interact with your provider:

export function CounterProvider({ children }) {
  const [counter, setCounter] = useState(0);
 
  const increment = () => {
    setCounter((prev) => prev + 1);
  };
 
  return (
    <CounterContext.Provider value={{ counter, increment }}>
      {children}
    </CounterContext.Provider>
  );
}

If later on we decide to increment by 2 or if we change the state to useReducer or any other library, we only need to update the provider code.

This is especially important when writing library code where we want to minimize breaking changes for the consumers of the context.

Memoize the provider value

We wanted to make our context send two variables so we had to change the context value from a number to an object. But by doing value={{ ... }} we are potentially introducing a performance problem in our app: every time the provider re-renders it will tell all consumers of the context to re-render too, even if the variables inside the object didn't change.

To determine if the value changed, the Context API uses the Object.is() function with the previous and new values. Since our value is an object it follows the referential equality rule, which means both values should have the same reference to be considered equal.

So what is happening is that every time our provider component re-renders we are creating a new object and this will make the equality check to fail. To solve this we can use the useMemo hook:

export function CounterProvider({ children }) {
  const [counter, setCounter] = useState(0);
 
  const increment = () => {
    setCounter((prev) => prev + 1);
  };
 
  const value = useMemo(
    () => ({ counter, increment }),
    [counter, increment]
  );
 
  return (
    <CounterContext.Provider value={value}>
      {children}
    </CounterContext.Provider>
  );
}

The hook will create the object only once and return the same reference on every re-render as long as the dependency array doesn't change. If any of the values in the dependency array is changed then a new object is created and our provider will correctly trigger a re-render in the consumers to reflect the new value.

Avoid big objects as the context value

When you have your context created it might be tempting to add everything that you want to be global in there. But as mentioned in the previous section, any changes will recreate the object, triggering a re-render in all the components that are consuming the context.

Re-rendering a couple of components is not a big deal, but as your context grows the performance of the app may get worse since changing a single value will update a lot of unrelated components. If you notice that your context is holding two values that are completely unrelated to each other, consider splitting them into different contexts.

Are you noticing that your app is growing, there are too many contexts being created, and managing the state is becoming complex? Maybe it is time to use a state management library, such as Redux Toolkit or Recoil.

Throw an error when no provider is found

We changed our context value but now our default value doesn't make sense, it's still 0. We could change it to the following:

const CounterContext = createContext({
  counter: 0,
  increment: () => {},
});

The new default value now matches the shape of the value of our provider, but we need to stop a bit and think about it. What do we really want to happen when someone tries to read the counter when they didn't set up the provider? There is no state associated with the default value, so any calls to increment would do nothing.

This default value is allowing the consumers of the provider to run the app with no errors, they will only notice something is wrong when they try to call increase and the number doesn't change. We want to fail fast.

To fix this we are gonna change the default value to null, so if the provider isn't found in the tree above the component we are gonna check for null and throw an error. This is easily done by changing the hook we made earlier:

const CounterContext = createContext(null);
 
export function useCounter() {
  const value = useContext(CounterContext);
 
  if (value === null) {
    throw new Error(
      'useCounter cannot be called without a CounterProvider'
    );
  }
 
  return value;
}

Now if the consumer tries to use our hook without setting up the provider first, we are gonna give a useful error telling what is the problem. We could further improve the error with instructions:

useCounter cannot be called without a CounterProvider. Did you forget to wrap your app with the provider?

This tip is specific for use cases where it is necessary to have a provider. There are valid scenarios where the default value is useful, like allowing the consumer to change the theme of your components. If no provider is found you would like to use the default theme instead of throwing an error.

Correctly typing the provider

If you are using TypeScript you can type your context by providing the type of the context value to createContext:

type CounterContextValue = {
  counter: number;
  increment: () => void;
};
 
const CounterContext = createContext<CounterContextValue | null>(null);

We are using null to check if the consumer didn't set up the provider, so the type of the context is going to be either CounterContextValue or null. Since in our hook we are throwing an error if the context value is null, it is guaranteed that the return type of useCounter will always be CounterContextValue.

The final code

This is the final code of the provider with all the tips applied. You can use it as a template for new contexts:

type CounterContextValue = {
  counter: number;
  increment: () => void;
};
 
const CounterContext = createContext<CounterContextValue | null>(null);
 
export function useCounter() {
  const value = useContext(CounterContext);
 
  if (value === null) {
    throw new Error(
      'useCounter cannot be called without a CounterProvider'
    );
  }
 
  return value;
}
 
type CounterProviderProps = {
  children: React.ReactNode;
};
 
export function CounterProvider({ children }: CounterProviderProps) {
  const [counter, setCounter] = useState(0);
 
  const increment = () => {
    setCounter((prev) => prev + 1);
  };
 
  const value = useMemo(
    () => ({ counter, increment }),
    [counter, increment]
  );
 
  return (
    <CounterContext.Provider value={value}>
      {children}
    </CounterContext.Provider>
  );
}