I have the following use-case:
React.memo
) component whose ref is being forwarded (React.forwardRef
).React.memo
) that renders multiple A components, by mapping over a list.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
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
);