'Start and Stop a Timer with Recursive setTimeout

I am trying to start and stop a timer with a recursive setTimeout, since setInterval doesn't fit my need. When the user presses start, the timer starts counting.

1...2...3...4..5...1...2...3..4...5...1..2...3..4...5...

And when the user presses stop, it should stop this timer loop. If I press start again, changing the state of the button (stream variable), the timer should do the same thing again (1..2..3..4..5..1..2...3..4...5..)

I thought the way to do it would be this.


const ref = useRef(null);
useEffect(() => {
  if (!stream) {
    ref.current=setTimeout(repeatingFunc, 1000);

  } else {
    clearTimeout(ref.current);
  }
}, [stream]);

function repeatingFunc() {
  console.log("It's been 5 seconds. Execute the function again.");
  setTimeout(repeatingFunc, 5000);
}

What am I doing wrong?

The timer doesn't stop. It keeps going even if I press stop! (changing the stream state value!)



Solution 1:[1]

The reason why the timer doesn't stop is because you only store the timeout ID of the initial setTimeout call. When repeatingFunc is called another setTimeout callback is registered, which has a new timeout ID.

So when you try to clear the timeout using clearTimeout(ref.current) you are passing an outdated timeout ID.

Since the previous setTimeout registration is already called, you no longer need to store the previous ID and you could simply replace it with the new one.

const ref = useRef(null);

useEffect(() => {
  if (!stream) {
    ref.current = setTimeout(repeatingFunc, 1000);
  } else {
    clearTimeout(ref.current);
  }
}, [stream]);

function repeatingFunc() {
  console.log("It's been 5 seconds. Execute the function again.");
  ref.current = setTimeout(repeatingFunc, 5000);
  // ^^^^^^^^^^^^ update timeout ID after registering a new timeout
}

Note that the scope of repeatingFunc is fixed in a point in time where you initially call setTimeout(repeatingFunc, 1000), meaning that things like states and properties can contain outdated values. This might or might not become a problem depending on your context.


You might also want to add a useEffect cleanup function that clears the timeout on component dismount. Otherwise your loop keeps going, even when the component is no longer mounted.

useEffect(() => {
  if (!stream) {
    ref.current = setTimeout(repeatingFunc, 1000);
  }

  return () => clearTimeout(ref.current);
}, [stream]);

Assuming stream is a boolean (true/false) the above will clear the current timeout whenever the value of stream is changed, or on component dismount.

When stream changes from true to false, the cleanup function will be called and the timeout will be cleared.

When stream changes from false to true the cleanup function is also called, but results in a no-op because the current ref.current is no longer registered. Then a new timeout is set.

Passing an invalid ID to clearTimeout() silently does nothing; no exception is thrown.

See: MDN - clearTimeout()

Solution 2:[2]

You can define a custom timer like this:

function CustomTimer(func) {
    this.id = -1;
    this.func = func;

    this.start = function (interval) {
        this.stop();

        if (this.func) {
            var t = this;
            this.id = setTimeout(function () { t.func.call(t) }, interval || 1000);
        }
    }

    this.stop =  function () {
        if (this.id >= 0) {
            clearTimeout(this.id);
            this.id = -1;
        }
    }
}

And use it:

var myTimer = new CustomTimer(function () {
    console.log("It's been 5 seconds. Execute the function again.");
    this.start(5000);
});

myTimer.start(5000);

You can change the function to execute in any moment and also the interval. For example: run a start event in 1 second and then, 5 times every 5 seconds do other thing:

var secondFunc = function () {
    console.log("It's been 5 seconds. Execute the function again.");

    if (++this.count < 5)
        this.start(5000);
}

var myTimer = new CustomTimer(function () {
    console.log("First event at 1 second");

    this.count = 0;
    this.func = secondFunc;
    this.start(5000);
});

myTimer.start(1000);

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1
Solution 2