21

Currently useEffect is fired when just one of the dependencies have changed.

How could I update it / use it to fire when all of the dependencies have changed?

Jack
  • 2,891
  • 11
  • 48
  • 65
  • 1
    Can you ellaborate on what you mean by "firing"? `useCallback` is simply a memoized callback function. – Austin Brunkhorst Jul 18 '20 at 23:41
  • I'm sorry I had it mixed up with useEffect which does fire each time the params change? By Firing I mean calling the callback you provide in the first parameter. – Jack Jul 18 '20 at 23:44
  • 3
    I don't think it's possible through the `useEffect` API. – Robert Cooper Jul 18 '20 at 23:48
  • It doesn't seem like there's a straightforward way to implement this behavior. What is the scenario in which you only want the effect to run when all dependencies have changed? – Austin Brunkhorst Jul 18 '20 at 23:53
  • 2
    It's a pretty simple one which I can likely work around however it is this. Currently I use redux to set an option. This triggers a refresh which then triggers a refresh of a data source with the new options. That would cause the useEffect to fire as both options have changed and the new data has changed. You may think just call it on the new data, however the data can change from other sources and the useEffect is expensive so I'd like to only do it once the options and THEN the data has been changed. Firing it before the data does not work. Perhaps I'm missing something obvious? – Jack Jul 19 '20 at 00:06
  • 1
    You have Redux? Then likely this is the wrong question, try to resolve it in the store. – Ackroydd Jul 20 '20 at 06:15

5 Answers5

16

You'll need to add some logic to call your effect when all dependencies have changed. Here's useEffectAllDepsChange that should achieve your desired behavior.

The strategy here is to compare the previous deps with the current. If they aren't all different, we keep the previous deps in a ref an don't update it until they are. This allows you to change the deps multiple times before the the effect is called.

import React, { useEffect, useState, useRef } from "react";

// taken from https://usehooks.com/usePrevious/
function usePrevious(value) {
  const ref = useRef();

  useEffect(() => {
    ref.current = value;
  }, [value]);
  
  return ref.current;
}

function useEffectAllDepsChange(fn, deps) {
  const prevDeps = usePrevious(deps);
  const changeTarget = useRef();

  useEffect(() => {
    // nothing to compare to yet
    if (changeTarget.current === undefined) {
      changeTarget.current = prevDeps;
    }

    // we're mounting, so call the callback
    if (changeTarget.current === undefined) {
      return fn();
    }

    // make sure every dependency has changed
    if (changeTarget.current.every((dep, i) => dep !== deps[i])) {
      changeTarget.current = deps;

      return fn();
    }
  }, [fn, prevDeps, deps]);
}

export default function App() {
  const [a, setA] = useState(0);
  const [b, setB] = useState(0);

  useEffectAllDepsChange(() => {
    console.log("running effect", [a, b]);
  }, [a, b]);

  return (
    <div>
      <button onClick={() => setA((prev) => prev + 1)}>A: {a}</button>
      <button onClick={() => setB((prev) => prev + 1)}>B: {b}</button>
    </div>
  );
}

Edit vibrant-sky-q9hju

An alternate approach inspired by Richard is cleaner, but with the downside of more renders across updates.

function useEffectAllDepsChange(fn, deps) {
  const [changeTarget, setChangeTarget] = useState(deps);

  useEffect(() => {
    setChangeTarget(prev => {
      if (prev.every((dep, i) => dep !== deps[i])) {
        return deps;
      }

      return prev;
    });
  }, [deps]);

  useEffect(fn, changeTarget);
}
Austin Brunkhorst
  • 20,704
  • 6
  • 47
  • 61
5

I like @AustinBrunkhorst's soultion, but you can do it with less code.

Use a state object that is only updated when your criteria is met, and set it within a 2nd useEffect.

import React, { useEffect, useState } from "react";
import "./styles.css";

export default function App() {
  const [a, setA] = useState(0);
  const [b, setB] = useState(0);
  const [ab, setAB] = useState({a, b});

  useEffect(() => {
    setAB(prev => {
      console.log('prev AB', prev)
      return (a !== prev.a && b !== prev.b) 
        ? {a,b} 
        : prev;  // do nothing
    })
  }, [a, b])

  useEffect(() => {
    console.log('both have changed')
  }, [ab])

  return (
    <div className="App">
      <div>Click on a button to increment its value.</div>
      <button onClick={() => setA((prev) => prev + 1)}>A: {a}</button>
      <button onClick={() => setB((prev) => prev + 1)}>B: {b}</button>
    </div>
  );
}

Edit relaxed-https-w5grz

Richard Matsen
  • 20,671
  • 3
  • 43
  • 77
  • Nice approach. I think the downside here is the extra renders caused by `setAB`. We could also improve reusability by moving this functionality to another hook with the same signature as `useEffect`. – Austin Brunkhorst Jul 19 '20 at 19:55
3

You'll have to track the previous values of your dependencies and check if only one of them changed, or both/all. Basic implementation could look like this:

import React from "react";

const usePrev = value => {
  const ref = React.useRef();

  React.useEffect(() => {
    ref.current = value;
  }, [value]);

  return ref.current;
};

const App = () => {
  const [foo, setFoo] = React.useState(0);
  const [bar, setBar] = React.useState(0);
  const prevFoo = usePrev(foo);
  const prevBar = usePrev(bar);

  React.useEffect(() => {
    if (prevFoo !== foo && prevBar !== bar) {
      console.log("both foo and bar changed!");
    }
  }, [prevFoo, prevBar, foo, bar]);

  return (
    <div className="App">
      <h2>foo: {foo}</h2>
      <h2>bar: {bar}</h2>
      <button onClick={() => setFoo(v => v + 1)}>Increment foo</button>
      <button onClick={() => setBar(v => v + 1)}>Increment bar</button>
      <button
        onClick={() => {
          setFoo(v => v + 1);
          setBar(v => v + 1);
        }}
      >
        Increment both
      </button>
    </div>
  );
};

export default App;

Here is also a CodeSandbox link to play around.

You can check how the usePrev hook works elsewhere, e.g here.

Maxim Zubarev
  • 2,403
  • 2
  • 29
  • 48
  • `prevFoo` gets updated when you click 'increment bar'?. – Richard Matsen Jul 19 '20 at 08:46
  • No, it doesn't get updated when you click 'increment bar' – Maxim Zubarev Jul 19 '20 at 19:59
  • 2
    Add `

    prevFoo: {prevFoo}

    ` you will see it happen.
    – Richard Matsen Jul 19 '20 at 21:30
  • Added and if I only click "increment bar" `prevFoo` stays absolutely the same. It changes, when you click "increment foo" or "increment both", and afterwards click "increment bar", but that's the purpose of it, that it changes delayed. – Maxim Zubarev Jul 20 '20 at 10:47
  • @Maxim looks like you forgot to reload the page - prevFoo starts `undefined` then gets `0` when 'increment bar'. Then if you click 'increment foo` you do not see 'both foo and bar changed' which is the behaviour other solutions have. –  Jul 20 '20 at 12:01
1

FWIW, react-use is a nice library of additional hooks for react that has ~30k stars on GitHub:

https://github.com/streamich/react-use

And one of those custom hooks is the useCustomCompareEffect:

https://github.com/streamich/react-use/blob/master/docs/useCustomCompareEffect.md

Which could be easily used to handle this kind of custom comparison

chrismarx
  • 11,488
  • 9
  • 84
  • 97
0

To demonstrate how you can compose hooks in various manners, here's my approach. This one doesn't invoke the effect in the initial attribution.

import React, { useEffect, useRef, useState } from "react";
import "./styles.css";

function usePrevious(state) {
  const ref = useRef();

  useEffect(() => {
    ref.current = state;
  });

  return ref.current;
}

function useAllChanged(callback, array) {
  const previousArray = usePrevious(array);

  console.log("useAllChanged", array, previousArray);

  if (previousArray === undefined) return;

  const allChanged = array.every((state, index) => {
    const previous = previousArray[index];
    return previous !== state;
  });

  if (allChanged) {
    callback(array, previousArray);
  }
}

const randomIncrement = () => Math.floor(Math.random() * 4);

export default function App() {
  const [state1, setState1] = useState(0);
  const [state2, setState2] = useState(0);
  const [state3, setState3] = useState(0);

  useAllChanged(
    (state, prev) => {
      alert("Everything changed!");
      console.info(state, prev);
    },
    [state1, state2, state3]
  );

  const onClick = () => {
    console.info("onClick");
    setState1(state => state + randomIncrement());
    setState2(state => state + randomIncrement());
    setState3(state => state + randomIncrement());
  };

  return (
    <div className="App">
      <p>State 1: {state1}</p>
      <p>State 2: {state2}</p>
      <p>State 3: {state3}</p>

      <button onClick={onClick}>Randomly increment</button>
    </div>
  );
}

Edit dazzling-swartz-e3oq6

Rodrigo Amaral
  • 1,324
  • 1
  • 13
  • 17