Skip to content

Instantly share code, notes, and snippets.

@slikts
Last active November 7, 2024 04:59
Show Gist options
  • Save slikts/e224b924612d53c1b61f359cfb962c06 to your computer and use it in GitHub Desktop.
Save slikts/e224b924612d53c1b61f359cfb962c06 to your computer and use it in GitHub Desktop.
Why using the `children` prop makes `React.memo()` not work

nelabs.dev

Why using the children prop makes React.memo() not work

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.

Other memoization pitfalls are better covered ground

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.

Premature optimization or not

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 missing piece of immutability

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.

There's no completely nice solution

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.

@windmaomao
Copy link

@williamdespard yeah, i have to revisit what I have wrote out there.

Overall I think ignoring the children isn't a universal correct solution, however, it depends on if you think there's a change to the Child. Taking the following as an example.

const App = () => {
  return (
     <Parent 
          a={a}
          b={b}
     >
        <Child />
     </Parent>
  )
}

If the props a or b changes, and you don't want to recalculate Child, then it's perfect case to apply it. You might say, I don't know if Child would need to change, in that case, that reverts to the React base case. It renders when parent renders!

Maybe there's not much to it, if we were writing in the following way, maybe it helps us to understand the problem.

const App = () => {
  return (
     <Parent 
          a={a}
          b={b}
          children={<Child />}
     />
  )
}

NOTE: Props doesn't seem to have much play in React to determine if a component should render in general. To me, they are more like a responser, or simply arguments to a function. Whether the component needs to be rendered has been determined already (at least in the function component case).

@kenanyildiz
Copy link

I had the same issue, below works for me.

const title = useMemo(() => <h5>{name}</h5>, [name])
return (<Memoized age={props.age} title={title} />;

In this way you don't need to have a custom comparison fn in memoized component. If there is change on name memoized cmp gets rendered, otherwise it does not (except other props changes).

The use case is, for some reason you might need to have different heading tag/title than h5. So you can pass.
const title = useMemo(() => <h1>{age}</h1>, [age])
Using props as slot and do not break the memoization.

@KODerFunk
Copy link

@slikts Congratulations on your decision to use deep comparison for optimization. I'm not kidding, many people appeal to the fact that deep comparison is resource-intensive, but doing it in the right places will be much cheaper than shifting it to React.
Оnly thing is, I would recommend doing it even higher, first of all, when there are deep changes in data where it is stored, and most importantly, when there are repeated API requests, when you are trying to replace previous data that either has not changed or has changed partially.
I understand that Immer solves some of problems, and that React does a lot of work asynchronously, but I use optimize-immutable-update. README so far only describes problem, types can be used to understand the capabilities, and in the future I plan to add more information and asynchronous updating of structures.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment