A React.memo component that keeps re-rendering is one of the most common performance puzzles — some prop is changing identity every render, and you can't see which. useWhyDidYouUpdate is the debugging hook that tells you: drop it in, and on every render it compares the current props to the previous ones and logs exactly which keys changed, with their old and new values.
Implement useWhyDidYouUpdate(name, props). Keep the previous render's props in a ref, diff them against the current props (by Object.is), and when anything changed, console.log the changes tagged with name. Return the changes object ({ key: { from, to } }), or null when nothing changed — including the first render.
function useWhyDidYouUpdate(name, props) {
// returns { [key]: { from, to } } of changed props, or null
}
function Row(props) {
useWhyDidYouUpdate('Row', props);
// console: Row { onClick: { from: fn, to: fn } } <- a new function each render!
return <div>{props.label}</div>;
}
const changes = useWhyDidYouUpdate('Chart', { data, options });
// changes === { options: { from: {...}, to: {...} } } when `options` is rebuilt
Object.is(prev, next) is false; record { from, to }.from/to as undefined accordingly.null) on the first render or when nothing changed.You'll remember the previous render's props in a ref, diff every key against the current props with Object.is, log and return the changed ones, then refresh the ref for next time.
To answer "why did this component re-render?", you need to compare this render's props with the last one's — but a component function can't see its own history; each render starts clean. The trick is a ref, which persists across renders and isn't part of the reactive data flow. Store last render's props there, compare on the next render, and you can name every key that changed and by how much. The comparison must be Object.is (React's own equality), so a new object or function with the same shape still counts as a change — which is exactly the bug you're usually hunting.
Keep a snapshot of "props last time" in a ref. Each render: line up last time's keys and this time's keys, and for every key where Object.is(prev, next) is false, note { from, to }. That set of differences is your answer — log it and hand it back. Then overwrite the snapshot with the current props so the next render compares against this one. First render has no snapshot, so there's nothing to diff.
The tempting version keeps the previous props in state:
function useWhyDidYouUpdateNaive(name, props) {
const [previous, setPrevious] = useState(props);
const changes = {};
for (const key in props) {
if (previous[key] !== props[key]) changes[key] = { from: previous[key], to: props[key] };
}
setPrevious(props); // setState during render -> infinite loop
return changes;
}
Two bugs. Storing previous in state and calling setPrevious during render schedules another render, which sets state again — an infinite loop. And iterating only for (const key in props) misses keys that were removed (present last time, gone now). A ref (no re-render) plus diffing the union of old and new keys fixes both.
const { useRef, useEffect } = require('react');
function useWhyDidYouUpdate(name, props) {
const previous = useRef();
let changed = null;
if (previous.current) {
// Union of previous and current keys catches added AND removed props.
const keys = Object.keys({ ...previous.current, ...props });
const changes = {};
for (const key of keys) {
if (!Object.is(previous.current[key], props[key])) {
changes[key] = { from: previous.current[key], to: props[key] };
}
}
if (Object.keys(changes).length > 0) changed = changes;
}
useEffect(() => {
if (changed) console.log('[why-did-you-update]', name, changed);
previous.current = props; // snapshot for next render
});
return changed;
}
module.exports = { useWhyDidYouUpdate };
The diff happens during render: if there's a previous snapshot, we walk the union of old and new keys (so an added or removed prop is caught) and record each key where Object.is reports a difference. changed is that object, or null when the map is empty or there's no prior snapshot (first render). The useEffect runs after commit: it logs when there were changes, then sets previous.current = props so the next render diffs against this one. Using a ref is essential — mutating it triggers no re-render, so there's no loop, and it faithfully carries one render's props into the next.
A Row renders with { label: 'A', onClick: fn1 }, then its parent re-renders passing a fresh onClick:
previous.current is undefined, so changed stays null. The effect logs nothing and sets previous.current = { label: 'A', onClick: fn1 }. Hook returns null.{ label: 'A', onClick: fn2 }) — keys label, onClick. Object.is('A', 'A') is true → skip. Object.is(fn1, fn2) is false → changes.onClick = { from: fn1, to: fn2 }. changed is that object.[why-did-you-update] Row { onClick: { from: fn1, to: fn2 } }, then snapshots the new props.You immediately see the culprit: onClick is a new function every render (an inline arrow), which is why React.memo didn't help.
setState during render loops infinitely. Use a ref, which persists without re-rendering.for (key in props) misses removed props. Diff the union of previous and current keys.!== vs Object.is — Object.is matches React's comparison, correctly treating NaN as equal and distinguishing +0/-0; use it to mirror what memo actually sees.NODE_ENV !== 'production' so it doesn't ship.why-did-you-render — the popular library automates this across a whole app, patching React to report avoidable re-renders, built on this same diff idea.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
A React.memo component that keeps re-rendering is one of the most common performance puzzles — some prop is changing identity every render, and you can't see which. useWhyDidYouUpdate is the debugging hook that tells you: drop it in, and on every render it compares the current props to the previous ones and logs exactly which keys changed, with their old and new values.
Implement useWhyDidYouUpdate(name, props). Keep the previous render's props in a ref, diff them against the current props (by Object.is), and when anything changed, console.log the changes tagged with name. Return the changes object ({ key: { from, to } }), or null when nothing changed — including the first render.
function useWhyDidYouUpdate(name, props) {
// returns { [key]: { from, to } } of changed props, or null
}
function Row(props) {
useWhyDidYouUpdate('Row', props);
// console: Row { onClick: { from: fn, to: fn } } <- a new function each render!
return <div>{props.label}</div>;
}
const changes = useWhyDidYouUpdate('Chart', { data, options });
// changes === { options: { from: {...}, to: {...} } } when `options` is rebuilt
Object.is(prev, next) is false; record { from, to }.from/to as undefined accordingly.null) on the first render or when nothing changed.You'll remember the previous render's props in a ref, diff every key against the current props with Object.is, log and return the changed ones, then refresh the ref for next time.
To answer "why did this component re-render?", you need to compare this render's props with the last one's — but a component function can't see its own history; each render starts clean. The trick is a ref, which persists across renders and isn't part of the reactive data flow. Store last render's props there, compare on the next render, and you can name every key that changed and by how much. The comparison must be Object.is (React's own equality), so a new object or function with the same shape still counts as a change — which is exactly the bug you're usually hunting.
Keep a snapshot of "props last time" in a ref. Each render: line up last time's keys and this time's keys, and for every key where Object.is(prev, next) is false, note { from, to }. That set of differences is your answer — log it and hand it back. Then overwrite the snapshot with the current props so the next render compares against this one. First render has no snapshot, so there's nothing to diff.
The tempting version keeps the previous props in state:
function useWhyDidYouUpdateNaive(name, props) {
const [previous, setPrevious] = useState(props);
const changes = {};
for (const key in props) {
if (previous[key] !== props[key]) changes[key] = { from: previous[key], to: props[key] };
}
setPrevious(props); // setState during render -> infinite loop
return changes;
}
Two bugs. Storing previous in state and calling setPrevious during render schedules another render, which sets state again — an infinite loop. And iterating only for (const key in props) misses keys that were removed (present last time, gone now). A ref (no re-render) plus diffing the union of old and new keys fixes both.
const { useRef, useEffect } = require('react');
function useWhyDidYouUpdate(name, props) {
const previous = useRef();
let changed = null;
if (previous.current) {
// Union of previous and current keys catches added AND removed props.
const keys = Object.keys({ ...previous.current, ...props });
const changes = {};
for (const key of keys) {
if (!Object.is(previous.current[key], props[key])) {
changes[key] = { from: previous.current[key], to: props[key] };
}
}
if (Object.keys(changes).length > 0) changed = changes;
}
useEffect(() => {
if (changed) console.log('[why-did-you-update]', name, changed);
previous.current = props; // snapshot for next render
});
return changed;
}
module.exports = { useWhyDidYouUpdate };
The diff happens during render: if there's a previous snapshot, we walk the union of old and new keys (so an added or removed prop is caught) and record each key where Object.is reports a difference. changed is that object, or null when the map is empty or there's no prior snapshot (first render). The useEffect runs after commit: it logs when there were changes, then sets previous.current = props so the next render diffs against this one. Using a ref is essential — mutating it triggers no re-render, so there's no loop, and it faithfully carries one render's props into the next.
A Row renders with { label: 'A', onClick: fn1 }, then its parent re-renders passing a fresh onClick:
previous.current is undefined, so changed stays null. The effect logs nothing and sets previous.current = { label: 'A', onClick: fn1 }. Hook returns null.{ label: 'A', onClick: fn2 }) — keys label, onClick. Object.is('A', 'A') is true → skip. Object.is(fn1, fn2) is false → changes.onClick = { from: fn1, to: fn2 }. changed is that object.[why-did-you-update] Row { onClick: { from: fn1, to: fn2 } }, then snapshots the new props.You immediately see the culprit: onClick is a new function every render (an inline arrow), which is why React.memo didn't help.
setState during render loops infinitely. Use a ref, which persists without re-rendering.for (key in props) misses removed props. Diff the union of previous and current keys.!== vs Object.is — Object.is matches React's comparison, correctly treating NaN as equal and distinguishing +0/-0; use it to mirror what memo actually sees.NODE_ENV !== 'production' so it doesn't ship.why-did-you-render — the popular library automates this across a whole app, patching React to report avoidable re-renders, built on this same diff idea.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.