Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
React Hook recipe from https://usehooks.com
import React, { useState, useEffect, useRef } from 'react';
// Usage
function MyComponent({ obj }) {
const [state, setState] = useState();
// Use the previous obj value if the "id" property hasn't changed
const objFinal = useMemoCompare(obj, (prev, next) => {
return prev && prev.id === next.id;
});
// Here we want to fire off an effect if objFinal changes.
// If we had used obj directly without the above hook and obj was technically a
// new object on every render then the effect would fire on every render.
// Worse yet, if our effect triggered a state change it could cause an endless loop
// where effect runs -> state change causes rerender -> effect runs -> etc ...
useEffect(() => {
// Call a method on the object and set results to state
return objFinal.someMethod().then((value) => setState(value));
}, [objFinal]);
// So why not pass [obj.id] as the dependency array instead?
useEffect(() => {
// Then eslint-plugin-hooks would rightfully complain that obj is not in the
// dependency array and we'd have to use eslint-disable-next-line to work around that.
// It's much cleaner to just get the old object reference with our custom hook.
return obj.someMethod().then((value) => setState(value));
}, [obj.id]);
return <div> ... </div>;
}
// Hook
function useMemoCompare(next, compare) {
// Ref for storing previous value
const previousRef = useRef();
const previous = previousRef.current;
// Pass previous and next value to compare function
// to determine whether to consider them equal.
const isEqual = compare(previous, next);
// If not equal update previousRef to next value.
// We only update if not equal so that this hook continues to return
// the same old value if compare keeps returning true.
useEffect(() => {
if (!isEqual) {
previousRef.current = next;
}
});
// Finally, if equal then return the previous value
return isEqual ? previous : next;
}
@zarcode

This comment has been minimized.

Copy link

@zarcode zarcode commented Apr 9, 2020

It seams to me that your compare function prev => prev && prev.id === obj.id accepts only one argument, but later on you are calling it with two compare(previous, value)

@gragland

This comment has been minimized.

Copy link
Owner Author

@gragland gragland commented Apr 10, 2020

It seams to me that your compare function prev => prev && prev.id === obj.id accepts only one argument, but later on you are calling it with two compare(previous, value)

In this example, since the compare function is defined inline, I already have obj in scope so don't need to use the second arg. If the function was extracted out though I'd need that arg.

@antonioru

This comment has been minimized.

Copy link

@antonioru antonioru commented Jul 8, 2020

@gragland
I really like the idea behind useMemoCompare but I think one of the purpose of useMemo is to avoid computation by memoization, if we pass the value to useMemoCompare the purpose of the hook might be a bit fuzzy, especially if we consider it an improvement over useMemo.
I suggest to rewrite it as the following:

const useMemoCompare = (fn, comparingFn) => {
  const valueRef = useRef(fn()); // perform the fn on the first render
  const isEqual = comparingFn(valueRef.current);

  // If not equal update previous to new value (for next render)
  // and then return new new value below.
  useEffect(() => {
    if (!isEqual) {
      valueRef.current = fn(valueRef.current);
    }
  }, [isEqual]);

  return valueRef.current;
};
@gragland

This comment has been minimized.

Copy link
Owner Author

@gragland gragland commented Jul 30, 2020

@antonioru That's a great point. I'm wondering if useMemoCompare is really the right name for this hook, as it's more about getting a previous value than avoiding computation. Maybe useRefCompare or usePreviousCompare? Here's an upcoming useHooks post I'm working on where this hook is used: https://gist.github.com/gragland/383b0b77b4d05792c3a5a3c6e8a265af

@heyimalex

This comment has been minimized.

Copy link

@heyimalex heyimalex commented Nov 20, 2020

I think an improvement could be made. The main idea is that comparisons can be costly. Very often when a value changes in the === sense but remains equal in the comparison sense, it stays at the new value for many more renders. However, with the current code we repeat our expensive comparison every time. Just as a pseudo example demonstrating the problem:

const expensiveCompare = jest.fn(Object.is);
const compare = (a, b) => a === b || expensiveCompare(a, b);

const first = { hello: "world" };
useMemoCompare(first, compare);
useMemoCompare(first, compare);
useMemoCompare(first, compare);

// These should be trivially ===, so no need to run my expensive compare
expect(expensiveCompare).toHaveBeenCalledTimes(0);

const second = { ...first };
useMemoCompare(second, compare);
useMemoCompare(second, compare);
useMemoCompare(second, compare);

// We unfortunately run the comparison every time that this function gets called,
// even though its input value hasn't changed.
expect(expensiveCompare).toHaveBeenCalledTimes(3);

To fix this, we can save both the first and latest equivalent value passed in, and bring the === check into the hook itself.

export const useMemoCompare = (value, compare) => {
  const { current } = useRef({
    first: value,
    last: value,
  });
  const isEqual = current.last === value || current.first === value || compare(current.last, value);
  useEffect(() => {
    if (!isEqual) {
      current.first = value;
    }
    current.last = value;
  });
  return isEqual ? current.first : value;
};

Hopefully that makes sense!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
You can’t perform that action at this time.