Anthony
Anthony

Reputation: 388

My useEffect effect is running but I'm not sure why - dependency array value (seemingly) isn't changing

I'm learning about React hooks. One task to practice using the useRef and useEffect hooks was to build a "click counting" game. The game has a timer (which is powered by useEffect and setIinterval) that counts down from 10, and a state variable counts how many times you are able to click in that set amount of time.

I wanted to above and beyond and keep exploring so I wanted to add a button that would "reset" the game. I found that I had to add a state value to track whether the game is "active" or not (the game is not active when the countdown timer reaches 0). In order for the reset functionality to work I had to list this state value (called gameIsActive) in the useEffect dependency array. When the countdown timer reaches zero, the gameIsActive variable is switched from its default value of true to false, and clicking the reset button toggles it back to true, as well as resetting the other relevant state values (click count goes back to zero, timer goes back to 10, in this case).

What I'm struggling to understand is why this works. From the React docs on useEffect it would seem that adding gameIsActive to the dependency array should keep the effect from running, because during the game the value of gameIsActive does not change... The relevant wording in the docs:

In the example above, we pass [count] as the second argument. What does this mean? If the count is 5, and then our component re-renders with count still equal to 5, React will compare [5] from the previous render and [5] from the next render. Because all items in the array are the same (5 === 5), React would skip the effect. That’s our optimization.

When we render with count updated to 6, React will compare the items in the [5] array from the previous render to items in the [6] array from the next render. This time, React will re-apply the effect because 5 !== 6. If there are multiple items in the array, React will re-run the effect even if just one of them is different.
import React, { useState, useEffect, useRef } from "react";
import ReactDOM from "react-dom";

import "./styles.css";

function CounterGame() {
  const [clickCount, setClickCount] = useState(0);
  const [timeRemaining, setTimeRemaining] = useState(10);
  const [gameIsActive, setGameIsActive] = useState(true);
  const id = useRef(null);

  const handleClick = () => {
    setClickCount((clickCount) => clickCount + 1);
  };

  const handleReset = () => {
    setTimeRemaining(10);
    setClickCount(0);
    setGameIsActive(true);
  };

  const clearInterval = () => {
    window.clearInterval(id.current);
  };

  useEffect(() => {
    id.current = window.setInterval(() => {
      setTimeRemaining((timeRemaining) => timeRemaining - 1);
    }, 1000);

    return clearInterval;
    // If gameIsActive is ommitted from the dependency array
    // countdown timer will not restart when game is "reset"
  }, [gameIsActive]);

  useEffect(() => {
    if (timeRemaining === 0) {
      clearInterval();
    }

    setGameIsActive(false);
  }, [timeRemaining]);

  return (
    <div className="App">
      <h3>
        Time remaining (secs):
        {timeRemaining}
      </h3>
      <h3>
        Click Count:
        {clickCount}
      </h3>
      <button onClick={handleClick} disabled={!timeRemaining}>
        Click Me
      </button>
      <button onClick={handleReset}>Reset Game</button>
    </div>
  );
}

const rootElement = document.getElementById("root");
ReactDOM.render(<CounterGame />, rootElement);

If I exclude the gameIsActive from the dependency array on the first useEffect hook, the reset will not work if the counter hits zero. I'm operating on the guess that this is because when the timer hits zero, I clear the interval, and never re-instantiate it. Adding the gameIsActive state seemed necessary to trigger the effect to set another interval, so it makes sense when I go from false (when the timer hits zero) back to true (when I reset the "game"). But why does the effect run every time the timer ticks? I'm especially confused given I had to use the useRef hook to persist the interval ID from render to render, and that logic is occurring in the same hook.

Here is a link to a working codesandbox of the issue - you'll see that removing the gameIsActive value in the first useEffect's dependency array will cause the game to no longer work after the timer hits zero (although things will reset and start counting down if you click reset BEFORE the timer hits zero).

Upvotes: 0

Views: 377

Answers (2)

Maurice
Maurice

Reputation: 357

So what is currently happening:

Whenever the value of gameIsActive changes, the useEffect hook is executed. This means, that it is actually executed on the rising edge and the falling edge of the value change.

Now, as soon as your game starts, the gameIsActive value is initialized with true. However, as soon as there is the first game tick, you set the value to false. What is happening in the background is, that the interval gets cleared and initialized once again (it gets cleared due to the cleanup function you return in the hook) but you will not notice this, as the counter is unaffected by this.

As soon as the counter ticks to zero, you stop the interval in your second hook. Other than that, nothing happens as the gameIsActive state is already false.

Now, as you execute the reset function, you change the value of gameIsActive, leading to a state update and the execution of the useEffect hook. And again, you then instantly set the game gameIsActive value back to false.

This solution is not optimal. What you rather want to do is to define a dedicated startGame function, which will initialize the interval. You can then call this function once the game starts up, using a useEffect hook without any dependencies.

When you reset the game, you would then simply run the startGame function to restart the timer.

If you do it this way, you also don't ned to return a function from the useEffect hook. Instead, (to avoid concurrent intervals running) you would want to execute the clearInterval function as a first statement in your startGame function, to clear any potentially existing intervals.

Upvotes: 1

topched
topched

Reputation: 775

It works because on the first tick of the timer -- it hits your useEffect (the time isnt 0) but it sets gameIsActive to false. Once you click reset it sets it back to true -- which triggers the useEffect to restart the timer. and so on and so forth.

Check out what happens when you comment out setGameIsActive(false); and you try and reset it -- notice it doesnt reset?

Upvotes: 1

Related Questions