0

I am trying to create a timer in react, using setTimeout and useState.

The runTimer function shown below is responsible for keeping track of time and it is supposed to stop calling itself if the state variable timerRunning is false.

If I click on the Start button and later on the Stop button, I see this does not work and the counter continues to increase.

As I logged the values of timmerRunning and seconds from the body of the App component and from the runTimer function, I noticed they are different. The value of timerRunning in App is different from the value of timerRunning in runTimer, and the same is true for seconds.

Code:

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

export default function App() {
  const [timerRunning, setTimerRunning] = useState(false);
  const [seconds, setSeconds] = useState(0);

  console.log("App body --------------");
  console.log({ timerRunning, seconds });

  function runTimer() {
    console.log("*********** runTimer **********");
    console.log({ timerRunning, seconds });
    if (timerRunning) {
      setTimeout(() => {
        setSeconds((prev) => prev + 1);
        runTimer();
      }, 1000);
    }
  }

  const startTimer = () => {
    console.log("startTimer");
    setTimerRunning(true);
    runTimer();
  };
  const stopTimer = () => {
    // Complete this function
    console.log("stopTimer");
    setTimerRunning(false);
  };
  const resetTimer = () => {
    // Complete this function
    console.log("resetTimer");
    setTimerRunning(false);
    setSeconds(0);
  };
  return (
    <div className="container">
      <h1>Timer</h1>
      <span> 0 mins </span>
      <span> {seconds} secs</span>
      <div>
        <button onClick={startTimer}>Start</button>
        <button onClick={stopTimer}>Stop</button>
        <button onClick={resetTimer}>Reset</button>
      </div>
    </div>
  );
}

Here is an excerpt from the console:

index.js:27 App body --------------
index.js:27 {timerRunning: true, seconds: 9}
index.js:27 *********** runTimer **********
index.js:27 {timerRunning: true, seconds: 0}
index.js:27 App body --------------
index.js:27 {timerRunning: true, seconds: 10}
index.js:27 App body --------------
index.js:27 {timerRunning: true, seconds: 10}
index.js:27 stopTimer
index.js:27 App body --------------
index.js:27 {timerRunning: false, seconds: 10}
index.js:27 App body --------------
index.js:27 {timerRunning: false, seconds: 10}
index.js:27 *********** runTimer **********
index.js:27 {timerRunning: true, seconds: 0}
index.js:27 App body --------------
index.js:27 {timerRunning: false, seconds: 11}
index.js:27 App body --------------
index.js:27 {timerRunning: false, seconds: 11}
index.js:27 *********** runTimer **********
index.js:27 {timerRunning: true, seconds: 0}
index.js:27 App body --------------
index.js:27 {timerRunning: false, seconds: 12}
index.js:27 App body --------------
index.js:27 {timerRunning: false, seconds: 12}

As you can see I clicked on Stop and triggered stopTimer after 10 seconds. After that App sees timerRunning as false, whereas runTimer sees it as true. This causes runTimer to keep actively calling setTimeout.

See a sandbox here: https://codesandbox.io/s/hooks-usestate-timer-q-forked-issue-with-state-variable-f9i0eq?file=/src/App.js:0-1231

Why does runTimer have a separate instance of seconds and timerRunning? What is the proper way of implementing a timer?

2 Answers 2

1

First of all this won't work as you expect:

 const startTimer = () => {
    console.log('startTimer');
    setTimerRunning(true);
    runTimer();
  };

timerRunning doesn't get updated immediately, hence inside runTimer you won't see its recent state after one click.

Apart from that, When runTimer runs, you schedule another run inside timeout:

  function runTimer() {
    console.log("*********** runTimer **********");
    console.log({ timerRunning, seconds });
    if (timerRunning) {
      setTimeout(() => {
        setSeconds((prev) => prev + 1);
        runTimer(); // --> this one
      }, 1000);
    }
  }

but the runTimer you are referring to inside timeout is from current render. Which means variables/functions it will refer to inside will also be from current render. That's why inside it will always observe value of timerRunning which it had during invocation. This is a stale closure problem you can read about it more online.

Try using useRef for storing timerRunning, that should fix it:

  let timerRunning = useRef(false);
  // timerRunning.current = false etc.
Sign up to request clarification or add additional context in comments.

2 Comments

Thanks Giorgi. I have played with useRef and got to a solution that ALMOST works: codesandbox.io/s/… Two problems with this: 1. runTimer still has its own stale version of the seconds variable with a value of 0, although it is indeed updating the value correctly with setSeconds(prev => prev +1) and 2. The Reset button sets the seconds to 0, but the counter is then increased by 1 and it stays at 1. I cannot visualise why this happens.
@freenrg which seconds variable do you refer to? this one: console.log({ timer, seconds });? that is also stale closure, so it will be like that if you don't remove it from code. 2. For 2nd problem move the if (timer.current.isRunning) condition inside the setTimeout, and wrap the setTimeout contents with it.
0

Credit to Giorgi Moniava for this solution as he suggested the use of useRef in his answer:

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

export default function App() {
  // const [timerRunning, setTimerRunning] = useState(false);
  let timer = useRef({ isRunning: false });
  const [seconds, setSeconds] = useState(0);

  console.log("App body --------------");
  console.log({ timer, seconds });

  function runTimer() {
    console.log("*********** runTimer **********");
    console.log({ timer, seconds });
    if (timer.current.isRunning) {
      setTimeout(() => {
        setSeconds((prev) => prev + 1);
        runTimer();
      }, 1000);
    }
  }

  const startTimer = () => {
    console.log("startTimer");
    timer.current.isRunning = true;
    runTimer();
  };
  const stopTimer = () => {
    // Complete this function
    console.log("stopTimer");
    timer.current.isRunning = false;
  };
  const resetTimer = () => {
    // Complete this function
    console.log("resetTimer");
    setSeconds(0);
    timer.current.isRunning = false;
  };
  return (
    <div className="container">
      <h1>Timer</h1>
      <span> 0 mins </span>
      <span> {seconds} secs</span>
      <div>
        <button onClick={startTimer}>Start</button>
        <button onClick={stopTimer}>Stop</button>
        <button onClick={resetTimer}>Reset</button>
      </div>
    </div>
  );
}

Link to sandbox with this solution

The counter will start and stop correctly.

However, please note the above solution has two issues:

  1. The runTimer still has its own stale version of the seconds state variable. Its value corresponds to the value of the seconds state variable at the time the "Start" button is clicked. Meanwhile, the App component's seconds variable is indeed updating its value every second, so setSeconds((prev) => prev + 1) inside runTimer is doing its work.

  2. The Reset button will set seconds to 0, but then it seems one previously scheduled setTimeout runs and increases the seconds to 1.

I found another solution that is based on setInterval, clearInterval and a window.timer variable in local storage.

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

export default function App() {
  const [seconds, setSeconds] = useState(0);
  const startTimer = () => {
    window.timer = setInterval(() => {
      setSeconds((prevSeconds) => prevSeconds + 1);
    }, 1000);
  };
  const stopTimer = () => {
    clearInterval(window.timer);
  };
  const resetTimer = () => {
    setSeconds(0);
    clearInterval(window.timer);
  };
  return (
    <div className="container">
      <h1>Timer</h1>
      <span> {Math.floor(seconds / 60)} mins </span>
      <span> {seconds % 60} secs</span>
      <div>
        <button onClick={startTimer}>Start</button>
        <button onClick={stopTimer}>Stop</button>
        <button onClick={resetTimer}>Reset</button>
      </div>
    </div>
  );
}

Link to sandbox with this solution

This seems to work without any issues. Any drawbacks with this solution?

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.