Why don't cached React elements rerender when their parents rerender?

Published

TL;DR: When a React component instance rerenders, React compares the elements in the previous element tree with the next one. If it finds that two elements have referential equality, React will skip rerendering that element.


For some background, the impetus for this post is an issue we encountered at work a while back. We were running into an issue with React Router 3.x where a route component would rerender, but its children route components wouldn't. In other words...

1<Router history={hashHistory}>
2  <Route
3    path="/"
4    component={Parent} // This was rerendering due to a state change
5  >
6    <Route
7      path="demo"
8      component={Child} // But this was not
9    />
10  </Route>
11</Router>
12

If you'd like to see the behavior yourself, I put together a little demo. To reproduce:

  • Load the sandbox, this will load the root '/' route
  • Click 'Go to demo'
  • Notice that the parent and child components are rendering the same message
  • Change the background using the color picker
  • Result: The parent updates to show the correct background color, but the child does not
  • Expected behavior: Both the parent and child show the correct background color

Why did I expect that behavior? My mental model of React told me that when a parent rerenders, the children rerender (unless they explicitly choose not to via PureComponent, React.memo, etc).

So, what's going on? After a whole lot of digging, I found the underlying cause.

It seems React skips re-rendering for elements that pass prev === next.

Andreas Svensson

Turns out that React Router v3 caches the React elements it builds. And when React is rendering part of the tree, when it encounteres an element whose reference hasn't changed, it skips rerendering it.

Let's unpack this a bit more.

Elements and strict equality

Let's look at what that means in practice. In the following code, we have two components:

  • <Demo /> - A demo component that has some state (count), a button to update the state and trigger a rerender, and two RenderCountLogger elements.
  • <RenderCountLogger /> - A component that displays how many times it has rendered.
1import React, { useState, useRef, useEffect } from "react";
2
3function RenderCountLogger() {
4  const renderCountRef = useRef(1);
5  useEffect(() => {
6    renderCountRef.current += 1;
7  });
8  return <span>I&apos;ve rendered {renderCountRef.current} times</span>;
9}
10
11const renderCountLoggerElement = <RenderCountLogger />;
12
13export default function Demo() {
14  const [bool, setBool] = useState(false);
15  return (
16    <>
17      <p>Cached element: {renderCountLoggerElement}</p>
18      <p>
19        Inlined element: <RenderCountLogger />
20      </p>
21      <button onClick={() => setBool((currentState) => !currentState)}>
22        Rerender parent
23      </button>
24    </>
25  );
26}
27

We're rendering two instances of <RenderCountLogger />, one on line 17 (aka the "cached element") and one on line 19 (aka the "inlined element"). When we click the button and cause the <Demo /> to rerender, what happens to the cached element and inlined element? Turns out that only the inlined element rerenders. You can see this behavior below.


Why is this? As Andreas pointed out, React won't rerender elements who pass a strict equality check. Since we're assigning the element to the renderCountLoggerElement variable on line 11, when the parent's state updates and React starts doing its reconciliation, it encounters this element on line 19, sees that renderCountLoggerElement === renderCountLoggerElement, and just moves on without rerendering it.

This is unlike when we use JSX, which is an abstraction over React.createElement, which returns a new object. As you probably know {} === {} // false, so the strict equality check doesn't pass, and the component rerenders normally.

Can we rerender cached elements?

Yes! While rerenders that happen up the element tree won't cause our cached element, if a cached element rerenders itself via a setState or forceUpdate, it'll rerender normally.

It will also rerender normally if it's consuming context and the context provider's value changes. We can see this behavior below.

1import React, { useState, useContext, createContext } from "react";
2
3const TextContext = createContext();
4
5function TextContextProvider({ children }) {
6  const [text, setText] = useState("");
7  return (
8    <TextContext.Provider value={text}>
9      <label>
10        <div>Change the text to update the context value</div>
11        <br />
12        <input
13          type="text"
14          value={text}
15          onChange={(e) => setText(e.target.value)}
16        />
17      </label>
18      <br />
19      {children}
20    </TextContext.Provider>
21  );
22}
23
24function TextContextLogger({ label }) {
25  const textContextValue = useContext(TextContext);
26  return (
27    <p>
28      The {label} says the value is: {textContextValue}
29    </p>
30  );
31}
32
33const cachedTextContextLogger = <TextContextLogger label="cached element" />;
34
35export default function Demo() {
36  return (
37    <TextContextProvider>
38      <TextContextLogger label="inlined element" />
39      {cachedTextContextLogger}
40    </TextContextProvider>
41  );
42}
43

In this example, we're once again using an inlined element and a cached element but, unlike before, both elements rerender when we change the value of the context provider.


Workarounds

So, say you find yourself in a scenario where you're working with cached React elements and you need them to rerender when their parents rerender. What can you do? Simple, just wrap your cached element with React.cloneElement(el). This will clone the element, causing the equality check to return false, and your component will rerender normally.