Skip to content

🧊 feat(hooks): add useCustomCompareEffect - #508

Open
ashenoooone wants to merge 1 commit into
siberiacancode:mainfrom
ashenoooone:feature/use-custom-compare-effect
Open

🧊 feat(hooks): add useCustomCompareEffect#508
ashenoooone wants to merge 1 commit into
siberiacancode:mainfrom
ashenoooone:feature/use-custom-compare-effect

Conversation

@ashenoooone

Copy link
Copy Markdown
Contributor

Fixes #507

useCustomCompareEffect(effect, deps, comparator) runs the effect when the comparator says the dependencies changed, instead of relying on React's reference check.

useCustomCompareEffect(() => subscribe(user), [user], ([user], [prev]) => user.id === prev.id);

The comparator returns true when the dependencies are equal and the effect is skipped. It is read fresh on every render, so it does not belong in deps. On mount the effect always runs and the comparator is not called at all, which means it never has to handle an undefined previous value. useShallowEffect and useDeepEffect are one-line wrappers over it, so all the dependency bookkeeping lives in one place.

const useShallowEffect = (effect, deps) => useCustomCompareEffect(effect, deps, shallowEqualDeps);
const useDeepEffect = (effect, deps) => useCustomCompareEffect(effect, deps, deepEqual);

Calling any of them without dependencies keeps the useEffect(fn) meaning and runs the effect on every render, matching what useShallowEffect did before.

Changes are tracked with a counter. The comparison runs during render, before useEffect reads its dependencies, and every time the comparator reports a change the counter grows.

if (!deps || !depsRef.current || !comparator(deps, depsRef.current)) signalRef.current += 1;
depsRef.current = deps;

useEffect(effect, [signalRef.current]);

What useEffect receives is [signalRef.current], never the caller's array. That gives two things. The array is always length 1, so a dependency list that changes length between renders never triggers React's The final argument passed to useEffect changed size between renders warning. And React's own element-wise Object.is cannot overrule the comparator, since a growing counter always reads as changed:

// re-runs on every render, exactly as asked, even though the dependency is the same object
useCustomCompareEffect(fn, [stableUser], () => false);

deepEqual moved out of useShallowEffect.ts into packages/core/src/helpers/deepEqual, next to a new shallowEqual, both with tests and demo pages. The move fixes what the old implementation got wrong: NaN was not equal to itself, Date and RegExp were walked as plain objects so any two dates compared equal, Set, Map and symbol keys were unhandled, a cyclic dependency overflowed the stack, and keysB.includes(key) inside the key loop made it quadratic on wide objects.

Two comparison rules are deliberate and documented in the JSDoc. Set members and Map keys compare by reference while Map values compare deeply, which matches the semantics of Set and Map themselves. The second rule: an object with no own enumerable string keys that is not a plain object compares by reference.

deepEqual(new Error('a'), new Error('b')); // false, compared by name and message
deepEqual(new ArrayBuffer(8), new ArrayBuffer(4)); // false, compared by reference

Without that rule Error, File, URL, ArrayBuffer and DOM nodes all come out equal, because their state is either non-enumerable or in internal slots, and useDeepEffect(fn, [ref.current]) would never fire again. Reporting them unequal is the safe direction, since an extra run is visible and a missing one is not.

Two breaking changes worth calling out. useShallowEffect compared dependencies recursively despite its name and now compares one level below each dependency.

// used to skip re-runs, now re-runs on every render
useShallowEffect(fn, [{ filter: { q: 'a' } }]);

Callers who want the old behavior move to useDeepEffect. And the exported deepEqual keeps its name with different semantics, as described above. Both need a line in the release notes, otherwise they look like a regression appearing with no change on the caller's side.

55 tests cover this, including the case where shallow and deep disagree. The demos use a nested object for the same reason: the existing useShallowEffect demo uses a flat one, which behaves identically under either comparator and therefore shows nothing.

@ashenoooone ashenoooone changed the title feature/use-custom-compare-effect 🧊 feat(hooks): добавь useCustomComp… 🧊 feat(hooks): add useCustomCompareEffect Sep 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[hook]: Create useCustomCompareEffect hook

1 participant