Leo Jiang
Leo Jiang

Reputation: 26075

Run useEffect only if certain values are true, but don't re-run unless the dependencies change

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

Answers (4)

thedude
thedude

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()]);
}

Edit optimistic-wu-cbfd5

Upvotes: 3

lawrence-witt
lawrence-witt

Reputation: 9354

If I've understood correctly then the behaviour you're looking for is:

  • if isReady becomes true for the first time, run fn()
  • if isReady flips from false to true:
  1. deps have changed since last time ready, run fn()
  2. deps have not changed since last time ready, do not run fn()
  • if 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

Thirumani guhan
Thirumani guhan

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

Rohan Agarwal
Rohan Agarwal

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

Related Questions