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; | |
} |
This comment has been minimized.
This comment has been minimized.
In this example, since the compare function is defined inline, I already have |
This comment has been minimized.
This comment has been minimized.
@gragland
|
This comment has been minimized.
This comment has been minimized.
@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 |
This comment has been minimized.
This comment has been minimized.
I think an improvement could be made. The main idea is that comparisons can be costly. Very often when a value changes in the 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 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! |
This comment has been minimized.
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)