useDeepCompareEffect is a useEffect that re-runs its effect only when the dependencies change by value — a deep comparison — instead of by reference. React's own useEffect compares each dependency with Object.is, which is exactly right for a string or number but a trap for an object or array: a dependency built fresh each render ([{ query, page }]) is a new reference every time, so the effect fires on every render — a fetch per keystroke elsewhere in the component, forever. This hook compares the dependency's contents, so the effect runs only when something actually changed.
function useDeepCompareEffect(
effect: () => void | (() => void),
deps: unknown[],
): void;
// Same call shape as useEffect. Runs `effect` after mount, then again only when
// a DEEP comparison of `deps` against the previous `deps` finds a difference.
// `filters` is a config object you don't control the shape of. A plain
// useEffect refetches on every render; this refetches only when its value moves.
useDeepCompareEffect(() => {
fetchResults(filters);
}, [filters]);
// An inline object literal — a fresh reference each render — with a cleanup.
useDeepCompareEffect(() => {
const socket = connect({ room, token });
return () => socket.close();
}, [{ room, token }]); // reconnects only when room or token actually change
useEffect — the effect may return a cleanup function, and deps is the array. Nothing about the call site changes.deepEqual is provided — a complete deep-equality function is handed to you in the starter (it is the Deep Equal question, finished). Wire it in; don't re-implement it.deps straight to useEffect — that is the naive version, and it re-runs every render for an object dependency. Building the mechanism that fixes it is the whole exercise.useEffect already does the right thing and is cheaper.You will wrap useEffect so it re-runs on a deep change of deps instead of a reference change — by comparing the deps by value and feeding the real useEffect a single primitive that only moves when the value truly changed.
React's useEffect decides "did the dependencies change?" by running Object.is on each entry. For a string or a number that is exactly right. For an object or an array it is a trap. Write useEffect(() => fetch(query), [{ q, page }]) and the dependency is a brand-new object literal on every render — Object.is(previous, next) is always false, so the effect fires on every render. Someone types in an unrelated input, the component re-renders, and you fire another fetch. useDeepCompareEffect fixes this by asking a different question: did the contents of the deps change?
The dependency you pass is the same value every render but a different reference every render. Object.is only sees the reference, so it reports "changed" forever. A deep comparison looks past the reference at the contents, sees they match, and reports "unchanged." So the whole hook is: keep the previous deps, deep-compare the new deps against them, and only let the effect run when that comparison finds a real difference.
The signature hands you deps, so the obvious move is to pass it straight to useEffect:
function useDeepCompareEffect(effect, deps) {
// looks right — deps in, deps out
useEffect(effect, deps);
}
For primitive deps this is genuinely correct, which is what makes it tempting. But it is exactly a plain useEffect, so it inherits the trap. Pass [{ q, page }] and every render builds a new object; useEffect compares it with Object.is, sees a new reference, and re-runs the effect every single render — the fetch-per-keystroke bug the hook is supposed to prevent. The deps have to be compared by value, not handed to React as-is.
You cannot make useEffect deep-compare — its comparison is Object.is and you do not get to change it. So the trick is to give useEffect a dependency it can compare meaningfully: a single number that you bump yourself, only when your own deep comparison finds a change.
const { useEffect, useRef } = require('react');
// deepEqual(a, b) is PROVIDED complete at the bottom of this file (it is the
// Deep Equal question, handed to you finished). Wire it in; don't re-implement it.
function isPrimitive(value) {
return value === null || (typeof value !== 'object' && typeof value !== 'function');
}
function useDeepCompareEffect(effect, deps) {
if (process.env.NODE_ENV !== 'production') {
if (Array.isArray(deps) && deps.length > 0 && deps.every(isPrimitive)) {
console.warn(
'useDeepCompareEffect got a dependency list of all primitives. A plain ' +
'useEffect already compares those correctly — you do not need this hook.',
);
}
}
// The deps we last ran with, and a primitive counter that only moves when a
// deep comparison finds a real change. Writing a ref during render is the
// accepted memoization pattern here: it is deterministic given `deps`.
const depsRef = useRef(deps);
const signalRef = useRef(0);
if (!deepEqual(deps, depsRef.current)) {
depsRef.current = deps; // remember the new value...
signalRef.current += 1; // ...and bump the signal React can actually compare
}
// The real dependency is ONE number. React runs Object.is on it and re-runs
// the effect only when the number moved — i.e. only on a real deep change.
useEffect(effect, [signalRef.current]);
}
module.exports = { useDeepCompareEffect };
// ── PROVIDED: deepEqual — do not edit ────────────────────────────────────────
// The Deep Equal question, complete. SameValueZero primitives, symmetric type
// tags for arrays/Dates, key-order-independent objects, cycle-safe via a WeakMap.
// See /questions/deep-equal for how it is built.
function deepEqual(a, b, seen = new WeakMap()) {
if (Object.is(a, b)) return true;
if (a === 0 && b === 0) return true;
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) {
return false;
}
if (Array.isArray(a) !== Array.isArray(b)) return false;
if (a instanceof Date && b instanceof Date) return +a === +b;
if (a instanceof Date || b instanceof Date) return false;
if (seen.get(a) === b) return true;
seen.set(a, b);
if (Array.isArray(a)) {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (!deepEqual(a[i], b[i], seen)) return false;
}
return true;
}
const aKeys = Object.keys(a);
const bKeys = Object.keys(b);
if (aKeys.length !== bKeys.length) return false;
for (const k of aKeys) {
if (!Object.prototype.hasOwnProperty.call(b, k)) return false;
if (!deepEqual(a[k], b[k], seen)) return false;
}
return true;
}
Three ideas do the work. First, deepEqual is not your job — it is the Deep Equal question, provided complete, so this exercise is single-axis: the effect plumbing. Second, depsRef holds the last deps and signalRef holds a counter; on each render you deep-compare, and only when the contents differ do you store the new deps and bump the counter. Third, the effect depends on [signalRef.current] — a primitive. That matters because useEffect deps are only meaningful to Object.is, and Object.is is only meaningful on things it can compare by value: a number. A fresh object never satisfies Object.is; a number that changes only on a real difference always does.
Mount with deps = [{ q: 'a' }], then re-render twice with a fresh { q: 'a' }, then once with { q: 'b' }:
depsRef.current is seeded to the first deps, so deepEqual(deps, deps) is trivially true; no bump. The counter is 0, and the effect runs (a first effect always runs). Effect calls: 1.{ q: 'a' } — a different reference, same value. deepEqual walks both and returns true, so the counter stays 0. The effect's dep [0] is unchanged under Object.is, so it does not run. Effect calls: still 1.{ q: 'a' } — same story. Counter 0, no run. Effect calls: still 1.{ q: 'b' } — deepEqual finds q differs, so depsRef.current becomes the new deps and the counter goes 0 → 1. The effect's dep is now [1], which Object.is reports as changed, so React runs the cleanup from the last effect and then the effect. Effect calls: 2.Two of the four renders passed a deeply-equal object and cost nothing; only the one real change re-ran the effect.
Be honest about the trade, because this hook is reached for far too often.
Usually you do not need it. If you own the dependency, destructure it into primitives ([q, page]) or wrap the object in useMemo. That is what React wants and it is cheaper. The hook earns its place only when the dependency is an object whose shape you do not control — a config prop, a parsed query string, an options bag — and cannot cleanly reduce to primitives. And it is not free: it runs a full deep comparison on every render, the opposite trade from the re-render it saves. For a small object that is nothing; for a big, deep one it is real work you are doing every render to avoid work you might have done rarely.
Two libraries ship this hook, and they made different calls — both verified from source.
react-use's useDeepCompareEffect compares with fast-deep-equal/react and stores the deps in a ref, then passes that stored array straight to useEffect (useEffect(effect, ref.current)) — no counter. Because the stored reference only changes on a real difference, React's per-entry Object.is sees no change across equal renders. It warns (never throws) when the deps are missing or all primitive. One rough edge: when the deps array length changes, passing the stored array trips React's own "final argument changed size between renders" warning. The single-number signal here sidesteps that — its length is always one.
use-deep-compare-effect (kentcdodds) compares with dequal and does use a counter (signalRef), returning useMemo(() => ref.current, [signalRef.current]) — the same idea as here. The sharp difference is its check throws on empty or all-primitive deps rather than warning, so a misuse crashes your dev build; it ships a separate useDeepCompareEffectNoCheck as the escape hatch. Throwing is more aggressive than most teams want from a hook; warning nudges without breaking the app, so that is the choice here.
deps straight to useEffect. That is the naive version — it re-runs every render for an object dep. You must compare by value and drive the effect with a primitive signal.deepEqual returns false and the effect re-runs every render — correctly. The hook stops spurious re-runs; it cannot stop real ones.useDeepCompareEffect(fn, [count, name]) does exactly what a plain useEffect does, but pays for a deep comparison to get there. That is the smell the warning flags.deps — like any effect, do not read values you left out of the dependency array.useCustomCompareEffect(effect, deps, isEqual) takes the comparison as an argument — swap deep equality for a shallow compare, a field-subset compare, or an id-only compare. Same signal machinery, different equality.useDeepCompareMemo and useDeepCompareCallback. The same "memoize on a deep-equal signal" trick generalizes to useMemo and useCallback — anything that keys on a dependency array.useMemo-ing the object is usually the right answer. Learn this hook to understand the trade — then reach for it only when you cannot reshape the dependency.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
useDeepCompareEffect is a useEffect that re-runs its effect only when the dependencies change by value — a deep comparison — instead of by reference. React's own useEffect compares each dependency with Object.is, which is exactly right for a string or number but a trap for an object or array: a dependency built fresh each render ([{ query, page }]) is a new reference every time, so the effect fires on every render — a fetch per keystroke elsewhere in the component, forever. This hook compares the dependency's contents, so the effect runs only when something actually changed.
function useDeepCompareEffect(
effect: () => void | (() => void),
deps: unknown[],
): void;
// Same call shape as useEffect. Runs `effect` after mount, then again only when
// a DEEP comparison of `deps` against the previous `deps` finds a difference.
// `filters` is a config object you don't control the shape of. A plain
// useEffect refetches on every render; this refetches only when its value moves.
useDeepCompareEffect(() => {
fetchResults(filters);
}, [filters]);
// An inline object literal — a fresh reference each render — with a cleanup.
useDeepCompareEffect(() => {
const socket = connect({ room, token });
return () => socket.close();
}, [{ room, token }]); // reconnects only when room or token actually change
useEffect — the effect may return a cleanup function, and deps is the array. Nothing about the call site changes.deepEqual is provided — a complete deep-equality function is handed to you in the starter (it is the Deep Equal question, finished). Wire it in; don't re-implement it.deps straight to useEffect — that is the naive version, and it re-runs every render for an object dependency. Building the mechanism that fixes it is the whole exercise.useEffect already does the right thing and is cheaper.You will wrap useEffect so it re-runs on a deep change of deps instead of a reference change — by comparing the deps by value and feeding the real useEffect a single primitive that only moves when the value truly changed.
React's useEffect decides "did the dependencies change?" by running Object.is on each entry. For a string or a number that is exactly right. For an object or an array it is a trap. Write useEffect(() => fetch(query), [{ q, page }]) and the dependency is a brand-new object literal on every render — Object.is(previous, next) is always false, so the effect fires on every render. Someone types in an unrelated input, the component re-renders, and you fire another fetch. useDeepCompareEffect fixes this by asking a different question: did the contents of the deps change?
The dependency you pass is the same value every render but a different reference every render. Object.is only sees the reference, so it reports "changed" forever. A deep comparison looks past the reference at the contents, sees they match, and reports "unchanged." So the whole hook is: keep the previous deps, deep-compare the new deps against them, and only let the effect run when that comparison finds a real difference.
The signature hands you deps, so the obvious move is to pass it straight to useEffect:
function useDeepCompareEffect(effect, deps) {
// looks right — deps in, deps out
useEffect(effect, deps);
}
For primitive deps this is genuinely correct, which is what makes it tempting. But it is exactly a plain useEffect, so it inherits the trap. Pass [{ q, page }] and every render builds a new object; useEffect compares it with Object.is, sees a new reference, and re-runs the effect every single render — the fetch-per-keystroke bug the hook is supposed to prevent. The deps have to be compared by value, not handed to React as-is.
You cannot make useEffect deep-compare — its comparison is Object.is and you do not get to change it. So the trick is to give useEffect a dependency it can compare meaningfully: a single number that you bump yourself, only when your own deep comparison finds a change.
const { useEffect, useRef } = require('react');
// deepEqual(a, b) is PROVIDED complete at the bottom of this file (it is the
// Deep Equal question, handed to you finished). Wire it in; don't re-implement it.
function isPrimitive(value) {
return value === null || (typeof value !== 'object' && typeof value !== 'function');
}
function useDeepCompareEffect(effect, deps) {
if (process.env.NODE_ENV !== 'production') {
if (Array.isArray(deps) && deps.length > 0 && deps.every(isPrimitive)) {
console.warn(
'useDeepCompareEffect got a dependency list of all primitives. A plain ' +
'useEffect already compares those correctly — you do not need this hook.',
);
}
}
// The deps we last ran with, and a primitive counter that only moves when a
// deep comparison finds a real change. Writing a ref during render is the
// accepted memoization pattern here: it is deterministic given `deps`.
const depsRef = useRef(deps);
const signalRef = useRef(0);
if (!deepEqual(deps, depsRef.current)) {
depsRef.current = deps; // remember the new value...
signalRef.current += 1; // ...and bump the signal React can actually compare
}
// The real dependency is ONE number. React runs Object.is on it and re-runs
// the effect only when the number moved — i.e. only on a real deep change.
useEffect(effect, [signalRef.current]);
}
module.exports = { useDeepCompareEffect };
// ── PROVIDED: deepEqual — do not edit ────────────────────────────────────────
// The Deep Equal question, complete. SameValueZero primitives, symmetric type
// tags for arrays/Dates, key-order-independent objects, cycle-safe via a WeakMap.
// See /questions/deep-equal for how it is built.
function deepEqual(a, b, seen = new WeakMap()) {
if (Object.is(a, b)) return true;
if (a === 0 && b === 0) return true;
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) {
return false;
}
if (Array.isArray(a) !== Array.isArray(b)) return false;
if (a instanceof Date && b instanceof Date) return +a === +b;
if (a instanceof Date || b instanceof Date) return false;
if (seen.get(a) === b) return true;
seen.set(a, b);
if (Array.isArray(a)) {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (!deepEqual(a[i], b[i], seen)) return false;
}
return true;
}
const aKeys = Object.keys(a);
const bKeys = Object.keys(b);
if (aKeys.length !== bKeys.length) return false;
for (const k of aKeys) {
if (!Object.prototype.hasOwnProperty.call(b, k)) return false;
if (!deepEqual(a[k], b[k], seen)) return false;
}
return true;
}
Three ideas do the work. First, deepEqual is not your job — it is the Deep Equal question, provided complete, so this exercise is single-axis: the effect plumbing. Second, depsRef holds the last deps and signalRef holds a counter; on each render you deep-compare, and only when the contents differ do you store the new deps and bump the counter. Third, the effect depends on [signalRef.current] — a primitive. That matters because useEffect deps are only meaningful to Object.is, and Object.is is only meaningful on things it can compare by value: a number. A fresh object never satisfies Object.is; a number that changes only on a real difference always does.
Mount with deps = [{ q: 'a' }], then re-render twice with a fresh { q: 'a' }, then once with { q: 'b' }:
depsRef.current is seeded to the first deps, so deepEqual(deps, deps) is trivially true; no bump. The counter is 0, and the effect runs (a first effect always runs). Effect calls: 1.{ q: 'a' } — a different reference, same value. deepEqual walks both and returns true, so the counter stays 0. The effect's dep [0] is unchanged under Object.is, so it does not run. Effect calls: still 1.{ q: 'a' } — same story. Counter 0, no run. Effect calls: still 1.{ q: 'b' } — deepEqual finds q differs, so depsRef.current becomes the new deps and the counter goes 0 → 1. The effect's dep is now [1], which Object.is reports as changed, so React runs the cleanup from the last effect and then the effect. Effect calls: 2.Two of the four renders passed a deeply-equal object and cost nothing; only the one real change re-ran the effect.
Be honest about the trade, because this hook is reached for far too often.
Usually you do not need it. If you own the dependency, destructure it into primitives ([q, page]) or wrap the object in useMemo. That is what React wants and it is cheaper. The hook earns its place only when the dependency is an object whose shape you do not control — a config prop, a parsed query string, an options bag — and cannot cleanly reduce to primitives. And it is not free: it runs a full deep comparison on every render, the opposite trade from the re-render it saves. For a small object that is nothing; for a big, deep one it is real work you are doing every render to avoid work you might have done rarely.
Two libraries ship this hook, and they made different calls — both verified from source.
react-use's useDeepCompareEffect compares with fast-deep-equal/react and stores the deps in a ref, then passes that stored array straight to useEffect (useEffect(effect, ref.current)) — no counter. Because the stored reference only changes on a real difference, React's per-entry Object.is sees no change across equal renders. It warns (never throws) when the deps are missing or all primitive. One rough edge: when the deps array length changes, passing the stored array trips React's own "final argument changed size between renders" warning. The single-number signal here sidesteps that — its length is always one.
use-deep-compare-effect (kentcdodds) compares with dequal and does use a counter (signalRef), returning useMemo(() => ref.current, [signalRef.current]) — the same idea as here. The sharp difference is its check throws on empty or all-primitive deps rather than warning, so a misuse crashes your dev build; it ships a separate useDeepCompareEffectNoCheck as the escape hatch. Throwing is more aggressive than most teams want from a hook; warning nudges without breaking the app, so that is the choice here.
deps straight to useEffect. That is the naive version — it re-runs every render for an object dep. You must compare by value and drive the effect with a primitive signal.deepEqual returns false and the effect re-runs every render — correctly. The hook stops spurious re-runs; it cannot stop real ones.useDeepCompareEffect(fn, [count, name]) does exactly what a plain useEffect does, but pays for a deep comparison to get there. That is the smell the warning flags.deps — like any effect, do not read values you left out of the dependency array.useCustomCompareEffect(effect, deps, isEqual) takes the comparison as an argument — swap deep equality for a shallow compare, a field-subset compare, or an id-only compare. Same signal machinery, different equality.useDeepCompareMemo and useDeepCompareCallback. The same "memoize on a deep-equal signal" trick generalizes to useMemo and useCallback — anything that keys on a dependency array.useMemo-ing the object is usually the right answer. Learn this hook to understand the trade — then reach for it only when you cannot reshape the dependency.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.