Calling a method (that mutates state) from two different useEffect


I am playing around with React Hooks, calling a method (that mutates state) from two different useEffect. Following code is given:

function App() {
  const [clicked, setClicked] = useState(false);

  /**
   * Listen for clicked changes. When clicked changes to true,
   * allow setCounterAndSetUrlHash to do it's thing, before accpeting
   * the next click. So the clicked flag is simply a valve, that opens
   * after two seconds.
   */
  useEffect(() => {
    if (clicked) {
      setCounterAndSetUrlHash(counter + 1);
      setTimeout(() => {
        setClicked(false);
      }, 2000);
    }
  }, [clicked]);

  const [counter, setCounter] = useState(0);

  /**
   * Listen for changes in the URL hash. When the user presses
   * the back button in the browser toolbar, decrement the
   * counter value.
   */
  useEffect(() => {
    window.onhashchange = () => {
      const value = Number(window.location.hash.replace("#", ""));
      // must be number
      if (typeof value === "number" && value % 1 === 0) {
        if (counter - 1 === value) {
          setCounterAndSetUrlHash(counter - 1);
        }
      }
    };
  });

  /**
   * Set a new counter value and apply the same value
   * to the URL hash. I want to reuse this function
   * in both useEffect above.
   */
  const setCounterAndSetUrlHash = value => {
    setCounter(value);
    if (value === 0) {
      window.location.hash = "";
    } else {
      window.location.hash = String(value);
    }
  };

  return (
    <div className="App">
      <p>Clicked: {String(clicked)}</p>
      <p>Counter: {counter}</p>
      <button type="button" onClick={() => setClicked(true)}>
        Click me
      </button>
    </div>
  );
}

The code in action: https://codesandbox.io/s/dreamy-shadow-7xesm

The code is actually working. However I am getting this warning..

React Hook useEffect has a missing dependency: 'counter'. Either include it or remove the dependency array. (react-hooks/exhaustive-deps)

.. and I am not sure how to conform with that while keeping the current functionality. When I add counter to the dependencies, I end up with an infinite loop.

Your first effect uses counter state variable but its dependency list does not include it. Including it in dependency list will create infinite loop.

You can remove the dependency on counter by using function type argument in setCounter.

function App() {
  const [clicked, setClicked] = useState(false);

  /**
   * Listen for clicked changes. When clicked changes to true,
   * allow setCounterAndSetUrlHash to do it's thing, before accpeting
   * the next click. So the clicked flag is simply a valve, that opens
   * after two seconds.
   */
  useEffect(() => {
    if (clicked) {
      incrCounter(1);
      setTimeout(() => {
        setClicked(false);
      }, 2000);
    }
  }, [clicked]);

  const [counter, setCounter] = useState(0);

  /**
   * Listen for changes in the URL hash. When the user presses
   * the back button in the browser toolbar, decrement the
   * counter value.
   */
  useEffect(() => {
    window.onhashchange = () => {
      const value = Number(window.location.hash.replace("#", ""));
      // must be number
      if (typeof value === "number" && value % 1 === 0) {
        if (counter - 1 === value) {
          incrCounter(- 1);
        }
      }
    };
  });

  useEffect(() => {
    if (counter === 0) {
      window.location.hash = "";
    } else {
      window.location.hash = String(counter);
    }
  }, [counter])
  /**
   * Set a new counter value and apply the same value
   * to the URL hash. I want to reuse this function
   * in both useEffect above.
   */
  const incrCounter = delta => {
    setCounter(value => value + delta);
  };

  return (
    <div className="App">
      <p>Clicked: {String(clicked)}</p>
      <p>Counter: {counter}</p>
      <button type="button" onClick={() => setClicked(true)}>
        Click me
      </button>
    </div>
  );
}

Try using the functional setState, setState((state, props) => stateChange)

useEffect(() => {
  if (clicked) {
    setCounterAndSetUrlHash(counter => counter + 1);
    setTimeout(() => {
      setClicked(false);
    }, 2000);
  }
}, [clicked]);

To solve the issue of the onhashchange callback using the first counter value I suggest to move the functionality to the callback of setCounter. This would also imply that you need a different function for the button and the hash change.

Also set the variables and useState definitions at the top, and after useEffect which can make use of them. If you want a useEffect to run only once, set an empty array of dependencies; leaving out dependecies will run on every render.

export const App = () => {
    const [clicked, setClicked] = useState(false);
    const [counter, setCounter] = useState(0);

    /**
     * Listen for clicked changes. When clicked changes to true,
     * allow setCounterAndSetUrlHash to do it's thing, before accpeting
     * the next click. So the clicked flag is simply a valve, that opens
     * after two seconds.
     */
    useEffect(() => {
        if (clicked) {
            setCounter(counter => {
                const value = counter + 1;

                if (value === 0) {
                    window.location.hash = "";
                } else {
                    window.location.hash = String(value);
                }

                return value;
            });

            setTimeout(() => {
                setClicked(false);
            }, 2000);
        }
    }, [clicked]);

    /**
     * Listen for changes in the URL hash. When the user presses
     * the back button in the browser toolbar, decrement the
     * counter value.
     */
    useEffect(() => {
        window.onhashchange = e => {
            const value = Number(window.location.hash.replace("#", ""));

            // must be number
            if (typeof value === "number" && value % 1 === 0) {
                setCounter(counter => {

                    if (counter - 1 !== value) {
                        return counter;
                    }

                    if (value === 0) {
                        window.location.hash = "";
                    } else {
                        window.location.hash = String(value);
                    }

                    return value;
                });
            }
        };
    }, []);

    return (
        <div className="App">
            <p>Clicked: {String(clicked)}</p>
            <p>Counter: {counter}</p>
            <button type="button" onClick={() => setClicked(true)}>
                Click me
            </button>
        </div>
    );
};

Add counter to the first useEffect():

  const [counter, setCounter] = useState(0);

  useEffect(() => {
    if (clicked) {
      setCounterAndSetUrlHash(counter + 1);
      setTimeout(() => {
        setClicked(false);
      }, 2000);
    }
  }, [clicked, counter]);

https://codesandbox.io/s/cold-sun-56u7j