Using Context to Prevent Unnecessary Re Renders
With the rising popularity of React, we are in an era of the most responsive user interfaces we have ever seen. React uses a virtual DOM, and for every render it runs calculations to determine which parts of the actual DOM need to get updated. This means that React only re-renders components that need to be re-rendered, and nothing more.
However, that doesn’t mean we shouldn’t introduce optimizations of our own! Too many re-renders can lead to performance issues, so we should optimize wherever we can. One common pitfall I see in React applications is what I call messenger components, which are components that pass some props from a parent to a child, but do not actually do anything with those props. For example, consider the following three components.
fun ction Parent(props) {
const foo = 1;
const bar = 2;
return (
<Child foo={foo} bar={bar} />
);
}
function Child(props) {
return (
<>
<p>Foo is {props.foo}</p>
<Grandchild bar={props.bar} />
</>
);
}
function Grandchild(props) {
return (
<p>Bar is {props.bar}</p>
);
}
In this situation, Child
receives both foo
and bar
as props, but only truly “cares” about the value of foo
; Grandchild
cares about the value of bar
. In this case, Child
is a messenger component for the bar
prop. Child
should only re-render when the value of foo
updates, but in this case it will also re-render when bar
updates. How can we fix this?
The Context API #
Using React’s Context API, we can broadcast changes to values, and only components that directly subscribe to that piece of context will receive the new value. This solves the problem of messenger components re-rendering unnecessarily, because we no longer need to pass props down - we can simply directly subscribe to the value in Grandchild
. To demonstrate this, I will add refs to show the number of re-renders for each component before and after adding context.
Before #
Using the current system of prop drilling, we can see how Child
will re-render every time either prop is updated. If you like, use create-react-app to bootstrap an app to see the renders in action.
function Parent(props) {
const [foo, setFoo] = useState(0);
const [bar, setBar] = useState(0);
function incrementFoo() {
setFoo((prev) => prev + 1);
}
function incrementBar() {
setBar((prev) => prev + 1);
}
return (
<>
<button onClick={incrementFoo}>Increment Foo</button>
<button onClick={incrementBar}>Increment Bar</button>
<Child foo={foo} bar={bar} />
</>
);
}
function Child(props) {
const renders = useRef(0);
return (
<>
<p>Foo (in Child): {props.foo}</p>
<p>Child renders: {renders.current++}</p>
<Grandchild bar={bar} />
</>
);
}
function Grandchild(props) {
const renders = useRef(0);
return (
<>
<p>Bar (in Grandchild): {props.bar}</p>
<p>Grandchild renders: {renders.current++}</p>
</>
);
}
If you run this application, you will notice that the number of renders for the Child
and Grandchild
components will always be the same. However, since Child
doesn’t need to update when bar
updates, the number of re-renders shouldn’t be the same for the two components.
But wait, why don’t we just memoize Child
so that it only re-renders when foo
updates? Beware of this approach, because it is only half of the solution! Memoizing Child
to not re-render when bar
is updated leaves nothing to re-render Grandchild
, so this will result in unintended consequences: Grandchild
would only re-render when foo
is updated.
After #
Using a combination of memoization and the Context API, we can prevent Child
from re-rendering unnecessarily. The following application demonstrates this:
const BarContext = createContext(null);
function Parent(props) {
const [foo, setFoo] = useState(0);
const [bar, setBar] = useState(0);
function incrementFoo() {
setFoo((prev) => prev + 1);
}
function incrementBar() {
setBar((prev) => prev + 1);
}
return (
<BarContext.Provider value={bar}>
<button onClick={incrementFoo}>Increment Foo</button>
<button onClick={incrementBar}>Increment Bar</button>
<MemoizedChild foo={foo} />
</BarContext.Provider>
);
}
function Child(props) {
const renders = useRef(0);
return (
<>
<p>Foo (in Child): {props.foo}</p>
<p>Child renders: {renders.current++}</p>
<Grandchild />
</>
);
}
const MemoizedChild = memo(Child);
function Grandchild(props) {
const renders = useRef(0);
const bar = useContext(BarContext);
return (
<>
<p>Bar (in Grandchild): {bar}</p>
<p>Grandchild renders: {renders.current++}</p>
</>
);
}
As you can see, we made two updates. First, we used a piece of context to hold the reference to bar
, and we have Grandchild
subscribe to that piece of context using the useContext
hook. However, this is only half of the equation; if we do not memoize Child
, then anytime bar
is updated in Parent
, Child
will re-render. In order to prevent this, we use the memo function to create a memoized version of the Child
component that will only re-render when the props change.
And voila! We have successfully eliminated unnecessary messenger re-renders with the Context API and some simple memoization.
I would be remiss not to say that for most applications, re-renders don’t cause much of a performance issue because most applications don’t involve a lot of front-end computation. However, for applications with a lot of animations or drag-and-drops, it’s more than worth it to eliminate unnecessary re-renders wherever you can. Happy coding!