React.memo inside nested component never calls areEqual, always re-renders

Viewed 1260

I bumped across a curiosity of where React.memo behaves as expected today.

It seems that React.memo doesn't work in nested functional components, but does work in the components main return and in nested functions that return a React.memo component.

Is this intended behaviour or is there another way to make React.memo work with nested functional components?

https://codesandbox.io/s/sweet-bhaskara-kbnlg?file=/src/memoTest.js:0-451

const MemoTestBase = ({ name, counter }) => {
  console.log("rendering", name);
  return (
    <div className="">
      <p>
        {name}: {counter}
      </p>
    </div>
  );
};

function areEqual(prevProps, nextProps) {
  const isEqual = prevProps.name === nextProps.name;

  if (isEqual) {
    console.log("stopping", prevProps.name);
  }

  return isEqual;
}

const MemoTest = React.memo(MemoTestBase, areEqual);


const { useState } = React;

function App() {
  const [counter, setCounter] = useState(0);

  function funOne() {
    return <MemoTest name="inside function" counter={counter} />;
  }

  const ComponentOne = () => {
    return <MemoTest name="inside component" counter={counter} />;
  };

  return (
    <div className="App">
      <h1>React.memo render test</h1>
      <p>Count: {counter}</p>
      <button onClick={() => setCounter(counter + 1)}>Count + 1</button>
      <hr />
      <p>Memo tests</p>
      <MemoTest name="root" counter={counter} /> {/* works! */}
      <ComponentOne /> {/* doesn't work */}
      {ComponentOne()} {/* works! */}
      {funOne()} {/* works! */}
    </div>
  );
}
ReactDOM.render(<App />, document.querySelector('.react'));
<script crossorigin src="https://unpkg.com/react@16/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@16/umd/react-dom.development.js"></script>
<div class='react'></div>

2 Answers

The problem here is that you are defining ComponentOne inside your component. So when React tries to re-render it sees that the ComponentOne Reference in previous re-render was different than the one in the current re-render and hence this component is remounted. Hence the memo doesn't work

However when you call it like a function, it returns the same instance of MemoTest, so a re-render is supposed to happen which is prevented by memo as props do not change

To make it work like you have define it currently, you should move the functional component definition out of the App Component

const MemoTestBase = ({ name, counter }) => {
  console.log("rendering", name);
  return (
    <div className="">
      <p>
        {name}: {counter}
      </p>
    </div>
  );
};

function areEqual(prevProps, nextProps) {
  const isEqual = prevProps.name === nextProps.name;

  if (isEqual) {
    console.log("stopping", prevProps.name);
  }

  return isEqual;
}

const MemoTest = React.memo(MemoTestBase, areEqual);


const ComponentOne = ({counter}) => {
  return <MemoTest name="inside component" counter={counter} />;
};

const { useState } = React;

function App() {
  const [counter, setCounter] = useState(0);

  function funOne() {
    return <MemoTest name="inside function" counter={counter} />;
  }

  return (
    <div className="App">
      <h1>React.memo render test</h1>
      <p>Count: {counter}</p>
      <button onClick={() => setCounter(counter + 1)}>Count + 1</button>
      <hr />
      <p>Memo tests</p>
      <MemoTest name="root" counter={counter} /> {/* works! */}
      <ComponentOne counter={counter}/> {/* doesn't work */}
      {ComponentOne({counter})} {/* works! */}
      {funOne()} {/* works! */}
    </div>
  );
}
ReactDOM.render(<App />, document.querySelector('.react'));
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.8.3/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.3/umd/react-dom.production.min.js"></script>
<div class='react'></div>

I converted the MemoTest and ComponentOne code from JSX to React.createElement counter part. Also added a useEffect with [] dependency array inside MemoTest which signifies initial component mount and calls a cleanup function on component unmount. This will let you differentiate between the Component() call and React.createElement(Component) call.

The code :-

const { useState, useEffect } = React;



const MemoTestBase = ({ name, counter }) => {
  useEffect(() => {
    console.log("mounting");
    return () => console.log("unmounting");
  }, []);
  return (
    <div className="">
      <p>
        {name}: {counter}
      </p>
    </div>
  );
};

function areEqual(prevProps, nextProps) {
  const isEqual = prevProps.name === nextProps.name;

  if (isEqual) {
  }

  return isEqual;
}

const MemoTest = React.memo(MemoTestBase, areEqual);

function App() {
  const [counter, setCounter] = useState(0);

  
const ComponentOne = () => {
  return React.createElement(MemoTest,{name:"inside component", counter});
};

  return (
    <div className="App">
      <h1>React.memo render test</h1>
      <p>Count: {counter}</p>
      <button onClick={() => setCounter(counter + 1)}>Count + 1</button>
      <hr />
      <p>Memo tests</p>
      {React.createElement(ComponentOne)} {/* doesn't work */}
      {ComponentOne()} {/* works! */}
    </div>
  );
}
ReactDOM.render(<App />, document.querySelector('.react'));
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.8.3/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.3/umd/react-dom.production.min.js"></script>
<div class='react'></div>

Now, deriving from @Shubham's answer, a new Component function is created every time when your counter changes which causes a re-render of App.

Now a new reference of Component is passed to React.createElement(...) on re-render which mean's the old one is unmounted and a new one is mounted. This also means the same for it's children. The child here is MemoTest and so you will be able to see the unmounted and mounted logs being triggered from inside it. React.memo(..) is a render optimization and not something which can memoize across re-mount of components.

For the Component() call, there is no React.createElement in the picture itself so there is no unmount of Component and thus none for MemoTest and so React.memo(..) works as expected.

Moving Component outside of App as a separate function ensures that it's reference doesn't change on each render of App as it was happening earlier and so calling React.createElement(Component) doesn't cause a unmount and mount on each render of App which ultimately ensures that React.createElement(MemoTest) only tries to cause a re-render of MemoTest which doesn't work due to React.memo(...).

Related