-
-
Save gragland/ca6806dbb849efa32be8a6919e281d09 to your computer and use it in GitHub Desktop.
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; | |
} |
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!
Is there a reason for wrapping the inequality check inside a useEffect
?
This is my suggestion.
Current problem is
- lack of type.
- current useEffect doesn't have dependencies.
- previous can be undefined. compare function should not get undefined as its params. You have to handle it.
- useEffect should not be used here.
import { useRef } from "react";
export function useMemoCompare(state: T, compare: (previous: T, current: T) => boolean) {
const previousRef = useRef();
const previous = previousRef.current;
if (previous === undefined) {
return state
}
const isEqual = compare(previous, state);
if (!isEqual) {
previousRef.current = state;
}
return isEqual ? previous : state;
}
Edited: Don't use above code. I updated my code. The previous one causes an infinite loop.
import { useRef } from "react";
export function useMemoCompare<T>(state: T, compare: (previous: T, current: T) => boolean) {
const previousRef = useRef<T>();
const previous = previousRef.current;
const isEqual = (previous === undefined) ? false : compare(previous, state);
if (!isEqual) {
previousRef.current = state;
}
return isEqual ? previous : state;
}
@asterisk37n You need to wrap updates to refs in useEffect because it is a side effect. React can discard renders before running effects.
Also... your code is super broken. previous
will always be undefined
in that block because ref.current
is never updated. If you want to not call compare on the first render either store that inside the ref or use some sort of sentinel value instead.
const FirstRender = Symbol();
export function useMemoCompare<T>(state: T, compare: (previous: T, current: T) => boolean) {
const previousRef = useRef<T | typeof FirstRender>(FirstRender);
const previous = previousRef.current;
const isEqual = previous !== FirstRender && compare(previous, state);
useEffect(() => {
if (!isEqual) {
previousRef.current = state;
}
}, [state]);
return isEqual ? previous : state;
}
@heyimalex Thanks for quick check. Yeah, as you said, my post causes an infinite loop and was about to destroy my computer. I updated my code that seemingly works fine.
Could you explain why you would useEffect
in your code at your convenience?
I also have a question about how to "get data and edit, update into database".
@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