Skip to content

Instantly share code, notes, and snippets.

@slikts
Last active March 3, 2024 12:57
Show Gist options
  • Star 122 You must be signed in to star a gist
  • Fork 3 You must be signed in to fork a gist
  • 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

windmaomao commented Aug 26, 2021

The children prop is added to the problem, and by design, when App reconciles, the Parent carries the new children prop.

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

solution 1

In order to work with React, we can useMemo for the <Child />.

const App = () => {
   const child = useMemo(() => {
      return <Child />
   }, [a, b])
   return <Parent a={a} b={b}>{child}</Parent>
}

This solution seems odd, because the change has to be applied to the App.

solution 2

If we want to work with the Parent instead, maybe we can use the memo second parameter to just ignore children.

export default React.memo(Parent, ignoreChildrenEqual)

Tried both solution

but i tried both solution, seems all not practical. The reason is that how do I know when the Child needs to render again. The child could have a prop change, or a state change. So it needs to render most of time the App renders.

Now goes back to the Child. we still need to focus if the Child (the first component which doesn't have a children, like function component or classs component) to optimize the performance.

Another attempt

Actually i made another attempt.

import { memo } from 'react'

const equalWithoutChildren = (prev, next) => {
  for (let k in prev) {
    if (k === 'children') continue
    if (prev[k] !== next[k]) return false
  }
  return true
}

const memoChild = (Component) => {
  const Memo = memo(({ children, ...props }) => {
    return <Component children={children} {...props} />
  }, equalWithoutChildren)

  return Memo
}

export default memoChild

This would turn any component into one that monitors the children based on the props change.

const Child = memoChild(({ children }) => {
  console.log('Nothing1')
  return children
})

function Title() {
  const [count, setCount] = useState(0)
  const onClick = () => {
    setCount(c => c + 1)
  }
  const flag = count > 1
  const a = useMemo(() => flag, [flag])

  return (
    <>
      <div onClick={onClick}>{count}</div>
      <Child flag={flag}>
        <Nothing2 a={a} />
      </Child>
    </>
  )
}

@Ashoat
Copy link

Ashoat commented Feb 22, 2022

This is an excellent insight into how React works. When thinking about render cycles and memoization in React, It's critical to understand that children is no different from any other prop.

In my opinion, the cleanest "solution" to this problem is actually to split the ancestor component into two. Compare my forked CodeSandbox to the original.

While I agree that render cycle optimization can sometimes be premature, it's pretty critical in "provider" components and root components. Even outside of that, it's a good "rule of thumb" to keep this separation: stateless (aka "pure") components that return nested JSX, and stateful components that avoid doing so.

Wrapping the children in a React.memo is an interesting alternative, and does solve the problem... but in my opinion it's a bit messier and less readable. I'll admit that the preference is purely aesthetic.

@willdspd
Copy link

willdspd commented Mar 4, 2022

The children prop is added to the problem, and by design, when App reconciles, the Parent carries the new children prop.

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

solution 1

In order to work with React, we can useMemo for the <Child />.

const App = () => {
   const child = useMemo(() => {
      return <Child />
   }, [a, b])
   return <Parent a={a} b={b}>{child}</Parent>
}

This solution seems odd, because the change has to be applied to the App.

solution 2

If we want to work with the Parent instead, maybe we can use the memo second parameter to just ignore children.

export default React.memo(Parent, ignoreChildrenEqual)

Tried both solution

but i tried both solution, seems all not practical. The reason is that how do I know when the Child needs to render again. The child could have a prop change, or a state change. So it needs to render most of time the App renders.

Now goes back to the Child. we still need to focus if the Child (the first component which doesn't have a children, like function component or classs component) to optimize the performance.

Another attempt

Actually i made another attempt.

import { memo } from 'react'

const equalWithoutChildren = (prev, next) => {
  for (let k in prev) {
    if (k === 'children') continue
    if (prev[k] !== next[k]) return false
  }
  return true
}

const memoChild = (Component) => {
  const Memo = memo(({ children, ...props }) => {
    return <Component children={children} {...props} />
  }, equalWithoutChildren)

  return Memo
}

export default memoChild

This would turn any component into one that monitors the children based on the props change.

const Child = memoChild(({ children }) => {
  console.log('Nothing1')
  return children
})

function Title() {
  const [count, setCount] = useState(0)
  const onClick = () => {
    setCount(c => c + 1)
  }
  const flag = count > 1
  const a = useMemo(() => flag, [flag])

  return (
    <>
      <div onClick={onClick}>{count}</div>
      <Child flag={flag}>
        <Nothing2 a={a} />
      </Child>
    </>
  )
}

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.

@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.

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