Contexts in React are great. They provide an easy solution for shared component state, avoiding prop drilling and many other common patterns. All that while being built into React itself. What is not to like about that, and when should we reach for other solutions?
Where context shines: contextual data in the background
Context has a lot of use cases, and it is built into React for good reason. Library authors and users can access the same APIs and can be a lot easier to agree on a solution to a common problem.
Themes and locales
Both themes and locales are great examples of uses for context that affect an entire application. The values are not changed often, and when they are changed the entire app will probably need to be re-rendered with either a new theme or new translations.
Libraries
Library authors can utilize context since it is built right into React. Common uses could be to provide settings to a library like SWR does with Global configuration, which adds a context provider that SWR consumes. Or letting the user access library APIs at the component level such as the React Router hooks API, which exposes parts of the internal React Router context for library users.
Semi-local state
Context is a nice fit for a shared state that is used in small parts of an application as well. An example of this can be wrapping an enhanced form in a context provider to collect all form state. Much in the same way as Formik does. With this use, the context can be very useful without affecting a large part of the application.
Where the performance falls apart
The examples where context works well all have some things in common. They either don't update often, or they affect only a small portion of an application. When we apply a context that changes often to a large part of an application the rendering process can grind to a halt.
To get into why this happens we can take a simplistic look into how context works. To use context we need a context, a provider, and a consumer.
We'll set up a context:
const DataContext = createContext();
A Provider that we'll put at the top level of our app:
const DataProvider = ({ children }) => {
const [data, setData] = useState();
return (
<DataContext.Provider value={{ data, setData }}>
{children}
</DataContext.Provider>
);
};
And one or more components that consume the context:
const ComponentA = () => {
const { data } = useContext(DataContext);
return (
// ...
);
};
In this case, any component below DataProvider
in the component tree can access the context value, such as ComponentA
does. This also means that any time the value in DataContext.Provider
changes, every component consuming the context will be re-rendered.
Complex states
In real-world apps, data is often not so simple. Our data
might end up containing a list of structured objects, and our value
might also include more functions for manipulating the state.
Adding multiple values to the context makes this process a bit more complicated. The context value is checked using strict referential equality (===
), and to pass multiple values we have to wrap them in an array or an object.
Simple enough, to avoid unnecessary renders we can wrap the object in a useMemo
to keep the referential equality:
const DataProvider = ({ children }) => {
const [data, setData] = useState();
const value = useMemo(
() => ({
data,
setData,
}),
[data, setData]
);
return (
<DataContext.Provider value={value}>
{children}
</DataContext.Provider>;
};
This approach works well enough. Any part of value
that is created in the function body of DataProvider
has to be wrapped in a useMemo
or useCallback
to avoid triggering a re-render of all consumers at every render of DataProvider
. If data
in this example is a complex object or a long list of objects this can become quite cumbersome.
Consuming only part of a context
When using context as a global store for a specific piece of data we'll usually do it to make it available to many different components in our app. These components will probably end up only needing certain parts of the value in our context.
This is one of the use cases that become a bit harder to solve with context, as React has no way to figure out which part of a context is used by a specific component.
If for example out context data
from the previous examples contains a User
with an email
and a name
. We can have two components using different parts of the user object:
const ComponentA = () => {
const { data } = useContext(DataContext);
return <p>{data.user.name}</p>;
};
const ComponentB = () => {
const { data } = useContext(DataContext);
return <p>{data.user.email}</p>;
};
Both of these components are subscribed to the same context. This means they will both be re-rendered whenever any part of the context value changes, even though they only use part of the value. As of right now React gives us no way to avoid this behavior.
This example will probably never lead to degraded performance due to excessive re-renders. But adding a bit more complexity to the context value might do. Especially combined with updating the data frequently, such as on changes in a form or on keypress.
Optimizing complex context states
We can do some optimizations to make our application snappy again if many re-renders are triggered by a context, but these optimizations have to be implemented outside of the context itself.
Memoizing child components
The first solution is to wrap child components of the context consumer in React.memo
. This will stop the unnecessary re-renders from propagation to children of the component:
// Re-renders only when `name` has changed
const Name = React.memo(({ name }) => {
return <p>{name}</p>;
});
// Re-renders every time the context value is changed
const ComponentB = () => {
const { data } = useContext(DataContext);
return <Name name={data.user.name} />;
};
Keep in mind that by default React.memo
only does a shallow equality check on the props of a component. Objects, lists, or functions as props can lead to memoization never kicking in.
Deferring frequent state updates
The other option is to stop our state and therefore context value to update frequently in the first place. A good place to start can be to debounce or throttle a form or an input. Another solution can be to batch state updates together.
Other solutions and the future
Selecting parts of a context
It would be great if we could subscribe to just the parts of a context. That way many of the performance pitfalls can be avoided. Luckily there is a library for that!
use-context-selector has been available for quite some time. It wraps around a context to enable selection of the part you want:
// This component will only re-render when `name` is changed
const ComponentA = () => {
const name = useContextSelector(DataContext, (value) => value.data.user.name);
return <p>{name}</p>;
};
Not a fan of including a library to do the work?
You might be in luck, as the React team has been experimenting with adding internal support for context selectors. The experiment is currently in a halted state, so I would not get my hopes up for it arriving soon.
useDeferredValue in React 18
With concurrent mode React will enable behavior that can keep the UI responsive while other work is deferred in the background. With useDeferredValue we'll be able to stop changes to the context value from propagating immediately.
This feature has not been released yet, and may not work in all cases.
Should I use context, or should I reach for another solution?
As with all things in this world, it depends on the "context" of what you are doing. The internal context in React works well for a lot of things and is easy to get started with. It is a great first choice if the application doesn't have specific needs.
On the other hand, it tends to not scale well with complex state in larger applications, frequent state updates, and can easily lead to sluggish performance if used incorrectly.
In my personal opinion, I'll continue using context for simple tasks. While relying on libraries for state management in cases where each store has more than a few primitives or single objects.