usePreviousDistinct(value, compare?) returns the last value that was actually different from the current one — not the value from the previous render. Renders where the value stayed the same are skipped, so the answer survives however many times the component rerenders in between.
That gap is the whole question. usePrevious hands back the previous render's value, which is the same thing right up until the component rerenders for a reason that has nothing to do with your value: a parent rerendered, an unrelated piece of state moved, a notification count ticked. One idle rerender later, "what was it before?" answers "the same thing it is now", and the change you were tracking is gone.
function usePreviousDistinct<T>(
value: T,
compare?: (previous: T | undefined, next: T) => boolean,
): T | undefined;
compare returns true when the two values count as equal — that is, when nothing changed. It defaults to Object.is.
You are animating a tab switch and need to know which panel to slide out:
function Tabs({ tab }) {
const previousTab = usePreviousDistinct(tab);
return <Panel from={previousTab} to={tab} />;
}
// tab: inbox inbox archive archive spam
// render: 1 2 3 4 5
// returns: undefined undefined inbox inbox archive
Renders 2 and 4 change nothing, so the answer does not move. usePrevious would return inbox, inbox, archive, archive for renders 2–5 — on renders 2 and 4 that is just the tab you are already on.
A comparator decides what "different" means:
// Two objects with the same id are the same tab, so this is not a change:
const previousTab = usePreviousDistinct(tab, (a, b) => a.id === b.id);
// tab: {id: 1} {id: 1} {id: 2}
// returns: undefined undefined {id: 1}
usePrevious. Getting it right is the question.compare(previous, next) returns true when they are EQUAL. Not "did it change" — the polarity is backwards from what most people guess, and it matches the react-use hook this mirrors.Object.is. So objects and arrays are compared by reference: a rebuilt object with identical contents is a change.0, '', false, null and undefined are all values to remember, not stand-ins for "nothing".You'll keep two boxes — one holding the value you're tracking, one holding the last value that differed from it — and move them only on the renders where the value actually changed.
You're animating a tab switch. When the user goes from Inbox to Archive you slide the old panel out and the new one in, so you need to know which panel you came from. usePrevious looks like the answer, and for exactly one render it is. Then the component rerenders for a reason that has nothing to do with tabs — the parent rerendered, an unread count ticked, a useEffect set some unrelated state — and now usePrevious reports archive as the previous tab. It isn't wrong: the previous render's tab really was archive. It's just useless. Your animation has nowhere to slide in from, and there is nothing left to compare against.
The two hooks answer different questions. usePrevious answers "what was this value one render ago?" usePreviousDistinct answers "what was this value before it last changed?" Those coincide only when every render changes the value — and React rerenders for all sorts of reasons that have nothing to do with your value, so they come apart almost immediately. The second question needs a memory of its own: a box that gets written only when the value moves, and that ignores every render in between.
You already solved usePrevious, so start from it: put the value in a ref, update the ref in an effect after the render commits, hand the ref back.
const { useRef, useEffect } = require('react');
function usePreviousDistinct(value, compare = Object.is) {
const ref = useRef(undefined);
useEffect(() => {
ref.current = value; // remember this render's value for next time
}, [value]);
return ref.current;
}
Track inbox, switch to archive, and this returns inbox — exactly right. Now let anything rerender the component without touching the tab. The effect from the render that did change the tab already wrote archive into the box, so this render reads archive back out: the tab you're already on. The memory of inbox is gone, and no comparison after the fact can recover it, because the value it used to hold has been overwritten by a render that had nothing to say. The ref is doing exactly what it was told — it remembers the last render. You need it to remember the last change. (Note that compare never gets called at all, which is a good hint that the shape is wrong.)
const { useRef } = require('react');
function usePreviousDistinct(value, compare = Object.is) {
// The last value that was actually different — the answer we hand back. It
// starts undefined because nothing has changed yet.
const prevRef = useRef(undefined);
// The value we are currently tracking. Seeding it with `value` is what makes
// the first render safe: `compare(value, value)` is true, so the block below
// is skipped and prevRef stays undefined. No first-mount flag needed.
const curRef = useRef(value);
// Runs on every render, but only does something when the value moved.
// `compare` returns true when the two are EQUAL, so `!compare(...)` reads as
// "these differ". On an unchanged render this block is skipped entirely —
// and that skip is the whole hook: it is what stops an idle rerender from
// overwriting the memory.
if (!compare(curRef.current, value)) {
prevRef.current = curRef.current; // the value we're leaving becomes "previous"
curRef.current = value; // and the new one becomes what we track
}
return prevRef.current;
}
module.exports = { usePreviousDistinct };
Two boxes instead of one is the change that matters. A single ref can only answer one question at a time, and this hook needs to hold two facts: what it is tracking now, and what it was tracking before that. Splitting them lets the comparison happen before anything is overwritten — which is the part the naive version can never do, because by the time it looks, the old value is already gone.
The other shift is when the write happens. usePrevious defers its write to an effect on purpose: the one-render lag is its answer. This hook can't. The render where tab becomes archive is exactly the render that needs to be told inbox — that's when you start the animation — so the shift has to happen during render, before the return. That means writing refs while rendering, which React's docs advise against; useLatest works through that trade-off in full and it applies here unchanged.
The default is Object.is, and it is a deliberate pick. React compares your useState updates and your dependency arrays with Object.is, so defaulting to it means the hook agrees with React about what the word "changed" means. Anything React would bail out on, this hook also treats as no change.
That agreement fixes two real cases that plain === gets wrong:
NaN === NaN; // false → === says a steady NaN changes on EVERY render
Object.is(NaN, NaN); // true → nothing moves, which is the truth
+0 === -0; // true → === misses a real +0 to -0 transition
Object.is(0, -0); // false → the box shifts, correctly
NaN is the interesting one. With ===, a value that sits at NaN forever looks like it changes on every single render, so the box shifts every time and the hook ends up reporting NaN as its own previous value. That quirk also explains a piece of the reference implementation: react-use, which this hook mirrors, defaults to === and carries an extra first-mount flag alongside the two refs. The flag is doing real work there — under ===, a first render holding NaN compares false against itself, so the shift would run on render 1 and the hook would hand back NaN instead of undefined. Object.is says true for any value compared with itself, so seeding curRef with value is guard enough on its own and the flag can go.
When reference equality isn't what you mean, pass your own comparator. It takes (previous, next) and returns true when they count as equal:
Take the tab from the diagram: inbox, inbox, archive, archive, spam.
inbox. useRef(undefined) builds prevRef with nothing in it; useRef('inbox') builds curRef holding inbox. compare('inbox', 'inbox') is true, so !compare(...) is false and the block is skipped. Returns prevRef.current, which is undefined — correct, nothing has changed yet.inbox. Neither useRef call builds anything; React hands back the same two boxes and ignores the arguments. compare('inbox', 'inbox') is true again, block skipped, returns undefined. usePrevious answers inbox here — the tab you're looking at.archive. compare('inbox', 'archive') is false, so the block runs: prevRef.current takes inbox, then curRef.current takes archive. Returns inbox, on the very render where the switch happened — which is when the animation needs it.archive. compare('archive', 'archive') is true, block skipped, prevRef untouched. Returns inbox. This is the render where the naive version falls over: its single ref was overwritten with archive after render 3 committed.spam. compare('archive', 'spam') is false, so the boxes shift again: prevRef takes archive, curRef takes spam. Returns archive.usePrevious and comparing afterwards. const prev = usePrevious(tab); const changed = prev !== tab; works until one idle rerender lands, at which point prev is tab, changed is false, and it stays false. Fix: the comparison has to happen before the box is overwritten, which is what this hook is for.compare as "did it change". It returns true when the values are equal. Hand it (a, b) => a !== b and you invert the hook: it shifts on every unchanged render and freezes on every real change. Fix: name your comparator something like isEqual at the call site so the polarity is visible.useRef(value) for prevRef makes the first render report the current tab as its own previous tab. Only curRef gets seeded — prevRef starts undefined so that "nothing has changed yet" has an honest answer.undefined is invisible. undefined means both "nothing has changed yet" and "the value before the change was literally undefined". A component whose tab goes from undefined to inbox gets undefined back and cannot tell which happened. Fix: return a pair, as in Going further.(a, b) => a.id === b.id, a rebuilt object never gets adopted into curRef — the comparator said it was the same thing. So after {id: 1, label: 'Inbox'} and then {id: 1, label: 'Inbox (2)'}, what you get back later is the first one. That's usually what you want (you declared them equal), but it surprises people who expected the freshest copy.undefined apart from no value. Return { previous, hasPrevious }, or start prevRef at a module-private Symbol() and map it to undefined on the way out. Either kills the ambiguity in the last gotcha, at the cost of a clumsier call site — which is why the common version doesn't bother.{ page, filters } from props), reference equality reports a change every time. Pass a deep-equal comparator — dequal, lodash isEqual, or a hand-rolled shallow one for flat objects — and the hook only shifts on real content changes. It runs on every render, so keep the value small.useEffect whose dependency comparison you control. The two refs and the conditional shift don't change; only what you do at the moment of the shift does.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
usePreviousDistinct(value, compare?) returns the last value that was actually different from the current one — not the value from the previous render. Renders where the value stayed the same are skipped, so the answer survives however many times the component rerenders in between.
That gap is the whole question. usePrevious hands back the previous render's value, which is the same thing right up until the component rerenders for a reason that has nothing to do with your value: a parent rerendered, an unrelated piece of state moved, a notification count ticked. One idle rerender later, "what was it before?" answers "the same thing it is now", and the change you were tracking is gone.
function usePreviousDistinct<T>(
value: T,
compare?: (previous: T | undefined, next: T) => boolean,
): T | undefined;
compare returns true when the two values count as equal — that is, when nothing changed. It defaults to Object.is.
You are animating a tab switch and need to know which panel to slide out:
function Tabs({ tab }) {
const previousTab = usePreviousDistinct(tab);
return <Panel from={previousTab} to={tab} />;
}
// tab: inbox inbox archive archive spam
// render: 1 2 3 4 5
// returns: undefined undefined inbox inbox archive
Renders 2 and 4 change nothing, so the answer does not move. usePrevious would return inbox, inbox, archive, archive for renders 2–5 — on renders 2 and 4 that is just the tab you are already on.
A comparator decides what "different" means:
// Two objects with the same id are the same tab, so this is not a change:
const previousTab = usePreviousDistinct(tab, (a, b) => a.id === b.id);
// tab: {id: 1} {id: 1} {id: 2}
// returns: undefined undefined {id: 1}
usePrevious. Getting it right is the question.compare(previous, next) returns true when they are EQUAL. Not "did it change" — the polarity is backwards from what most people guess, and it matches the react-use hook this mirrors.Object.is. So objects and arrays are compared by reference: a rebuilt object with identical contents is a change.0, '', false, null and undefined are all values to remember, not stand-ins for "nothing".You'll keep two boxes — one holding the value you're tracking, one holding the last value that differed from it — and move them only on the renders where the value actually changed.
You're animating a tab switch. When the user goes from Inbox to Archive you slide the old panel out and the new one in, so you need to know which panel you came from. usePrevious looks like the answer, and for exactly one render it is. Then the component rerenders for a reason that has nothing to do with tabs — the parent rerendered, an unread count ticked, a useEffect set some unrelated state — and now usePrevious reports archive as the previous tab. It isn't wrong: the previous render's tab really was archive. It's just useless. Your animation has nowhere to slide in from, and there is nothing left to compare against.
The two hooks answer different questions. usePrevious answers "what was this value one render ago?" usePreviousDistinct answers "what was this value before it last changed?" Those coincide only when every render changes the value — and React rerenders for all sorts of reasons that have nothing to do with your value, so they come apart almost immediately. The second question needs a memory of its own: a box that gets written only when the value moves, and that ignores every render in between.
You already solved usePrevious, so start from it: put the value in a ref, update the ref in an effect after the render commits, hand the ref back.
const { useRef, useEffect } = require('react');
function usePreviousDistinct(value, compare = Object.is) {
const ref = useRef(undefined);
useEffect(() => {
ref.current = value; // remember this render's value for next time
}, [value]);
return ref.current;
}
Track inbox, switch to archive, and this returns inbox — exactly right. Now let anything rerender the component without touching the tab. The effect from the render that did change the tab already wrote archive into the box, so this render reads archive back out: the tab you're already on. The memory of inbox is gone, and no comparison after the fact can recover it, because the value it used to hold has been overwritten by a render that had nothing to say. The ref is doing exactly what it was told — it remembers the last render. You need it to remember the last change. (Note that compare never gets called at all, which is a good hint that the shape is wrong.)
const { useRef } = require('react');
function usePreviousDistinct(value, compare = Object.is) {
// The last value that was actually different — the answer we hand back. It
// starts undefined because nothing has changed yet.
const prevRef = useRef(undefined);
// The value we are currently tracking. Seeding it with `value` is what makes
// the first render safe: `compare(value, value)` is true, so the block below
// is skipped and prevRef stays undefined. No first-mount flag needed.
const curRef = useRef(value);
// Runs on every render, but only does something when the value moved.
// `compare` returns true when the two are EQUAL, so `!compare(...)` reads as
// "these differ". On an unchanged render this block is skipped entirely —
// and that skip is the whole hook: it is what stops an idle rerender from
// overwriting the memory.
if (!compare(curRef.current, value)) {
prevRef.current = curRef.current; // the value we're leaving becomes "previous"
curRef.current = value; // and the new one becomes what we track
}
return prevRef.current;
}
module.exports = { usePreviousDistinct };
Two boxes instead of one is the change that matters. A single ref can only answer one question at a time, and this hook needs to hold two facts: what it is tracking now, and what it was tracking before that. Splitting them lets the comparison happen before anything is overwritten — which is the part the naive version can never do, because by the time it looks, the old value is already gone.
The other shift is when the write happens. usePrevious defers its write to an effect on purpose: the one-render lag is its answer. This hook can't. The render where tab becomes archive is exactly the render that needs to be told inbox — that's when you start the animation — so the shift has to happen during render, before the return. That means writing refs while rendering, which React's docs advise against; useLatest works through that trade-off in full and it applies here unchanged.
The default is Object.is, and it is a deliberate pick. React compares your useState updates and your dependency arrays with Object.is, so defaulting to it means the hook agrees with React about what the word "changed" means. Anything React would bail out on, this hook also treats as no change.
That agreement fixes two real cases that plain === gets wrong:
NaN === NaN; // false → === says a steady NaN changes on EVERY render
Object.is(NaN, NaN); // true → nothing moves, which is the truth
+0 === -0; // true → === misses a real +0 to -0 transition
Object.is(0, -0); // false → the box shifts, correctly
NaN is the interesting one. With ===, a value that sits at NaN forever looks like it changes on every single render, so the box shifts every time and the hook ends up reporting NaN as its own previous value. That quirk also explains a piece of the reference implementation: react-use, which this hook mirrors, defaults to === and carries an extra first-mount flag alongside the two refs. The flag is doing real work there — under ===, a first render holding NaN compares false against itself, so the shift would run on render 1 and the hook would hand back NaN instead of undefined. Object.is says true for any value compared with itself, so seeding curRef with value is guard enough on its own and the flag can go.
When reference equality isn't what you mean, pass your own comparator. It takes (previous, next) and returns true when they count as equal:
Take the tab from the diagram: inbox, inbox, archive, archive, spam.
inbox. useRef(undefined) builds prevRef with nothing in it; useRef('inbox') builds curRef holding inbox. compare('inbox', 'inbox') is true, so !compare(...) is false and the block is skipped. Returns prevRef.current, which is undefined — correct, nothing has changed yet.inbox. Neither useRef call builds anything; React hands back the same two boxes and ignores the arguments. compare('inbox', 'inbox') is true again, block skipped, returns undefined. usePrevious answers inbox here — the tab you're looking at.archive. compare('inbox', 'archive') is false, so the block runs: prevRef.current takes inbox, then curRef.current takes archive. Returns inbox, on the very render where the switch happened — which is when the animation needs it.archive. compare('archive', 'archive') is true, block skipped, prevRef untouched. Returns inbox. This is the render where the naive version falls over: its single ref was overwritten with archive after render 3 committed.spam. compare('archive', 'spam') is false, so the boxes shift again: prevRef takes archive, curRef takes spam. Returns archive.usePrevious and comparing afterwards. const prev = usePrevious(tab); const changed = prev !== tab; works until one idle rerender lands, at which point prev is tab, changed is false, and it stays false. Fix: the comparison has to happen before the box is overwritten, which is what this hook is for.compare as "did it change". It returns true when the values are equal. Hand it (a, b) => a !== b and you invert the hook: it shifts on every unchanged render and freezes on every real change. Fix: name your comparator something like isEqual at the call site so the polarity is visible.useRef(value) for prevRef makes the first render report the current tab as its own previous tab. Only curRef gets seeded — prevRef starts undefined so that "nothing has changed yet" has an honest answer.undefined is invisible. undefined means both "nothing has changed yet" and "the value before the change was literally undefined". A component whose tab goes from undefined to inbox gets undefined back and cannot tell which happened. Fix: return a pair, as in Going further.(a, b) => a.id === b.id, a rebuilt object never gets adopted into curRef — the comparator said it was the same thing. So after {id: 1, label: 'Inbox'} and then {id: 1, label: 'Inbox (2)'}, what you get back later is the first one. That's usually what you want (you declared them equal), but it surprises people who expected the freshest copy.undefined apart from no value. Return { previous, hasPrevious }, or start prevRef at a module-private Symbol() and map it to undefined on the way out. Either kills the ambiguity in the last gotcha, at the cost of a clumsier call site — which is why the common version doesn't bother.{ page, filters } from props), reference equality reports a change every time. Pass a deep-equal comparator — dequal, lodash isEqual, or a hand-rolled shallow one for flat objects — and the hook only shifts on real content changes. It runs on every render, so keep the value small.useEffect whose dependency comparison you control. The two refs and the conditional shift don't change; only what you do at the moment of the shift does.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.