Reputation: 26075
I found similar questions where people wanted to run useEffect
only if certain values are true, but the solutions are always along the lines of:
useEffect(() => {
if (!isTrue) {
return;
}
someFunction(dep);
}, [dep, isTrue]);
The issue with this is if isTrue
goes from true to false to true, it re-runs someFunction
, even if dep
didn't change. I want to re-run someFunction
only if dep
changes.
The only way I can think of to do this is a hack using refs, e.g:
function useEffectIfReady(fn, deps = [], isReady = true) {
const ref = useRef({
forceRerunCount: 0,
prevDeps: [],
});
if (isReady && (ref.current.forceRerunCount === 0
|| deps.length !== ref.current.prevDeps.length
|| !deps.every((d, idx) => Object.is(d, ref.current.prevDeps[idx])))) {
ref.current.forceRerunCount++;
ref.current.prevDeps = deps;
}
useEffect(() => {
if (ref.current.forceRerunCount === 0) {
return;
}
return fn();
}, [ref.current.forceRerunCount]);
});
Is there a cleaner way? I don't like the idea of comparing dependencies myself, since React might change how they compare dependencies and AFAIK they don't export their comparison function.
Upvotes: 1
Views: 9511
Reputation: 9812
How about using a ref as the gatekeepr
function useEffectIfReady(fn, deps = [], isReady = true) {
const readyWasToggled = useRef(isReady);
/*
There are 2 states:
0 - initial
1 - ready was toggled
*/
const getDep = () => {
if (readyWasToggled.current) {
return 1;
}
if (isReady) {
readyWasToggled.current = true;
}
return 0;
};
useEffect(() => {
if (!isReady) {
return;
}
return fn();
}, [...deps, fn, getDep()]);
}
Upvotes: 3
Reputation: 9354
If I've understood correctly then the behaviour you're looking for is:
isReady
becomes true for the first time, run fn()
isReady
flips from false to true:deps
have changed since last time ready, run fn()
deps
have not changed since last time ready, do not run fn()
deps
change and isReady
is true, run fn()
I worked this out from plugging your hook into some UI code, if it's not correct let me know and I'll change/delete this. It seems to me that isReady
and deps
do not have a straightforward relationship, as in they do not correlate well enough for a single useEffect
to handle. They are responsible for different things and have to be handled separately. I would try and refactor things higher up in the logic so that they will play nicer together, outside the react render cycle if possible, but I understand that isn't always the case.
If so, using a gate and refs
to control the flow between them makes the most sense to me. If you only pass one dep
to the hook it makes things a bit simpler:
function useGatedEffect(fn, dep, isReady = true) {
const gate = useRef(isReady);
const prev = useRef(dep);
useEffect(() => {
gate.current = isReady;
if (gate.current && prev.current !== null) {
fn(prev.current);
prev.current = null;
}
}, [isReady, fn]);
useEffect(() => {
gate.current ? fn(dep) : (prev.current = dep);
}, [dep, fn]);
}
However, if you have to pass in an unspecified number of dependencies then you are going to have to do your own equality checks. React will throw warnings if you try and pass spread objects to a useEffect
and it's better for readability to have them hard-coded anyway. As far as I can tell this does the same thing as your original hook, but separates the (simple) comparison logic which will hopefully make it easier to refactor and maintain:
const { useState, useEffect, useRef, useCallback } = React;
function useGatedEffect(fn, deps = [], isReady = true) {
const gate = useRef(isReady);
const prev = useRef(deps);
const [compared, setCompared] = useState(deps);
useEffect(() => {
depsChanged(compared, deps) && setCompared(deps);
}, [compared, deps]);
useEffect(() => {
gate.current = isReady;
if (gate.current && prev.current.length !== 0) {
fn(...prev.current);
prev.current = [];
}
}, [isReady, fn]);
useEffect(() => {
gate.current ? fn(...compared) : (prev.current = compared);
}, [compared, fn]);
function depsChanged(prev, next) {
return next.some((item, i) => prev[i] !== item);
}
}
function App() {
const [saved, setSaved] = useState("");
const [string, setString] = useState("hello");
const [ready, setReady] = useState(false);
const callback = useCallback(dep => {
console.log(dep);
setString(dep);
}, []);
useGatedEffect(callback, [saved], ready);
return (
<div>
<h1>{string}</h1>
<input type="text" onChange={e => setSaved(e.target.value)} />
<button onClick={() => setReady(!ready)}>
Set Ready: {(!ready).toString()}
</button>
</div>
);
}
ReactDOM.render(<App/>, document.getElementById('root'));
<script crossorigin src="https://unpkg.com/react@16/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@16/umd/react-dom.production.min.js"></script>
<div id="root"></div>
Upvotes: 3
Reputation: 398
Try splitting the useEffect in this case for each state, just an idea based on your codes
useEffect(() => {
// your codes here
if (!isTrue) {
return;
}
}, [isTrue]);
useEffect(() => {
// your another set of codes here
someFunction(dep);
}, [dep])
Upvotes: 0
Reputation: 2609
If I have understood your query correctly, you need to execute the someFn()
depending on the value of isTrue.
Though there are ways using custom hooks to compare old and new values and that can be used to solve your problem here, I think there is a much simpler solution to it.
Simply, removing the isTrue
from the dependency array. This will make sure that your useEffect
runs only when the dep
changes. If at that point, isTrue == true
then nothing gets executed, otherwise your code gets executed.
useEffect(() => {
if (!isTrue) {
return;
}
someFunction(dep);
}, [dep]);
Upvotes: 2