Why React.memo fails to memoize this component?

136 views Asked by At

I have two components, one receiving another one as a prop. For clarity I will remove all the unnecessary logic and JSX:

function Child() {
  useEffect(() => {
    console.log("Child rerender");
  });
  return <div>Child component</div>;
}

function Parent({ children }: { children: React.ReactNode }) {
  useEffect(() => {
    console.log("Parent rerender");
  });
  return (
    <div>
      Parent component
      {children}
    </div>
  );
}

They're both memoized as follows:

const ChildMemo = React.memo(Child);
const ParentMemo = React.memo(Parent);

Now, they're both being rendered in the top level component with a state, again for clarity's sake let's imagine for now it's just a button setting some state:

function App() {
  useEffect(() => {
    console.log("App rerender");
  });
  const [count, setCount] = useState(0);

  return (
    <>
      <button onClick={() => setCount(count + 1)}>Update state {count}</button>
      <ParentMemo>
        <ChildMemo />
      </ParentMemo>
    </>
  );
};

I expect that clicking a button triggers state update leading to just "App rerender" being logged in the console. Yet the <Parent /> component is being rerendered as well. Why does that happen?

1

There are 1 answers

3
Adnan On

FIRST

Looking at your code, you're using useEffect without deps array.

So the parent rerendered message you see in the console is not because it's rerendered, but because you didn't define dependencies array;

so every time:

a state changes -> the ParentMemo is executed -> the effect will run -> message is printed to console,

but it doesn't mean it's rerendered.

If you want to know if it's rerendered, you must add empty dependencies array:

useEffect(()=>{console.log("component executed and will compare with shadow-dom to check if it needs rerender")});
useEffect(()=>{console.log("created and rerendered")},[]);
useEffect(()=>{console.log("created or updated, and rerendered"},[state]);

Without the array, useEffect have nothing to compare, so it will assume that you intentionally need to run this effect with every execution.

SECOND

Since ChildMemo don't have any state and no props are changed, so there is no need to rerender it, so ChildMemo is not executed again.

But ParentMemo has children prop, so you can't guarantee it will not be rerendered.

To quote from the Docs:

"Wrap a component in memo to get a memoized version of that component. This memoized version of your component will usually not be re-rendered when its parent component is re-rendered as long as its props have not changed. ((But)) React may still re-render it: memoization is a performance optimization, not a guarantee."

To avoid ParentMemo re-execution, you can do an ugly solution, which is to pass ChildMemo as it's as children:

function App() {
  useEffect(() => {
    console.log("App rerender");
  });
  const [count, setCount] = useState(0);

  return (
    <>
      <button onClick={() => setCount(count + 1)}>Update state {count}</button>
      <ParentMemo>
        {ChildMemo}
      </ParentMemo>
    </>
  );
};

and call it from inside ParentMemo:

function Parent({ children: C }: { children: React.ReactNode | () => React.ReactNode }) {
  useEffect(() => {
    console.log("Parent rerender");
  });
  return (
    <div>
      Parent component
      {typeof C === "function" ? <C /> : C}
    </div>
  );
}