React Context Performance
September 5, 2019The biggest benefit and curse of React is how flexible it is. In most cases, React provides us with primitive tools and leaves it to us to compose them ourselves. This is great for large projects that require flexibility, but can be confusing if you are new to React. It also doesn't help that there is a lot of misinformation on the web, not from malicious intent of the author but simply from lack of a full undestanding of how React works.
I've heard a lot of people recently saying that React Context can lead to performance issues. I'm a big supporter of using Context and have not experienced performance problems with using it, so I did some searching and read a few articles that discussed the performance problems. All of the articles I read were using Context in inefficient ways that would absolutely lead to unnecessary rerenders. I thought it would be best to demonstrate how to use Context in efficient ways and hopefully debunk some of the myths spreading on the web.
What is React Context?
First things first, React Context is a way to provide values from a parent component to its children without directly passing it through props.
With Context, the parent supplies the value to a "provider" component that wraps any children that will need that value.
The children can then subscribe to the context using either the "consumer" component or the useContext
hook that is now available.
Context is vastly beneficial when you have values that need to be shared across a large component tree and almost a necessity if you have intermediary components that are unaware of the dependency on the context value and therefore could not pass the value as a prop (more on that in a later post).
It does come with the trade-off of indirection. Once you use Context, you may have to trace up the component tree to find where the "provider" is that supplies the value, rather than following the props chain.
This article isn't intended to dive into when you should use context, but if you are new to the API I recommend reading the React Docs.
When does React rerender?
In order to be aware of the performance of our React applications, its best to have a basic understanding of when react rerenders components.
For most cases, React rerenders a component on a state change in that component or one of its ancestors.
So if all of your state is managed at the root component of your application, every change will rerender all of the components you have.
That sounds terribly innefficient, but React renders are very performant and a rerender does not necessarily mean the DOM is updated.
There are also ways to skip rerendering parts of the component tree using React.memo
, React.PureComponent
, or shouldComponentUpdate
, but that's not the focus of this article.
Instead we are just going to use this as a benchmark for Context.
We want to ensure that our use of Context causes fewer rerenders and not more.
How do we track rerenders?
There are a few ways to track rerenders, but the simplest approach that we will use for this article is simply console.log
in the components we want to track.
Function components we can simply console.log
in the function body, while class components can console.log
in the render
function.
Baseline with State
Let's start with a baseline using only React state. We'll use a todo list example to keep things simple. The CodeSandbox example below has 3 components:
TodoList
is the "root" and holds the state of all todo items.TodoListItem
displays a single todo item. It has no state of its own, but updates the parent state through callback props.AddTodo
contains a form for adding a new todo. It owns the state of the form, but communicates the new item toTodoList
when it is submitted.Header
is just a simple component that lives outside ofTodoList
to show that it doesn't rerender on state changes inTodoList
.
Each component and function has console.log
added to track rerenders.
Play around with adding and updating items and observe the Console tab below the preview.
There are a few things to notice from the logs in this baseline example.
First, any input to the field in AddTodo
rerenders only that component.
As mentioned earlier, state updates rerender that component and everything below, not above.
Second, when a todo is added, updated, or removed, not only do all TodoListItem
components rerender, but so does AddTodo
.
This may seem weird since nothing in AddTodo
relies on todos
, but that is how React works by default.
Because state updated in TodoList
, and AddTodo
is a child of TodoList
, it gets rerendered on a state update in TodoList
.
Finally, notice that Header
only renders once. Because it lives above TodoList
it is not affected by the state updates.
Introducing Context
Now let's implement React Context as a replacement for the state in TodoList
.
We can copy the same code from TodoList
into a new component that will act as the Context value provider.
The new component, which I've called TodoContextProvider
can then wrap the entire app in index.js
.
Once that is done, any component in the app can use the useTodos
hook to access the context value.
Notice how fewer props we have being passed around now. Each component instead subscribes itself to the Context value.
Using Context for something this simple is absolutely overkill, but this is just an example. I'm not suggesting you go switch all of your state to Context.
Notice that this didn't eliminate any component renders. In fact, it added the TodoContextProvider
render.
That's because all of our components are subscribing to the context value with useTodos
.
Even if they didn't, the "root" TodoList
component subscribes to it, so the rerender that happens there will cascade to the other components anyway.
Header
still doesn't rerender though even though it is underneath TodoContextProvider
.
Even with the state updates happening, it does not cascade the rerender to Header
.
We are even providing a new value object on every rerender of TodoContextProvider
.
This shows us that React is smart enough not to rerender all components beneath TodoContextProvider
, just those that subscribe to the Context value.
So Context hasn't made anything worse (the added TodoContextProvider
is negligible), but it also hasn't helped our performance.
In a large app we couldn't benefit from improved maintainability and reduced prop-drilling though, so going this far is not for nothing.
Let's go a bit deeper though and explore some ways to skip unnecessary rerenders.
Skipping Rerenders
Remember how I said this was overkill for our todo list? Well this is extreme overkill. I would only recommend going this far if you have already implemented context and are having measurable performance concerns. Also, why are your renders so slow? Try to improve that first and it will have a greater overall benefit on your app.
We can start by improving the referential integrity of the Context value.
In our previous example, the three update functions we provided were declared as new function references on every render of TodoContextProvider
.
That's probably not a big performance issue in itself, but prevents us from relying on referential integrity to skip rerendering.
We can fix that by wrapping them in useCallback
, which will only give us a new function reference if one of the dependencies changes.
In our case we have no dependencies (setTodos
is guaranteed to remain consistent, and also why we use the callback version), so we will only ever make one copy of each of these functions.
That alone doesn't help us though. It just allows us to take the next step.
AddTodo
doesn't actually rely on the todos themselves, just the addTodo
function.
Let's first lift AddTodo
to be rendered in index.js
instead of by TodoList
so that it is unaffected by TodoList
rerenders.
If AddTodo
is expensive to rerender, we can wrap the component in React.memo
which will only rerender on a prop, state, or context value change.
This is only a shallow compare though, which is why immutability is important.
In the case of AddTodo
, our context value is changing, but we are only concerned with one function out of it.
We can take advantage of that by wrapping another component around AddTodo
that connects to useTodos
and only passes the addTodo
function to AddTodo
as a prop.
By doing this we remove the useTodos
dependency from AddTodo
itself, and React.memo
will check the referential integrity of our new prop and skip rerendering!
To be fair, we haven't reduced the number of rerenders, but we did exchange an expensive rerender for an inexpensive one.
All of this can be done within the internal scope of AddTodo.jsx
and none of the other components in the tree know the difference.
Let's keep going! What if we don't want to rerender all TodoListItem
(again, only if it is expensive) components for a change to a single item, like changing isComplete
?
We can take a similar approach to subscribe to TodoListItem
to a single todo item.
By wrapping TodoListItem
in another component that extracts the values we need from useTodos
and passes them as props, we can rely on React.memo
to skip rerendering.
As long as the todo
prop's reference is maintained, we skip rerendering TodoListItem
!
This only works because of our use of immutability in updateTodo
.
Otherwise, we would have just introduced a bug because TodoListItem
would not have rerendered changes made to the item!
Redux didn't need this!
Actually, it did. Redux requires you to connect to your global store using selectors and connect
.
That connect
function directly relates to our AddTodoContainer
and TodoListItemContainer
components from above.
In fact, if you look in the React DevTools, you'll see that Redux introduces Connect(AddTodo)
and Connect(TodoListItem
components in our tree.
We could easily write a connect
utility function that selected from our context and did the same thing, we have just been a bit more explicit.
Using components instead of connect
also allows us to take advantage of other React things, like using React.useMemo
instead of reselect
to avoid heavy recomputations.
Additionally, Redux requires you to use immutability for it to prevent unnecessary rerenders of your connected components,
something we also accomplished with React.memo
and the same immutability limitations.
Wrapping Up
What should we take away from all of this? The performance of React Context is dependent on how we just it, just like many other things we use. I don't recommend replacing all of your state with Context, or going as far as we did with preventing rerenders, but having these tools and knowledge gives us the upper hand if we encounted performance problems and need to address them. You should always benchmark and exhaust render optimizations first before implementing these more extreme performance tricks. If you have any questions about Context, other material here, or have found anything incorrect about what I've said, please drop me an email and let me know!