Implementation of a universal timer

Labeeb Latheef

Labeeb Latheef

March 26, 2024

Implementation of a universal timer

When developing a web application, there could be numerous instances where we deal with timers. The timer functions such as setTimeout, and setInterval are basic browser APIs that all web developers are well acquainted with. When trying to implement something like a self-advancing timer, these timer APIs make the job easy.

Let's consider a simple use case. In React, if we are asked to implement a countdown timer that updates the time on the screen every second, we can use the setInterval method to get the job done.

1const CountDownTimer = () => {
2  const [time, setTime] = useState(10);
3
4  useEffect(() => {
5    const interval = setInterval(() => {
6      setTime(time => {
7        if (time > 0) return time - 1;
8
9        clearInterval(interval);
10        return time;
11      });
12    }, 1000); // Run this every 1 second.
13  }, []);
14
15  return <p>Remaining time: {time}</p>;
16};

This works great if we are only expecting to show a single timer on the page. What if we have to show multiple timers running on the same page?

Multiple timers

In the conversation page of our NeetoChat application, when listing each message in a conversation, we annotate each message with a "time-ago" label. This label indicates the duration since the message was received and is expected to self-advance with passing time.

NeetoChat timestamp NeetoChat timestamp

Normally, our first take on such implementation would be to use a setInterval timer inside the message component, which triggers the component to re-render every second to update the label. This becomes highly inefficient when we have hundreds of messages to be rendered on the screen at the same time.

The browser ends up running separate timers for each message to update their label. Also, due to their asynchronous behavior, there is a higher chance that these timer events get stuck in the JS event loop and get fired at inappropriate moments or get dropped altogether.

Using a single timer

An alternate approach could be to keep a single timer and a state on the message listing parent component. Then update the state on every passing second, and trigger the entire list re-render. The obvious downside of this approach is rerendering a large conversation list and its children every single second. This is highly inappropriate and leads to unexpected stutter and other performance issues.

What we wanted to achieve was to use a single timer that updates a single state, triggering the re-render of all the components that needs to be updated. In case of NeetoChat conversations, we needed to update "time-ago" labels alone, not the entire message component or any of its parent.

React's Context API was the most appropriate choice at the time for this task. The Context API offers a simple way of sharing states or values across different components. Whenever the value or the state changes, all its subscribed components are immediately notified of the change and trigger a re-render. To use this approach, first, we extracted the timer and the state to a Context. Then, all the components that need to be updated with time are subscribed to this context value. The timer updates the context value and the subscribed components get rerendered.

1import React, {
2  createContext,
3  useEffect,
4  useRef,
5  useMemo,
6  useCallback,
7} from "react";
8
9const IntervalContext = createContext({});
10const defaultClockDelay = 10 * 1000; // 10 seconds
11
12export const IntervalProvider = ({ children }) => {
13  const subscriptions = useRef(new Map()).current;
14
15  useEffect(() => {
16    const interval = setInterval(() => {
17      const now = Date.now();
18      for (const subscription of subscriptions.values()) {
19        // Check if delay is elapsed
20        if (now < subscription.time) return;
21        subscription.callback(now);
22        // Set next callback time for the subscription.
23        subscription.time = now + subscription.delay;
24      }
25    }, defaultClockDelay);
26
27    return () => {
28      clearInterval(interval);
29    };
30  }, [subscriptions]);
31
32  const subscribe = useCallback(
33    (callback, delay = defaultClockDelay) => {
34      if (typeof callback !== "function") return undefined;
35      const subscription = { callback, delay, time: Date.now() + delay };
36      subscriptions.set(subscription, subscription);
37
38      //unsubscribe callback
39      return () => subscriptions.delete(subscription);
40    },
41    [subscriptions]
42  );
43
44  const contextValue = useMemo(() => ({ subscribe }), [subscribe]);
45
46  return (
47    <IntervalContext.Provider value={contextValue}>
48      {children}
49    </IntervalContext.Provider>
50  );
51};
52
53export default IntervalContext;

The above context exposes a subscribe method that accepts a callback and a delay, which is added to the list of subscriptions. During each interval, we are iterating through the list of subscriptions and will invoke those callbacks for which the specified delay has elapsed.

To integrate this universal timer into the individual components easily, we have also added a hook that wraps around the common subscription and cleanup logic.

1import { useContext, useEffect, useState } from "react";
2
3import IntervalContext from "contexts/interval";
4
5const useInterval = delay => {
6  const [state, setState] = useState(Date.now());
7
8  const { subscribe } = useContext(IntervalContext);
9
10  useEffect(() => {
11    const unsubscribe = subscribe(now => setState(now), delay);
12
13    return unsubscribe;
14  }, [delay, subscribe]);
15
16  return state;
17};
18
19export default useInterval;

Now, the component integration require only minimal configuration.

1import { timeFormat } from "neetocommons/utils";
2
3const TimeAgo = () => {
4  useInterval(10000); // Rerender every 10 seconds
5
6  // timeFormat.fromNow() returns the time
7  // difference between given time and now.
8  return <p>{timeFormat.fromNow(time)}</p>;
9};

This way only the "time-ago" label components are updated every 10 seconds while the parent message components remain unaffected by these updates.

Using a global store

As soon as that work was finished our development guidelines were updated to reflect that we should use zustand for all shared state usages. The above universal timer implementation was refactored to use a zustand store instead of React Context.

1import { useEffect, useMemo } from "react";
2
3import { isEmpty, omit, prop } from "ramda";
4import { v4 as uuid } from "uuid";
5import { create } from "zustand";
6
7const useTimerStore = create(() => ({}));
8
9// Interval is created directly inside the module body,
10// outside the components and hooks.
11setInterval(() => {
12  const currentState = useTimerStore.getState();
13  const nextState = {};
14  const now = Date.now();
15
16  for (const key in currentState) {
17    const { lastUpdated, interval } = currentState[key];
18    // Check if delay is elapsed.
19    const shouldUpdate = now - lastUpdated >= interval;
20    if (shouldUpdate) nextState[key] = { lastUpdated: now, interval };
21  }
22
23  if (!isEmpty(nextState)) useTimerStore.setState(nextState);
24}, 1000);
25
26// `useInterval` was changed to `useTimer`.
27const useTimer = (interval = 60) => {
28  const key = useMemo(uuid, []);
29
30  useEffect(() => {
31    useTimerStore.setState({
32      [key]: {
33        lastUpdated: Date.now(),
34        interval: 1000 * interval, // convert seconds to ms
35      },
36    });
37
38    return () =>
39      useTimerStore.setState(omit([key], useTimerStore.getState()), true);
40  }, [interval, key]);
41
42  return useTimerStore(prop(key));
43};
44
45export default useTimer;

zustand store allows access and updates to store values imperatively, outside the render by calling the getState() and setState() methods.

An improved version

In the latest iteration of useTimer hook, we decided to cut down on the external dependency zustand and instead migrate the implementation to use React's new useSyncExternalStore hook. The useSyncExternalStore hook basically allows you to derive a React state from external change events.

1import { useRef, useSyncExternalStore } from "react";
2
3import { isNotEmpty } from "neetocist";
4
5const subscriptions = [];
6let interval = null;
7
8const initiateInterval = () => {
9  // Create new interval if there are no existing subscriptions.
10  if (isNotEmpty(subscriptions)) return;
11  interval = setInterval(() => {
12    subscriptions.forEach(callback => callback());
13  }, 1000);
14};
15
16const cleanupInterval = () => {
17  // Cleanup existing interval if there are no more subscriptions
18  if (isNotEmpty(subscriptions)) return;
19  clearInterval(interval);
20};
21
22const subscribe = callback => {
23  initiateInterval();
24  subscriptions.push(callback);
25
26  // Runs on unmout. Remove subscription from the list.
27  return () => {
28    subscriptions.splice(subscriptions.indexOf(callback), 1);
29    cleanupInterval();
30  };
31};
32
33const useTimer = (delay = 60) => {
34  const lastUpdatedRef = useRef(Date.now());
35
36  return useSyncExternalStore(subscribe, () => {
37    const now = Date.now();
38    let lastUpdated = lastUpdatedRef.current;
39    // Calculate the time difference to derive new state
40    // If specified delay elapsed, return new value for the state. If not, return last value (no state change)
41    if (now - lastUpdated >= delay * 1000) lastUpdated = now;
42    lastUpdatedRef.current = lastUpdated;
43
44    return lastUpdated;
45  });
46};

In summary, when useTimer hook is invoked with a delay, the callback is added to the list of subscriptions and executed when the specified delay has elapsed. On unmount, the subscription is removed from the list of subscriptions. In contrast to previous versions, the new version is much cleaner and has the added benefit of running the interval timer only when required. The timer is added only when the first subscription is added and removed when all subscriptions have been completed.

If this blog was helpful, check out our full blog archive.

Stay up to date with our blogs.

Subscribe to receive email notifications for new blog posts.