reactjsreact-hooksreact-functional-componentreact-forwardrefreact-memo

Parent component unnecessarily re-renders all children when passing a ref to child nodes


I have the following use-case:

When component B's list gets a new element, all children A components are re-rendered, when they shouldn't.

Code

type Handle = {
  jump: () => void;
};

const Y = React.memo(
  React.forwardRef<Handle, { entry: number }>((props, ref) => {
    console.log("render");
    React.useImperativeHandle(
      ref,
      () => ({
        jump: () => {
          console.log("test");
        },
      }),
      []
    );

    return <div>{props.entry}</div>;
  })
);

const X: React.FC = React.memo(() => {
  const nodes = React.useRef<{ [key: string]: Handle }>({});
  const [array, setArray] = React.useState([1, 2, 3]);

  const handleClick = React.useCallback(() => {
    setArray(array.concat(4));
  }, [array]);

  const jumpToNode = React.useCallback(() => {
    nodes.current["1"]?.jump();
  }, []);

  return (
    <>
      <button onClick={handleClick}>add new</button>
      <button onClick={jumpToNode}>jump to first node</button>
      {array.map((e) => (
        <Y
          ref={(no: Handle) => { // <---- CULPRIT
             nodes.current[e.toString()] = no;
          }}
          entry={e}
          key={e}
        />
      ))}
    </>
  );
});

Investigation

When commenting out the ref that's being passed down to each component A, only the new incoming node gets rendered, while all other nodes do not re-render.

I created a simple codesandbox example in https://codesandbox.io/p/sandbox/react-typescript-forked-jckhqq?file=%2Fsrc%2FApp.tsx, that showcases the issue mentioned above. With the console open:

If the ref is then commented in, every time the "add new" button is clicked, all nodes are re-rendered.

I don't understand why that is happening and what one can do to avoid these unnecessary re-renderings. Our specific use-case is a computationally expensive component and every single render counts.

React version: 16.9.0


Solution

  • The ref causes React.memo to consider props as changed, and the component is re-rendered. The memo accepts a custom comparison function (arePropsEqual) to check if the props have changed as a 2nd parameter, and you can use it to ignore certain parameters when checking for equality. However, although React.memo doesn't ignore the ref, the ref is compared regardless of the result of arePropsEqual.

    So the simple solution, in my opinion, is to avoid using refForwarding in this case, passing the ref as a normal parameter (jumpRef in the example), and ignoring it in the React.memo (sandbox):

    const X: React.FC = React.memo(() => {
      const nodes = React.useRef<{ [key: string]: Handle }>({});
      const [array, setArray] = React.useState([1, 2, 3]);
    
      const handleClick = React.useCallback(() => {
        setArray((arr) => arr.concat(arr.length + 1));
      }, []);
    
      const jumpToNode = React.useCallback(() => {
        nodes.current["1"]?.jump();
      }, []);
    
      return (
        <>
          <button onClick={handleClick}>add new</button>
          <button onClick={jumpToNode}>jump to first node</button>
          {array.map((e) => (
            <Y
              jumpRef={(no: Handle) => {
                nodes.current[e.toString()] = no;
              }}
              entry={e}
              key={e}
            />
          ))}
        </>
      );
    });
    
    type JumpRef = (no: Handle) => void;
    type Props = {
      entry: number;
      jumpRef: JumpRef; // instead of normal ref
    };
    
    const Y = React.memo(
      ({ entry, jumpRef }: Props) => {
        console.log("render");
        React.useImperativeHandle(
          jumpRef,
          () => ({
            jump: () => {
              console.log("test");
            },
          }),
          []
        );
    
        return <div>{entry}</div>;
      },
      // only check if entry changed for example
      (oldProps, newProps) => oldProps.entry === newProps.entry
    );