DEV Community

Radu Breahna
Radu Breahna

Posted on

JavaScript Timer with React Hooks

This post is a part of a series about the ins and outs of chroniker.co

The main focus of chroniker.co is time tracking. Thus a way to accurately track time was necessary. This might seem like a trivial problem that can be solved with setTimeout or setInterval. However, things are not that simple with these functions as you will soon find out.

The code I used to track time on this website has changed significantly over time. I started with a simple react hook that used a setInterval and it worked. It was't fancy, and it was accurate when compared to a stopwatch. But when you leave it on for long periods of time, strange things begin to happen.

It becomes VERY inaccurate. If you set setInterval to fire every second, it will absolutely not do that precisely. Sometimes it will wait, sometimes it will be on point. And the result you get is an error that grows with each iteration. There is a great article that goes into detail about this issue.

Another thing to note is what happens to a setInterval when the browser tab in which it is running is inactive/not focused. The browser will re-route its resources to the focused tab, leaving setInterval running with great delays. It will also default to once per second even if you set it to run more frequently. Similar things happen when the computer goes into sleep mode. This is very inconvenient if your website is built around time tracking.

Bottom line is: NEVER trust that setInterval will run your code at exactly the interval you specified.

The solution to this problem comes in many shapes and sizes. However they all have one thing in common: Use the absolute value of Date(). The idea is to save the time before the interval starts and when it executes. That way you can subtract one from another and get the actual interval. You can then adjust either the interval or the logic that consumes it to get some accurate readings.

For example:

const doSomething = () => {
//your code
}

setInterval(() => {
  doSomething();
}, 1000);
Enter fullscreen mode Exit fullscreen mode

The above code tries to run doSomething every second, so it's easy to predict what the absolute time after it finishes should be:

new Date().getTime() + 1000;
Enter fullscreen mode Exit fullscreen mode

However in reality the absolute time when this interval ends will always vary because of the aforementioned reasons and you will have a new time that is either larger or smaller than your prediction.
By subtracting one from another you will get the variation that you need to factor into your timekeeping calculations.

Even with this method your 'clock' will not be accurate to the millisecond like a typical digital stopwatch, however it will be very close. It will start to drift only when counting large intervals of time in the order of days. This type of accuracy was sufficient for my purpose.

When I come across pieces of logic like this I always try to package them into a react hook. Here is what I came up with:


import { useEffect, useRef, useState } from 'react';

const usePreciseTimer = (handler, periodInMilliseconds, activityFlag) => {
  const [timeDelay, setTimeDelay] = useState(1);
  const savedCallback = useRef();
  const initialTime = useRef();

  useEffect(() => {
    savedCallback.current = handler;
  }, [handler]);

  useEffect(() => {
    if (activityFlag) {
      initialTime.current = new Date().getTime();
      const id = setInterval(() => {
        const currentTime = new Date().getTime();
        const delay = currentTime - initialTime.current;
        initialTime.current = currentTime;
        setTimeDelay(delay / 1000);
        savedCallback.current(timeDelay);
      }, periodInMilliseconds);

      return () => {
        clearInterval(id);
      };
    }
  }, [periodInMilliseconds, activityFlag, timeDelay]);
};

export default usePreciseTimer;



Enter fullscreen mode Exit fullscreen mode

First let me explain the hook definition:

usePreciseTimer = (handler, periodInMilliseconds, activityFlag)

Enter fullscreen mode Exit fullscreen mode

This hook expects us to pass it a handler, something it can run every interval - periodInMilliseconds, and it should only run it if the activityFlag evaluates to true. Because I need to display the total elapsed time, I also pass the expired time to the handler so that it can add it to the current elapsed time.

 useEffect(() => {
    savedCallback.current = handler;
  }, [handler]);

Enter fullscreen mode Exit fullscreen mode

Here I make use of the builtin useRef hook to make sure I update the local handler if it ever changes.

if (activityFlag) {
      initialTime.current = new Date().getTime();
      const id = setInterval(() => {
        const currentTime = new Date().getTime();
        const delay = currentTime - initialTime.current;
        initialTime.current = currentTime;
        setTimeDelay(delay / 1000);
        savedCallback.current(timeDelay);
      }, periodInMilliseconds);

      return () => {
        clearInterval(id);
      };
    }
Enter fullscreen mode Exit fullscreen mode

This is where the main logic is executed. If the activityFlag is true, we first save the current time as a reference. Once periodInMilliseconds elapses, our setInterval should start executing. At this point we take another measurement.

We subtract our start time form the real absolute time ending up with a delay. The resulting delay is the actual elapsed time, it can be larger or smaller than periodInMilliseconds.

We then convert the delay time into seconds and pass it to our handler in order to tell it how much time has passed, but not before updating the initialTime with the new currentTime for the next cycle.

return () => {
        clearInterval(id);
      };
Enter fullscreen mode Exit fullscreen mode

Here we cleanup the setInterval when the hook gets unmounted.

Lastly here is the actual way this hook is used:

 usePreciseTimer(updateTime, 1000, state.isActive);

Enter fullscreen mode Exit fullscreen mode

You can check the accuracy live on chroniker.co

I am curious on how to further improve this, let me know what you think :)

Top comments (0)