useTrackedEffect is a useEffect that also tells its effect which dependencies changed since the last run. A plain useEffect re-runs when any dependency changes but never says which one, so when an effect fires more often than you expected you are left adding console.logs to find the culprit. This hook hands the effect the indices of the deps that actually moved, making it a debugging tool for "why did this effect just run?" — the runtime answer to a question you would otherwise chase by hand.
function useTrackedEffect(
effect: (
changes: number[], // indices of deps that changed since last run
previousDeps: unknown[] | undefined, // last run's deps (undefined on first run)
currentDeps: unknown[], // this run's deps
) => void | (() => void), // may return a cleanup, like useEffect
deps: unknown[],
): void;
// Log which dependency triggered a refetch.
useTrackedEffect(
(changes) => {
console.log('effect ran; changed indices:', changes); // e.g. [1]
fetchResults(userId, filters, sort, page);
},
[userId, filters, sort, page],
);
// On the first run there is no previous list, so `changes` is every index
// and `previousDeps` is undefined.
useTrackedEffect((changes, previousDeps) => {
if (previousDeps === undefined) console.log('mount:', changes); // [0, 1]
}, [a, b]);
useEffect — the effect fires on exactly the same schedule (any dep changed by Object.is); you only add the changed-indices report on top. You are augmenting the effect, not changing when it runs.changes is indices, not values — an array of the positions in deps that differ from the previous run, in ascending order. Read them against your own deps array to name them.changes equal to every index and previousDeps equal to undefined; there is no previous list to diff against.useEffect.Object.is marks it changed every time; that is the same rule useEffect follows, not a quirk of this hook.useTrackedEffect — keep it, and keep the effect(changes, previousDeps, currentDeps) argument order.You will wrap useEffect so the effect it runs is handed the list of dependency indices that changed since last time — turning "why did this fire?" from a guess into data.
useEffect runs your effect whenever any dependency changes, but it never tells you which one changed. On a component with several deps — [userId, filters, sort, page] — that silence is a debugging slog. The effect re-fires more often than you expected, you suspect filters is being rebuilt each render, but to confirm it you drop a console.log above the effect, reload, squint at the output, and delete it again. useTrackedEffect closes that gap: it hands the effect the exact indices that changed, so "the effect re-ran because filters changed" is a value you can read, not a hunch you have to prove.
Here is the quiet irony: useEffect already knows which dependency changed — comparing the deps is how it decides to fire at all — it just throws that knowledge away after deciding. useTrackedEffect keeps a copy. You remember last render's dependency array in a ref, and on each run you compare it against this render's array position by position. The positions that differ are your changes. You are not changing when the effect fires; you are riding alongside React's own decision and reporting what it saw.
You know the effect should be told what changed, and you know the shape: effect(changes, previousDeps, currentDeps). Working out the diff feels fiddly, so you punt on it and report everything as changed:
const { useEffect, useRef } = require('react');
function useTrackedEffect(effect, deps) {
const previousDepsRef = useRef(undefined);
useEffect(() => {
const previousDeps = previousDepsRef.current;
previousDepsRef.current = deps;
const changes = deps.map((_, index) => index); // "just say everything changed"
return effect(changes, previousDeps, deps);
}, deps);
}
On the very first run this is accidentally correct — on mount everything genuinely is new, so "every index changed" is the right answer. That is what makes it tempting. But on every update it lies: change only filters and it still reports [0, 1, 2, 3], which is precisely the "which one?" question the hook was supposed to answer, left unanswered. It fires at exactly the right moments; it just cannot tell you why.
You cannot ask useEffect which dependency changed — it does not expose that. So you keep your own memory. A ref holds the previous deps; each run you diff the previous list against the current one with Object.is — the same per-entry rule React itself uses — collect the differing indices, and only then store the current list for next time.
const { useEffect, useRef } = require('react');
// Which indices differ between the previous deps and the current ones? Compare
// each entry with Object.is — the exact rule React's own useEffect uses.
function diffDeps(previous, current) {
// First run: there is no previous list, so every current index counts as
// changed (a plain effect treats everything as new on mount).
if (!previous) {
return current ? current.map((_, index) => index) : [];
}
return current
.map((_, index) => (Object.is(previous[index], current[index]) ? -1 : index))
.filter((index) => index >= 0);
}
function useTrackedEffect(effect, deps) {
// A box that survives across renders, holding the deps from the LAST run.
// It starts undefined: before the first run there is no previous list.
const previousDepsRef = useRef(undefined);
useEffect(() => {
// Read the ref BEFORE overwriting it. Both `changes` and `previousDeps` need
// the OLD list; store `deps` back too early and you compare it to itself.
const previousDeps = previousDepsRef.current;
const changes = diffDeps(previousDeps, deps);
previousDepsRef.current = deps;
// Hand the effect what a plain useEffect never tells it: which indices moved,
// plus the old and new lists. Forward the return value so cleanup still works.
return effect(changes, previousDeps, deps);
}, deps);
}
module.exports = { useTrackedEffect };
Three ideas carry it. First, the diff is nothing exotic — it is Object.is applied at each position, keeping the indices that come back different; a fresh object at index 1 is "different" for the same reason React would re-fire on it. Second, the effect is still driven by deps in that second argument to useEffect, so React schedules and cleans it up exactly as it would a plain effect — you are augmenting useEffect, not replacing its trigger. Third, the ordering: you read previousDepsRef.current for both the diff and previousDeps, and only after both reads do you write the new list back.
Mount with deps = [1, 'all', 'asc', 1] for [userId, filters, sort, page], then re-render with filters changed to 'unread', then again with page changed to 2:
previousDepsRef.current is undefined, so diffDeps takes the first-run branch and returns every index: changes = [0, 1, 2, 3]. The effect runs with previousDeps undefined, then you store [1, 'all', 'asc', 1] into the ref.diffDeps compares the stored [1, 'all', 'asc', 1] against [1, 'unread', 'asc', 1]: only position 1 differs, so changes = [1]. The effect is told exactly that, and the ref is updated to the new list.diffDeps compares [1, 'unread', 'asc', 1] against [1, 'unread', 'asc', 2]: only position 3 differs, so changes = [3]. Note it compares against the previous run, not the original mount — filters is not reported again because it did not move this time.Each run answers "which dep fired me?" with the indices that actually moved since the run before it.
The whole solution turns on one line's position. If you store deps back into the ref before you diff, you end up comparing the current deps against themselves — they are identical, so changes comes back empty on every update. The nasty part is how quiet the bug is: the effect still fires at the right times (its trigger is deps, untouched), so the hook looks like it works. It just reports that nothing ever changed.
previousDepsRef.current = deps before the diff compares deps to itself, so changes is always empty. Read for the diff first, store last.[{ q }] is a new reference each render, so Object.is says it moved every time and the effect re-fires every render — the same behavior a plain useEffect has. That is honest, not a bug; if you do not want it, memoize the object before passing it.useEffect — it only annotates the run with which indices changed. If you wanted to gate re-runs on the deps' value instead, that is a different hook (see Going further).filters is the culprit so you can go stabilize filters.ahooks ships this hook, and the shape here matches it — verified from source. Its callback is effect(changes, previousDeps, currentDeps); it computes changes inside the effect body against a previousDepsRef, captures the previous deps, then overwrites the ref, and returns effect(...) so cleanup flows through. On the first run previousDeps is undefined and changes is every index, exactly as above. One detail worth reading in its source: the diff assumes both dependency lists have the same length (its comment says so) and walks the current list's indices — a dependency array that changes length between renders is already a React anti-pattern, so neither this nor React's own useEffect is built to handle it.
usePrevious on the deps array. The previous-deps ref is exactly the usePrevious primitive — remember last render's value in a ref you read during the run and write after it — applied to the whole dependency list.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
useTrackedEffect is a useEffect that also tells its effect which dependencies changed since the last run. A plain useEffect re-runs when any dependency changes but never says which one, so when an effect fires more often than you expected you are left adding console.logs to find the culprit. This hook hands the effect the indices of the deps that actually moved, making it a debugging tool for "why did this effect just run?" — the runtime answer to a question you would otherwise chase by hand.
function useTrackedEffect(
effect: (
changes: number[], // indices of deps that changed since last run
previousDeps: unknown[] | undefined, // last run's deps (undefined on first run)
currentDeps: unknown[], // this run's deps
) => void | (() => void), // may return a cleanup, like useEffect
deps: unknown[],
): void;
// Log which dependency triggered a refetch.
useTrackedEffect(
(changes) => {
console.log('effect ran; changed indices:', changes); // e.g. [1]
fetchResults(userId, filters, sort, page);
},
[userId, filters, sort, page],
);
// On the first run there is no previous list, so `changes` is every index
// and `previousDeps` is undefined.
useTrackedEffect((changes, previousDeps) => {
if (previousDeps === undefined) console.log('mount:', changes); // [0, 1]
}, [a, b]);
useEffect — the effect fires on exactly the same schedule (any dep changed by Object.is); you only add the changed-indices report on top. You are augmenting the effect, not changing when it runs.changes is indices, not values — an array of the positions in deps that differ from the previous run, in ascending order. Read them against your own deps array to name them.changes equal to every index and previousDeps equal to undefined; there is no previous list to diff against.useEffect.Object.is marks it changed every time; that is the same rule useEffect follows, not a quirk of this hook.useTrackedEffect — keep it, and keep the effect(changes, previousDeps, currentDeps) argument order.You will wrap useEffect so the effect it runs is handed the list of dependency indices that changed since last time — turning "why did this fire?" from a guess into data.
useEffect runs your effect whenever any dependency changes, but it never tells you which one changed. On a component with several deps — [userId, filters, sort, page] — that silence is a debugging slog. The effect re-fires more often than you expected, you suspect filters is being rebuilt each render, but to confirm it you drop a console.log above the effect, reload, squint at the output, and delete it again. useTrackedEffect closes that gap: it hands the effect the exact indices that changed, so "the effect re-ran because filters changed" is a value you can read, not a hunch you have to prove.
Here is the quiet irony: useEffect already knows which dependency changed — comparing the deps is how it decides to fire at all — it just throws that knowledge away after deciding. useTrackedEffect keeps a copy. You remember last render's dependency array in a ref, and on each run you compare it against this render's array position by position. The positions that differ are your changes. You are not changing when the effect fires; you are riding alongside React's own decision and reporting what it saw.
You know the effect should be told what changed, and you know the shape: effect(changes, previousDeps, currentDeps). Working out the diff feels fiddly, so you punt on it and report everything as changed:
const { useEffect, useRef } = require('react');
function useTrackedEffect(effect, deps) {
const previousDepsRef = useRef(undefined);
useEffect(() => {
const previousDeps = previousDepsRef.current;
previousDepsRef.current = deps;
const changes = deps.map((_, index) => index); // "just say everything changed"
return effect(changes, previousDeps, deps);
}, deps);
}
On the very first run this is accidentally correct — on mount everything genuinely is new, so "every index changed" is the right answer. That is what makes it tempting. But on every update it lies: change only filters and it still reports [0, 1, 2, 3], which is precisely the "which one?" question the hook was supposed to answer, left unanswered. It fires at exactly the right moments; it just cannot tell you why.
You cannot ask useEffect which dependency changed — it does not expose that. So you keep your own memory. A ref holds the previous deps; each run you diff the previous list against the current one with Object.is — the same per-entry rule React itself uses — collect the differing indices, and only then store the current list for next time.
const { useEffect, useRef } = require('react');
// Which indices differ between the previous deps and the current ones? Compare
// each entry with Object.is — the exact rule React's own useEffect uses.
function diffDeps(previous, current) {
// First run: there is no previous list, so every current index counts as
// changed (a plain effect treats everything as new on mount).
if (!previous) {
return current ? current.map((_, index) => index) : [];
}
return current
.map((_, index) => (Object.is(previous[index], current[index]) ? -1 : index))
.filter((index) => index >= 0);
}
function useTrackedEffect(effect, deps) {
// A box that survives across renders, holding the deps from the LAST run.
// It starts undefined: before the first run there is no previous list.
const previousDepsRef = useRef(undefined);
useEffect(() => {
// Read the ref BEFORE overwriting it. Both `changes` and `previousDeps` need
// the OLD list; store `deps` back too early and you compare it to itself.
const previousDeps = previousDepsRef.current;
const changes = diffDeps(previousDeps, deps);
previousDepsRef.current = deps;
// Hand the effect what a plain useEffect never tells it: which indices moved,
// plus the old and new lists. Forward the return value so cleanup still works.
return effect(changes, previousDeps, deps);
}, deps);
}
module.exports = { useTrackedEffect };
Three ideas carry it. First, the diff is nothing exotic — it is Object.is applied at each position, keeping the indices that come back different; a fresh object at index 1 is "different" for the same reason React would re-fire on it. Second, the effect is still driven by deps in that second argument to useEffect, so React schedules and cleans it up exactly as it would a plain effect — you are augmenting useEffect, not replacing its trigger. Third, the ordering: you read previousDepsRef.current for both the diff and previousDeps, and only after both reads do you write the new list back.
Mount with deps = [1, 'all', 'asc', 1] for [userId, filters, sort, page], then re-render with filters changed to 'unread', then again with page changed to 2:
previousDepsRef.current is undefined, so diffDeps takes the first-run branch and returns every index: changes = [0, 1, 2, 3]. The effect runs with previousDeps undefined, then you store [1, 'all', 'asc', 1] into the ref.diffDeps compares the stored [1, 'all', 'asc', 1] against [1, 'unread', 'asc', 1]: only position 1 differs, so changes = [1]. The effect is told exactly that, and the ref is updated to the new list.diffDeps compares [1, 'unread', 'asc', 1] against [1, 'unread', 'asc', 2]: only position 3 differs, so changes = [3]. Note it compares against the previous run, not the original mount — filters is not reported again because it did not move this time.Each run answers "which dep fired me?" with the indices that actually moved since the run before it.
The whole solution turns on one line's position. If you store deps back into the ref before you diff, you end up comparing the current deps against themselves — they are identical, so changes comes back empty on every update. The nasty part is how quiet the bug is: the effect still fires at the right times (its trigger is deps, untouched), so the hook looks like it works. It just reports that nothing ever changed.
previousDepsRef.current = deps before the diff compares deps to itself, so changes is always empty. Read for the diff first, store last.[{ q }] is a new reference each render, so Object.is says it moved every time and the effect re-fires every render — the same behavior a plain useEffect has. That is honest, not a bug; if you do not want it, memoize the object before passing it.useEffect — it only annotates the run with which indices changed. If you wanted to gate re-runs on the deps' value instead, that is a different hook (see Going further).filters is the culprit so you can go stabilize filters.ahooks ships this hook, and the shape here matches it — verified from source. Its callback is effect(changes, previousDeps, currentDeps); it computes changes inside the effect body against a previousDepsRef, captures the previous deps, then overwrites the ref, and returns effect(...) so cleanup flows through. On the first run previousDeps is undefined and changes is every index, exactly as above. One detail worth reading in its source: the diff assumes both dependency lists have the same length (its comment says so) and walks the current list's indices — a dependency array that changes length between renders is already a React anti-pattern, so neither this nor React's own useEffect is built to handle it.
usePrevious on the deps array. The previous-deps ref is exactly the usePrevious primitive — remember last render's value in a ref you read during the run and write after it — applied to the whole dependency list.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.