score:13

Accepted answer

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

score: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

score:2

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

score:2

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

score: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.


Related Query

More Query from same tag