I've recently ran into a pitfall of React.memo()
that seems generally overlooked; skimming over the top results in Google just finds it mentioned in passing in a React issue, but not in the FAQ or API overview, and not in the articles that set out to explain React.memo()
(at least the ones I looked at). The issue is specifically that nesting children defeats memoization, unless the children are just plain text. To give a simplified code example:
const Memoized = React.memo(({ children }) => (<div>{children}</div>));
// Won't ever re-render
<Memoized>bar</Memoized>
// Will re-render every time; the memoization does nothing
<Memoized><b>bar</b></Memoized>
I've also made a running CodeSandbox example.
It's an obvious issue in retrospect: React.memo()
shallowly compares the new and the old props and short-circuits the render lifecycle if they're the same, and the children
prop isn't special, so passing newly created React elements (so any JSX that isn't specifically persisted) as children
will cause a re-render. However, it's not always easy to connect the dots in practice; for example, I arrived at this issue when I added children
to a previously memoized component and noticed that it would re-render unexpectedly.
It's probably a common interview question about function literals used as event handlers breaking memoization, and in class components it would be solved by persisting the event handlers as properties on this
, while the world of React hooks has a useCallback()
hook for this basic use case. Other builtin hooks that deal with memoization are useEffect()
and useCallback()
, whose second parameter is for passing a list of memoized dependencies. In fact, hooks as such exist to fill the role of this
in providing statefulness, but for function components (including arrow functions where this
is static) instead of classes.
State is central to React lifecycles and hooks; for example, it's important to know that changing the value of a context provider will re-render all the components that consume the context. reselect is an example of the complexity that has gone into providing memoized selectors for Redux to control which components will re-render on state updates, although these days, with Redux Toolkit and Immer, state management is increasingly simplified.
Still, one of React's strengths is that it allows to get away with a lot performance-wise, and VDOM reconciliation being much faster than DOM manipulation is one of the reasons for React's wide adoption, so on some level the preoccupation with performance seems questionable.
A common dictum is to measure first and to optimize later, owing to the fact that naive optimization can be wasted effort or counter-productive. Meanwhile, the value of experienced judgement is that it allows avoiding common problems in the first place, and memoization can be one of those "easy wins". VDOM reconciliation is faster than changing the DOM, but it can be even faster to not even do that.
The ideal case for rendering an app is that only the components whose dependencies have changed would need to be re-rendered, and that figuring out these dependencies would be cheap. The reason why React errs on the safe side by re-rendering and reconciling by default, and why React.memo()
compares props shallowly, is that JavaScript as a language has been on the back foot in providing features that support immutable data structures, and a lot of the ecosystem and convention is reliant on mutable shared state as a consequence. Thankfully, this is changing; I've written before about the underappreciated elegance of Immer, and there's also a proposal with some traction for adding value types to the language.
The relation between dependency modeling and immutability can be illustrated in code using Immer like so:
const foo = { a: { b: 1 }, c: { d: 2 }};
const bar = produce(stateA, draft => {
draft.a.b = 3; // Immutable deep update
});
foo === bar // false
foo.a === bar.a // false
foo.c === bar.c // true
The key takeaway is that all of these comparisons could be done with the ===
operator, which is so cheap that it's almost free, and that the comparisons were deep. Using the ===
operator once is all that React.memo()
would need to do to check whether a component needs to be re-rendered, and this check would also cover the children
prop or other nested object props.
It's already possible to make React.memo()
to do deep comparisons by providing a custom comparator, and I've made an example of a deepMemo()
component using my own value type library, but mentioning value semantics (the library readme has a detailed explanation of what those are) highlights the reason why something like Immer doesn't go all the way in fixing the issue:
const makeObj = () => produce({}, draft => { draft.a = 1 });
makeObj() === makeObj()
// false, even though the object shapes are the same
Meanwhile, with value types (using Tuplerone):
ValueObject({ a: 1 }) === ValueObject({ a: 1 }) // true
The reason why Immer doesn't work the same is that there's a significant cost in memoizing the shapes of objects, and that it can potentially leak memory if none of the values are garbage-collectible, and that the comparison would need to be repeated every time when using mutable objects, since they could have changed in the interim.
React would need to produce new component props using something like Immer to support cheap deep equality checks, so it's not really something a user can do without changing React internals, although it'd at least be an interesting experiment (if it hasn't been done already).
RTK with Immer is currently one of the nicest ways to limit child components causing unnecessary re-renders for parents, since it allows subscribing only to specific parts of the state, but it still has the issue of unnecessary child re-renders, which React.memo()
is intended to help solve.
If the memoized components are supposed to be used with children, it's possible to do something like this:
const children = useMemo(() => <div>foo</div>, []);
return (<Memoized>{children}</Memoized>);
It's probably not worth the clutter, however, and might not be faster. I'm planning to brave using a deep comparator function with React.memo()
for cases like this more often, but that's more for personal projects where it doesn't need to be defended in a code review.
The boring takeaway is that it's important to be cognizant of the limitations of tools like React.memo()
, and to have a vision of where the field is moving to be prepared for when there's better solutions.
Hi @windmaomao, great insight! However, I may be missing something, but your solution doesn't take into account if a component in the children prop ACTUALLY DOES change, because you're just ignoring the children prop completely when making your equality check. If I am right (again, I may be missing something), the only solution to reliably memoizing a component with children (that can change themselves) is to use useMemo on the children components. This allows you to prevent renders when not necessary, and manually trigger renders when you need the children to update.