We help companies like yours with expert Next.js performance advice and auditing.

nextjs, react
11/08/2025 09:35

Optimizing React Hooks with Custom Dependency Comparison

Make hooks fire only when values truly change - tame useEffect/useMemo/useCallback with smarter dependency checks.

media

TL;DR – Hook reruns aren’t free. React compares dependency arrays by reference, so freshly created objects or functions make useEffect/useMemo/useCallback fire when nothing really changed. This post shows why that happens, surveys common manual work-arounds, and demonstrates how the use-custom-compare hooks (plus react-fast-compare) let you supply smarter equality checks so expensive logic runs only when values truly differ.

The Problem: Reference Equality in Hook Dependencies

React’s Hooks like useEffect, useMemo, and useCallback rely on Object.is to compare their dependency arrays. Object.is is pretty similar to the === operator with a few minor differences (you can read more on this here). If a dependency is an object, array, or function that gets recreated on every render, its reference will differ each time - even if its contents are the same. React will treat it as “changed” and re-run the hook callback unnecessarily.

For example, imagine a following component:

export const Button = ({onPress, analyticsProps}) => {
  const onButtonPressed = useCallback(() => {
    onPress();
    sendAnalytics(analyticsProps);
  }, [onPress, analyticsProps]);
  ...
};

export const Dashboard = () => {
  ...
  const [times, setTimes] = React.useState(0);
  const analyticsProps = {page: 'Dashboard', numberOfTimesButtonPressed: times};
  return (
  ...
    <Button
      onPress={() => {
        doSomething();
        setTimes(t => t + 1);
      }}
      analyticsProps={analyticsProps} />
  ...
  );
}

At first glance this seems fine, but under the hood it’s problematic. On every render, React creates a fresh analyticsProps object and the inline arrow function. Because useCallback compares dependencies by reference, that new object fails the equality check and React runs the effect again. In our example, onButtonPressed gets recreated on every render, even though the onPress and analyticsProps are identical.

Why Unnecessary Hook Runs Hurt Performance

For performance-focused engineers, these extra executions are important to avoid. Each unnecessary effect or memo recalculation consumes time and CPU, and can block the main thread if heavy. Multiple unwarranted re-renders or effect executions can make the UI sluggish. In React apps, we strive to minimize work on each render - so preventing repeating hook calls is an easy win.

Consider a heavy computation wrapped in useMemo:

const processedData = useMemo(() => heavyProcess(data), [data]);

If data is an object or array that is recreated on every render (perhaps derived or passed from a parent), then heavyProcess runs every time. The useMemo provides no benefit in that case, instead it adds overhead. The app spends cycles re-processing data even when logically nothing changed. We need strategies to make dependency checks smarter.

Common Workarounds for Stable Dependencies

How can we prevent these unnecessary hook re-runs caused by referential inequality? Developers have come up with a few approaches, each with pros and cons:

1. Restructure objects

The ideal solution is often to avoid putting a freshly created object in the dependency array at all. Instead, derive needed primitive values or use React state. Using our previous example:

export const Button = ({ onPress, page, times }) => {
  const onButtonPressed = React.useCallback(() => {
    onPress();
    sendAnalytics({ page, numberOfTimesButtonPressed: times });
  }, [onPress, page, times]);           // only primitives here

  return <button onClick={onButtonPressed}>Press me</button>;
};

export const Dashboard = () => {
  const [times, setTimes] = React.useState(0);

  // stable function - identity never changes
  const handlePress = React.useCallback(() => {
    doSomething();
    setTimes(t => t + 1);
  }, []);

  return (
    <Button
      onPress={handlePress}
      page='Dashboard'    // constant string which is stable
      times={times}       // primitive number which is stable
    />
  );
};

Which works fine, but if you want to send additional analytics fields you’ll need to update both the Button component’s props and all its usage!

2. Memoize objects

For example, taking the earlier example, one fix is to lift the analyticsProps object into useMemo so that it isn’t re-created each render.

export const Dashboard = () => {
  const [times, setTimes] = React.useState(0);

  // stabilise the press function
  const handlePress = React.useCallback(() => {
    doSomething();
    setTimes(t => t + 1);
  }, []);

  // object re-created only when `times` changes
  const analyticsProps = React.useMemo(
    () => ({ page: 'Dashboard', numberOfTimesButtonPressed: times }),
    [times]
  );

  return (
    <Button
      onPress={handlePress}             // stable function between renders
      analyticsProps={analyticsProps}   // stable reference between renders
    />
  );
};
// Button component stays exactly the same

This is a bit tedious but works great if you have full control over the props that you’re passing on. But imagine if you have data coming from let’s say GraphQL queries and you want to pass on parts of those to sub-components. Not so fun writing useMemo for all component’s various object dependencies!

3. Spread object values in dependencies

Instead of using the whole object as a single dependency, you can list its primitive properties. For example:

export const Button = ({ onPress, analyticsProps }) => {
  const { page, numberOfTimesButtonPressed } = analyticsProps;

  const onButtonPressed = React.useCallback(() => {
    onPress();
    sendAnalytics({ page, numberOfTimesButtonPressed });        // use the primitives
  }, [
    onPress,                       // function reference
    page,                          // string
    numberOfTimesButtonPressed     // number
  ]);                              // no object in deps means stable unless a field changes
};

export const Dashboard = () => {
  const [times, setTimes] = React.useState(0);

  // we need to stabilise the function regardless
  const handlePress = React.useCallback(() => {
    doSomething();
    setTimes(t => t + 1);
  }, []);

  const analyticsProps = {
    page: 'Dashboard',
    numberOfTimesButtonPressed: times
  };

  return (
    <Button
      onPress={handlePress}
      analyticsProps={analyticsProps}
    />
  );
};

This way, React will re-run the effect only when a filter value changes. However, this approach can be tedious (especially if an object has many keys or dynamic keys) and it can violate the React Hooks lint rule unless you disable it, depending on your exact use case.

4. JSON stringify hack

A trick sometimes mentioned in community threads is to serialize the object to a string and use that string as a dependency. For example:

export const Button = ({ onPress, analyticsProps }) => {
  // Serialise once per render
  const analyticsJson = JSON.stringify(analyticsProps);

  // It re-creates only when onPress or analyticsJson change.
  const onButtonPressed = useCallback(() => {
    onPress();
    const parsed = JSON.parse(analyticsJson);
    sendAnalytics(parsed);
  }, [onPress, analyticsJson]);
  // analyticsJson is a primitive now
};

export const Dashboard = () => {
  const [times, setTimes] = useState(0);

  // stable function
  const handlePress = useCallback(() => {
    doSomething();
    setTimes(t => t + 1);
  }, []);

  // fresh object on each render which is fine, we’ll stringify it in the child
  const analyticsProps = {
    page: 'Dashboard',
    numberOfTimesButtonPressed: times,
  };

  return (
	...
      <Button
        onPress={handlePress}
        analyticsProps={analyticsProps}
      />
     ...
  );
};

This converts deep equality into a simple string equality - if the analyticsProps object’s content hasn’t changed, the JSON string remains the same and the effect won’t rerun. This approach keeps the lint rule happy and ensures the effect runs only when the data truly changes. But there are downsides: stringifying is expensive for large objects, doesn’t work with functions inside objects, and you lose type information.

Each of these workarounds can mitigate unnecessary executions, but they have trade-offs in complexity, performance, or linting. All approaches have their pros and cons and it depends on the situation which one makes most sense.

Wouldn’t it be nice if there was a cleaner, reusable solution? This is where use-custom-compare comes in.

Meet use-custom-compare: Custom Comparison Hooks

use-custom-compare is a library that provides drop-in replacements for React’s core Hooks, allowing custom comparison logic for dependencies.

What hooks does it include? The package exposes useCustomCompareEffect, useCustomCompareLayoutEffect, useCustomCompareMemo, and useCustomCompareCallback—direct drop‑ins for React’s core hooks that accept a depsAreEqual comparator.

These hooks mirror the API of their React counterparts, with one extra parameter at the end: a function depsAreEqual(prevDeps, nextDeps) that returns true if the dependencies are considered equal.

react-fast-compare: A Faster Deep Equality Utility

react-fast-compare is a tiny (~2 kB minified) library purpose‑built for deep equality in React. Benchmarks show it outperforms lodash’s isEqual by roughly 4-12x on large, nested objects thanks to early bail‑outs, circular‑reference handling, and minimal allocations.

Side note: react-fast-compare safely handles circular references - it keeps an internal reference map - so you won’t hit a “Maximum call stack size exceeded” if your data is self-referential.

Using useCustomCompareCallback for Stable Effects

Let’s revisit the earlier example. Using useCustomCompareCallback, we can ensure the effect only runs when the filter object’s contents change, not its reference:

import React from 'react';
import { useCustomCompareCallback } from 'use-custom-compare';
import isEqual from 'react-fast-compare';

export function Button({ onPress, analyticsProps }) {
  const onButtonPressed = useCustomCompareCallback(
    () => {
      onPress();
      sendAnalytics(analyticsProps);
    },
    [onPress, analyticsProps],
    isEqual          // deep-compare prevDeps vs nextDeps
  );
}

In this code, useCustomCompareCallback takes three arguments: the effect callback, an array of dependencies, and our custom compare function. We pass [onPress, analyticsProps] as the dependency array (just like we would to useCustomCompareCallback), but then provide a comparator that deep-compares the prevDeps vs nextDeps. Here we use react-fast-compare to do a deep value comparison of the dependency arrays. Now, even if a new object is created on each render, the comparator will see that its value is the same as before and return true, indicating “no change.” As a result, React will not re-run the effect on those renders. The effect behaves as if we had used a stable reference, without needing to refactor our code or resort to stringifying objects.

This approach can prevent a lot of needless work. In our example, onButtonPressed will only be recreated when _analyticsProps_changes value, not on every render. For side-effects like network requests or logging, this is a significant performance improvement.

Important: If you have multiple dependencies, your comparator function should compare all of them. The prevDeps and nextDeps arguments are arrays (tuples) of dependency values. You can still use isEqual to deep compare two arrays of values, or implement custom logic (for example, compare only certain fields of an object if only those matter). The flexibility of use-custom-compare is that you decide what constitutes equality for your scenario.

One thing to be mindful of is the React Hooks ESLint rule. If you use useCustomCompareEffect, the default linter might not recognize it as a variant of useEffect. You should update your ESLint config to include these custom hooks in the exhaustive-deps rule. The library documentation suggests adding an additionalHooks regex, for example:

// in .eslintrc.js or package.json ESLint config
"rules": {
  "react-hooks/exhaustive-deps": ["warn", {
    "additionalHooks": "(useCustomCompareEffect|useCustomCompareLayoutEffect|useCustomCompareMemo|useCustomCompareCallback)"
  }]
}

This ensures the linter will check that you list all necessary dependencies inside your custom hooks just like it would for the built-in hooks. With this config, you’ll still get warnings if you forget to include a reactive value in the dependency array, which is critical for avoiding bugs.

Best Practices and Caveats

While use-custom-compare is a powerful tool, it should be used judiciously:

  • Use it only when needed. If your dependencies are primitives (strings, numbers, booleans) or include React state variables that update appropriately, you likely don’t need custom comparison - React’s default behavior is sufficient. In fact, the library explicitly warns not to use it for cases with no dependencies or only primitive dependencies. As a rule of thumb we mostly suggest using these when the dependencies are either functions or objects.
  • Understand the cost. A deep equality check is not free - it adds CPU time on each render to compare objects. Typically, if your effect or computation is very expensive, a fast deep compare is worth it. But if you find yourself comparing very large objects frequently, consider if there’s a way to reduce the size of what needs comparing (e.g. compare just IDs or timestamps instead of full objects). Always weigh the comparison cost vs the avoided work. The goal is a net win for performance.
  • Ensure your comparator is correct. A custom comparator function should reliably return true only when dependencies are truly equivalent in the context of what your hook does. A buggy comparator could skip an effect when it should have run, leading to stale data or missed updates. For example, if you accidentally ignore a field that matters, the effect might not run when that field changes. Test your comparator logic or use battle-tested deep equal functions for safety.
  • Lint your dependencies. As mentioned, update your ESLint config so that useCustomCompareEffect and friends are checked. This helps catch cases where you might forget to include a dependency in the array. Just because you can write a custom comparator doesn’t mean you can omit listing real dependencies - you still list everything, you just define how to compare them. The lint rule (with the additionalHooks setting) will ensure you’re not accidentally referencing a variable inside the callback that isn’t declared in the deps array.

Conclusion

React’s default dependency checking is simple and usually effective, but it can falter when complex reference types are involved. Unnecessary hook executions due to referential inequality can hurt application performance and produce tricky bugs. We explored how this issue arises and reviewed common strategies to deal with it, from manually listing dependencies to deep-comparing or serializing objects.

The use-custom-compare library offers a convenient, reusable way to mitigate these issues. By allowing custom comparison functions for hook dependencies, it helps ensure your effects run only when truly needed, your expensive calculations don’t repeat wastefully, and your callbacks remain stable. In practice, using useCustomCompareEffect/useMemo/useCallback with a deep equality check (or other logic) can eliminate a lot of needless re-renders and computations with minimal code changes. It’s a powerful pattern for performance-minded engineers to add to their toolkit.

Remember, with great power comes responsibility - apply custom comparisons thoughtfully. When used in the right scenarios, _use-custom-compare_can strike an excellent balance between React’s reactivity and application performance, making your hooks smarter about detecting real changes. Happy optimizing!

Get Your Free Consultation Today

Don’t let a slow Next.js app continue to impact your commercial goals. Get a free consultation
with one of our performance engineers and discover how you can achieve faster load times,
happier customers, and a significant increase in your key commercial KPIs.