React: useCallback/useEffect support custom comparator

Created on 20 Dec 2018  ·  30Comments  ·  Source: facebook/react

Currently we can pass an array as second argument when using useCallback or useEffect like below:

useCallback(()=> {
  doSth(a, b)
}, [a, b]) // how to do deep equal if a is an object ?

The problem is it only compare array items with ===, it there any way to compare complex object ?

Support custom comparator as third argument looks not bad:

useCallback(()=> {
  doSth(a, b)
  }, 
  [complexObject], 
  (item, previousItem)=> { //custom compare logic, return true || false here }
)
Hooks Question

Most helpful comment

Right now we have no plans to do this. Custom comparisons can get very costly when spread across components — especially when people attempt to put deep equality checks there. Usually there's a different way to structure the code so that you don't need it.

There are a few common solutions:

Option 1: Hoist it up

If some value is supposed to be always static, there's no need for it to be in the render function at all.
For example:

const useFetch = createFetch({ /* static config */});

function MyComponent() {
  const foo = useFetch();
}

That completely solves the problem. If instantiating it is annoying, you can always have your own useFetch module that just re-exports the instantiated result (or even provides a few variations):

// ./myUseFetch.js
import createFetch from 'some-library';

export const useFetch = createFetch({ /* config 1 */ })
export const useFetchWithAuth = createFetch({ /* config 2 */ })

Option 2: Embrace dynamic values

Another option is the opposite. Embrace that values are always dynamic, and ask the API consumer to memoize them.

function MyComponent() {
  const config = useMemo(() => ({
    foo: 42,
  }, []); // Memoized
  const data = useFetch(url, config);
}

This might seem like it's convoluted. However for low-level Hooks you probably will create higher-level wrappers anyway. So those can be responsible for correct memoization.

// ./myUseFetch.js
import {useContext} from 'react';
import useFetch from 'some-library';
import AuthContext from './auth-context';

export function useFetchWithAuth(url) {
  const authToken = useContext(AuthContext);
  const config = useMemo(() => ({
    foo: 42,
    auth: authToken
  }), [authToken]); // Only changes the config if authToken changes
  return useFetch(url, config);
}

// ./MyComponent.js
function MyComponent() {
  const data = useFethWithAuth('someurl.com'); // No config
}

Then effectively the users don't need to configure anything at the call site — or you can expose limited supported options as specific arguments. That's probably a better API than an arbitrarily deep "options" object anyway.

Option 3: (be careful) JSON.stringify

Many people expressed a desire for deep equality checks. However, deep equality checks are very bad because they have unpredictable performance. They can be fast one day, and then you change the data structure slightly, and they become super slow because the traversal complexity has changed. So we want to explicitly discourage using deep checks — and useEffect API currently does that.

However, there are cases where it stands in the way of ergonomics too much. Such as with code like

const variables = {userId: id};
const data = useQuery(variables);

In this case there is no actual deep comparisons necessary. In this example, we know our data structure is relatively shallow, doesn't have cycles, and is easily serializable (e.g. because we plan to send that data structure over the network as a request). It doesn't have functions or weird objects like Dates.

In those rare cases it's acceptable to pass [JSON.stringify(variables)] as a dependency.

“Wait!” I hear you saying, “Isn’t JSON.stringify() slow?”

On small inputs, like the example above, it is very fast.

It does get slow on larger inputs. But at that point deep equality is also slow enough that you’ll want to rethink your strategy and API. Such as going back to a granular invalidation with useMemo. That gives you the performance — if indeed, you care about it.


There are probably minor other use cases that don't quite fit into these. For those use cases it is acceptable to use refs as an escape hatch. But you should strongly consider to avoid comparing the values manually, and rethinking your API if you feel you need that.

All 30 comments

You could do this instead.

useCallback(() => {
  doSth(a, b)
}, [a, a.prop, a.anotherProp, a.prop.nestedProp /* and so on */, b])

Or you could try using JSON.stringify(a) in the inputs array.

You can use the result of the custom equality check as a value inside the array to get this behavior.

useEffect(
  () => {
    // ...
  },
  [compare(a, b)]
);

@aweary How would that work? They want to use the custom comparator to check compare(a, prevA), not compare(a, b). The previous value isn't accessible in scope.

@heyimalex you can use a ref to store a reference to the previous value. This is mentioned in the Hooks FAQ: How to get the previous props or state?
.

@aweary How would passing [compare(a, prevA)] work? Wouldn't that make it update on transitions from true -> false and from false -> true when you really just want it to update when false? Sorry if I'm missing something obvious!

I was trying to write what the op wanted and here's what I got.

function usePrevious(value) {
    const ref = useRef();
    useEffect(() => {
        ref.current = value;
    });
    return ref.current;
}

function useCallbackCustomEquality(cb, args, equalityFunc) {
    const prevArgs = usePrevious(args);
    const argsAreEqual =
        prevArgs !== undefined &&
        args.length === prevArgs.length &&
        args.every((v, i) => equalityFunc(v, prevArgs[i]));
    const callbackRef = useRef();
    useEffect(() => {
        if (!argsAreEqual) {
            callbackRef.current = cb;
        }
    })
    return argsAreEqual ? callbackRef.current : cb;
}

@kpaxqin yeah it's sort of a half-baked solution 😕your approach is fine I think for useCallback. For useEffect you'd do something similar except you'd add a check for equality inside a useEffect before calling the provided effect callback.

You could actually encapsulate the arg memoization in one hook and then use that for all of the other functions.

function useMemoizeArgs(args, equalityCheck) {
  const ref = useRef();
  const prevArgs = ref.current;
  const argsAreEqual =
        prevArgs !== undefined &&
        args.length === prevArgs.length &&
        args.every((v, i) => equalityFunc(v, prevArgs[i]));
  useEffect(() => {
      if (!argsAreEqual) {
        ref.current = args;
      }
  });
  return argsAreEqual ? prevArgs : args;
}

useEffect(() => {
  // ...
}, useMemoizeArgs([a,b], equalityCheck));

A similar API to React.memo would be great for useEffect and friends when used with an immutable data structure library -- such as clojurescript. Not to over simplify it but it at least appears to be a very accessible change: https://github.com/facebook/react/blob/659c13963ea2b8a20e0b19d817ca40a3ebb19024/packages/react-reconciler/src/ReactFiberHooks.js#L524-L549

@heyimalex Thanks for your solution, it works great for me, but still got one risk as a common solution.

For example: re-render every second, use current time as input array, compare time and run effect if minute has changed.

The key is: A equal to B and B equal to C might not means A equal to C for custom comparator

Use another value as compare result could get rid of this

function useMemoizeArgs(args, equalityCheck) {
  const ref = useRef();
  const flag = ref.current ? ref.current.flag : 0;
  const prevArgs = ref.current ? ref.current.args : undefined;
  const argsAreEqual =
        prevArgs !== undefined &&
        args.length === prevArgs.length &&
        args.every((v, i) => equalityFunc(v, prevArgs[i]));
  useEffect(() => {
      ref.current = {
         args,
         flag: !argsAreEqual ? (flag + 1) : flag
      }
  });
  return ref.current.flag
}

useEffect(() => {
  // ...
}, useMemoizeArgs([a,b], equalityCheck));

Still think the best way would be support custom comparator officially for those hooks which need to compare inputs.

The solution given by @kpaxqin didn't work during my tests because:

  1. ref.current is undefined on first call so return ref.current.flag crash
  2. the way argsAreEqual is compute ends up returning true for the first call, therefore the flag is always incremented at least once

I modify it to:
```js
const useMemoizeArgs = (args, equalityFunc) => {
const ref = useRef();
const flag = ref.current ? ref.current.flag : 0;
const prevArgs = ref.current ? ref.current.args : undefined;
const argsHaveChanged =
prevArgs !== undefined &&
(args.length !== prevArgs.length || args.some((v, i) => equalityFunc(v, prevArgs[i])));

useEffect(() => {
ref.current = {
args,
flag: argsHaveChanged ? flag + 1 : flag,
};
});

return flag;
};
````

which works for my use case.

But obviously if hooks supported a custom comparator like React.memo as parameter, things will be a lot better/simpler.

My main use cases where deep equal required is data fetching

const useFetch = (config) => {
  const [data, setData] = useState(null); 
  useEffect(() => {
    axios(config).then(response => setState(response.data)
  }, [config]);  // <-- will fetch on each render
  return [data];
}

// in render
const DocumentList = ({ archived }) => {
  const data = useFetch({url: '/documents/', method: "GET", params: { archived }, timeout: 1000 }) 
  ...
}

I checked top google results for "react use fetch hook", they are either have a bug (refetch on each rerender) or use one of the methods:

So it would be good to have a comparator as a third argument or a section in docs that explains the right way for such use cases.

Right now we have no plans to do this. Custom comparisons can get very costly when spread across components — especially when people attempt to put deep equality checks there. Usually there's a different way to structure the code so that you don't need it.

There are a few common solutions:

Option 1: Hoist it up

If some value is supposed to be always static, there's no need for it to be in the render function at all.
For example:

const useFetch = createFetch({ /* static config */});

function MyComponent() {
  const foo = useFetch();
}

That completely solves the problem. If instantiating it is annoying, you can always have your own useFetch module that just re-exports the instantiated result (or even provides a few variations):

// ./myUseFetch.js
import createFetch from 'some-library';

export const useFetch = createFetch({ /* config 1 */ })
export const useFetchWithAuth = createFetch({ /* config 2 */ })

Option 2: Embrace dynamic values

Another option is the opposite. Embrace that values are always dynamic, and ask the API consumer to memoize them.

function MyComponent() {
  const config = useMemo(() => ({
    foo: 42,
  }, []); // Memoized
  const data = useFetch(url, config);
}

This might seem like it's convoluted. However for low-level Hooks you probably will create higher-level wrappers anyway. So those can be responsible for correct memoization.

// ./myUseFetch.js
import {useContext} from 'react';
import useFetch from 'some-library';
import AuthContext from './auth-context';

export function useFetchWithAuth(url) {
  const authToken = useContext(AuthContext);
  const config = useMemo(() => ({
    foo: 42,
    auth: authToken
  }), [authToken]); // Only changes the config if authToken changes
  return useFetch(url, config);
}

// ./MyComponent.js
function MyComponent() {
  const data = useFethWithAuth('someurl.com'); // No config
}

Then effectively the users don't need to configure anything at the call site — or you can expose limited supported options as specific arguments. That's probably a better API than an arbitrarily deep "options" object anyway.

Option 3: (be careful) JSON.stringify

Many people expressed a desire for deep equality checks. However, deep equality checks are very bad because they have unpredictable performance. They can be fast one day, and then you change the data structure slightly, and they become super slow because the traversal complexity has changed. So we want to explicitly discourage using deep checks — and useEffect API currently does that.

However, there are cases where it stands in the way of ergonomics too much. Such as with code like

const variables = {userId: id};
const data = useQuery(variables);

In this case there is no actual deep comparisons necessary. In this example, we know our data structure is relatively shallow, doesn't have cycles, and is easily serializable (e.g. because we plan to send that data structure over the network as a request). It doesn't have functions or weird objects like Dates.

In those rare cases it's acceptable to pass [JSON.stringify(variables)] as a dependency.

“Wait!” I hear you saying, “Isn’t JSON.stringify() slow?”

On small inputs, like the example above, it is very fast.

It does get slow on larger inputs. But at that point deep equality is also slow enough that you’ll want to rethink your strategy and API. Such as going back to a granular invalidation with useMemo. That gives you the performance — if indeed, you care about it.


There are probably minor other use cases that don't quite fit into these. For those use cases it is acceptable to use refs as an escape hatch. But you should strongly consider to avoid comparing the values manually, and rethinking your API if you feel you need that.

Just commenting that this is an issue with ClojureScript that provides its own equality semantics, where equality comparison of data structures are based on value and not of reference. So using the Object.is algorithm without an escape hatch is an issue. Similarly for setState.

@gaearon Option 3 is simple and works well for my case but clashes with react-hooks/exhaustive-deps since the non-serialized object is used in the effect. Is it just not possible with this linting option enabled?

@gaearon Option 3 is simple and works well for my case but clashes with react-hooks/exhaustive-deps since the non-serialized object is used in the effect. Is it just not possible with this linting option enabled?

One way around this is to actually deserialize the object from the string within the closure instead of using the already-deserialized object... it seems silly and a waste of cycles but it is in some sense a conceptually purer approach in keeping with the hook rules.

I just published react-delta which aims to break useEffect into several smaller hooks to give developers more control over when effects run. It's somewhat experimental, and I think it's one of those libs where it'll be hard to tell its value until it gets some real-world use. useEffect is largely restrictive whereas react-delta gives the developer a lot more power. This power could very well be abused, but if people are going to be bending over backward to get deep comparison checks out of useEffect anyway, then i think having a dedicated API to facilitate more fine-grained control over comparisons makes sense. In my opinion, useEffect is a bit too restrictive and has too many responsibilities.

Responsibilities

  1. It compares all values in its dependency array using Object.is
  2. It runs effect/cleanup callbacks based on the result of number 1

Partitioning Responsibilities

react-delta breaks useEffect's responsibilities into several smaller hooks.

Responsibility 1

Responsibility 2

Example

import React, { useState, useEffect } from "react";
import { useDeltaArray, some, useConditionalEffect } from "react-delta";

function App() {
  const [page, setPage] = useState(1);
  const [search, setSearch] = useState("");

   // traditional way
  useEffect(() => {
    console.log('useEffect: page or search changed')
  }, [page, search])

  // react-delta  way
  const deltas = useDeltaArray([ page, search ]);
  useConditionalEffect(() => {
    // deltas give access to prev and curr values for custom comparison
    console.log('useConditionalEffect: page or search changed', deltas)
  }, some(deltas))

  return (
    <div>
      <input value={search} onChange={e => setSearch(e.target.value)} />
      <button onClick={() => setPage(p => p + 1)}>Next Page: {page}</button>
    </div>
  );
}

But the question remains, does this API give too much control such that people will find ways to abuse it?

@gaearon you proposed good solutions. But unfortunately, it does not cover cases when we pass to useEffect some 3rd-party-library-sourced objects.
For example, if we use firebase, it always creates a new Query object, which has a method isEqual that could help us if we had a custom compassion. But we don't have an ability to make a custom comparator like this:

const areEqual = (queryA, queryB) => queryA.isEqual(queryB)

You said, that we can use useMemo in this thread:

const fetchCollection = (hookName = 'useEffectWithComparator') => {
  const memoizedQuery = useMemo(() => {
     return firebase.firestore().collection('hooks').where('name', '==', hookName), 
  }, [hookName]));
  return useFirestoreQuery(memoizedQuery);
};

const useFirestoreQuery = query => {
   const [state, setState] = useState(null);
   useEffect(() => {
      const result = await query() // we need to wrap it with self-called async function, and so on
      setState(result)
   }, [query])
   return result;
}

Looks like it works. But there is an important note from React documentation:

You may rely on useMemo as a performance optimization, not as a semantic guarantee. In the future, React may choose to “forget” some previously memoized values and recalculate them on next render, e.g. to free memory for offscreen components. Write your code so that it still works without useMemo — and then add it to optimize performance.

And the scary thing, that this useMemo is a semantic guarantee. Otherwise this code will go to infinite loop.

What do you think?

@Carduelis Thank you for asking this, I've been scratching my head for a while trying to figure out what the best approach to this problem is. The useMemo solution that @gaearon presented is _almost_ perfect for us, except for that tidbit in the docs you quoted about semantic guarantees makes me nervous.

I've seen answers saying to use refs if you need semantic guarantees, but I can't wrap my head around how to apply those to this use case.

My example:

function useFetch({ url, requestBody }){
     const [result, setResult] = useState({ loading: false, data: null, error: null })
     const queryFunc = useCallback(() => {
            // ... do fetch
     }, [url, requestBody]);
     return [result, queryFunc];
}

function MyComponent(props) {
    const body = useMemo(() => ({ id: props.id }), [props.id])
    const [result, query] = useFetch("example", body)

    useEffect(() => {
         query();
    } , [query])
}

As you can see, we use useMemo to preserve the reference as well as kick off a new query whenever props.id changes. If useMemo randomly decides the recalculate its value, this would cause an unexpected fetch call. How could we use refs here?

Consider the use-memo-one package with provides useMemo and useCallback with the added semantic guarantee.

I have built use-custom-compare hooks to solve this. I hope this will help.

I make apps with several data fetches within useEffect() and event functions. It took me a nearly a year to figure it out, but @gaearon 's option 1 is correct. Hoisting your calls up in separate modules is the right answer in 9 out of 10 cases.

Now I don't agree with option 2. Using useMemo() to deliver a static instance variable against an empty dependency array is, in my opinion, the wrong hook. Try first to determine why your dependency array should remain empty. If there are indeed no dependencies, then try useRef() instead.

For option 3, don't pass in a JSON.stringify() object/array into the list of dependency. Instead, let the hook hit and add conditionals to determine whether an inner function should proceed.

Assuming you have a shallow array of objects:

useCallback(() => {
   if (JSON.stringify(myJSONArr) !== JSON.stringify(somethingElse) {
         execFunc()
      }
}, [myJSONArr])

I don't understand why react does not offer an option to do deep equal compare. Everyone needs to do the search and build their own, it is a waste of time. Summing everyone's time together is a ton.

I don't understand why react does not offer an option to do deep equal compare. Everyone needs to do the search and build their own, it is a waste of time. Summing everyone's time together is a ton.

Yea this is quite annoying. I settled on using object-hash package seems to work alright

I don't understand why react does not offer an option to do deep equal compare. Everyone needs to do the search and build their own, it is a waste of time. Summing everyone's time together is a ton.

Yea this is quite annoying. I settled on using object-hash package seems to work alright

Does object-hash works for object with key in different order?

it will likely generate a new hash

I'm a bit surprised by the fact that React is inconsistent here, because React.memo does have this. Why is this functionality provided there when the hooks do not have it?

I wanted to share a problem I run into very frequently, which I believe would be solved if React supported custom equality functions for useMemo, useCallback and useEffect. /cc @gaearon

Problem

Take the example of deriving arrays.

Full example: https://stackblitz.com/edit/react-use-memo-result-yj2wxr?file=index.tsx

console.log(fruits);
// => ['apple', 'banana', 'grapes']

const yellowFruits = useMemo(
  () => fruits.filter((fruit) => fruit === "banana"),
  [fruits]
);
// => ['banana']

Image that fruits is memoized correctly. It is an array that _only changes reference when the contents inside of it change_.

When fruits changes, we recompute yellowFruits, which will create a new reference.

This means that we could have a new reference for yellowFruits _even if the array contents have not changed_. Example:

console.log(fruits);
// => ['apple', 'banana', 'grapes', 'pineapple'] // ✅ new content, new reference

const yellowFruits = useMemo(
  () => fruits.filter((fruit) => fruit === "banana"),
  [fruits]
);
// => ['banana'] // ❌ same content, new reference
  • If yellowFruits is passed downstream as a prop to a memo'd component, we will have wasted re-renders.
  • If it's passed to useEffect as a dependency, the effect will run more frequently than it should (as seen in the example).
  • If it's passed to useMemo or a reselect selector as a dependency, the memoized function will run more frequently than it should, causing further memoization issues downstream.

You get the idea.

Solution

Custom equality functions for useMemo, useCallback and useEffect would solve this problem:

 useEffect(() => {
   console.log("yellowFruits changed");
-}, [yellowFruits]);
+}, [yellowFruits], shallowEqual);

To demonstrate this solution, here's an example that's using use-custom-compare (thanks @kotarella1110!). But ideally we wouldn't need to resort to library for this.


We can already pass custom equality functions to reselect's createSelector and React's React.memo HOC. Why would we want to treat these hooks any differently?

Right now we have no plans to do this. Custom comparisons can get very costly when spread across components — especially when people attempt to put deep equality checks there. Usually there's a different way to structure the code so that you don't need it.

There are a few common solutions:

Option 1: Hoist it up

If some value is supposed to be always static, there's no need for it to be in the render function at all.
For example:

const useFetch = createFetch({ /* static config */});

function MyComponent() {
  const foo = useFetch();
}

That completely solves the problem. If instantiating it is annoying, you can always have your own useFetch module that just re-exports the instantiated result (or even provides a few variations):

// ./myUseFetch.js
import createFetch from 'some-library';

export const useFetch = createFetch({ /* config 1 */ })
export const useFetchWithAuth = createFetch({ /* config 2 */ })

Option 2: Embrace dynamic values

Another option is the opposite. Embrace that values are always dynamic, and ask the API _consumer_ to memoize them.

function MyComponent() {
  const config = useMemo(() => ({
    foo: 42,
  }, []); // Memoized
  const data = useFetch(url, config);
}

This might seem like it's convoluted. However for low-level Hooks you probably will create higher-level wrappers anyway. So those can be responsible for correct memoization.

// ./myUseFetch.js
import {useContext} from 'react';
import useFetch from 'some-library';
import AuthContext from './auth-context';

export function useFetchWithAuth(url) {
  const authToken = useContext(AuthContext);
  const config = useMemo(() => ({
    foo: 42,
    auth: authToken
  }), [authToken]); // Only changes the config if authToken changes
  return useFetch(url, config);
}

// ./MyComponent.js
function MyComponent() {
  const data = useFethWithAuth('someurl.com'); // No config
}

Then effectively the users don't need to configure anything at the call site — or you can expose limited supported options as specific arguments. That's probably a better API than an arbitrarily deep "options" object anyway.

Option 3: (be careful) JSON.stringify

Many people expressed a desire for deep equality checks. However, deep equality checks are very bad because they have unpredictable performance. They can be fast one day, and then you change the data structure slightly, and they become super slow because the traversal complexity has changed. So we want to explicitly discourage using deep checks — and useEffect API currently does that.

However, there are cases where it stands in the way of ergonomics too much. Such as with code like

const variables = {userId: id};
const data = useQuery(variables);

In this case there is no actual deep comparisons necessary. In this example, we know our data structure is relatively shallow, doesn't have cycles, and is easily serializable (e.g. because we plan to send that data structure over the network as a request). It doesn't have functions or weird objects like Dates.

In those rare cases it's acceptable to pass [JSON.stringify(variables)] as a dependency.

“Wait!” I hear you saying, “Isn’t JSON.stringify() slow?”

On small inputs, like the example above, it is very fast.

It does get slow on larger inputs. But at that point deep equality is also slow enough that you’ll want to rethink your strategy and API. Such as going back to a granular invalidation with useMemo. That gives you the performance — if indeed, you care about it.

There are probably minor other use cases that don't quite fit into these. For those use cases it is acceptable to use refs as an escape hatch. But you should strongly consider to avoid comparing the values manually, and rethinking your API if you feel you need that.

I have a use case.
for example, there are variables:

const a = [bigObject0, bigObject1, ...manyBigObjects] // length can be change
const b = { bigObject0, bigObject1, bigObject2, ...manyBigObjects } // length can be change
const c = 'I amd just a string'
const d = { a: 1, b: '2' } // length fixed.

React.useEffect(() => {
}, [...a, Object.keys(b).map(key => b[key]), c, d.a, d.b]);

For this use case, Option 3 not work well, because I don't want to serialize the deep objects. And if a, b can't be memorized well, option 2 not work too. Option 1 not work in many situation. So I choose the solution in the code. And it seems work well. But React always show a warning of it. React found the length of deps may change and it told me that's bad. So why bad?

I have built use-custom-compare hooks to solve this. I hope this will help.

Give this man a medal!

I think it is quite easy to compose a solution to this problem with one or two small hooks, I think. I just started using hooks, though, so perhaps my approach is somehow incorrect

// Returns a number representing the "version" of current.
function useChangeId<T>(next: T, isEqual: (prev: T | undefined, next: T) => boolean) {
  const prev = useRef<T>()
  const id = useRef(0)
  if (!isEqual(prev.current, next)) {
    id.current++
  }
  useEffect(() => { prev.current = next }, [next])
  return id
}

interface FetchConfig { /* ... etc ... */ }

function useFetch(config:  FetchConfig) {
  // Returns a new ID when config does not deep-equal previous config
  const changeId = useChangeId(config, _.isEqual)
  // Returns a new callback when changeId changes.
  return useCallback(() => implementUseFetchHere(config), [changeId])
}

function MyComponent(props: { url: string }) {
  const fetchConfig = { url, ... }
  const fetch = useFetch(fetchConfig)
  return <button onClick={fetch}>Fetch</button>
}

Was this page helpful?
0 / 5 - 0 ratings