---
title: "Implementation of a universal timer"
description: "Control multiple UI components with a single timer."
canonical_url: "https://www.bigbinary.com/blog/universal-timer"
markdown_url: "https://www.bigbinary.com/blog/universal-timer.md"
---

# Implementation of a universal timer

Control multiple UI components with a single timer.

- Author: Labeeb Latheef
- Published: March 26, 2024
- Categories: JavaScript, ReactJS

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.

```jsx
const CountDownTimer = () => {
  const [time, setTime] = useState(10);

  useEffect(() => {
    const interval = setInterval(() => {
      setTime(time => {
        if (time > 0) return time - 1;

        clearInterval(interval);
        return time;
      });
    }, 1000); // Run this every 1 second.
  }, []);

  return <p>Remaining time: {time}</p>;
};
```

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](https://neetochat.com) 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](https://www.bigbinary.com/blog/images/images_used_in_blog/2024/universal-timer/neeto-chat-timestamp-2.png)
![NeetoChat timestamp](https://www.bigbinary.com/blog/images/images_used_in_blog/2024/universal-timer/neeto-chat-timestamp-1.png)

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 need 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](https://legacy.reactjs.org/docs/context.html) 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 into a Context. Then, all the components that need to be
updated over time are subscribed to this context value. The timer updates the
context value, and the subscribed components get rerendered.

```jsx
import React, {
  createContext,
  useEffect,
  useRef,
  useMemo,
  useCallback,
} from "react";

const IntervalContext = createContext({});
const defaultClockDelay = 10 * 1000; // 10 seconds

export const IntervalProvider = ({ children }) => {
  const subscriptions = useRef(new Map()).current;

  useEffect(() => {
    const interval = setInterval(() => {
      const now = Date.now();
      for (const subscription of subscriptions.values()) {
        // Check if delay is elapsed
        if (now < subscription.time) return;
        subscription.callback(now);
        // Set next callback time for the subscription.
        subscription.time = now + subscription.delay;
      }
    }, defaultClockDelay);

    return () => {
      clearInterval(interval);
    };
  }, [subscriptions]);

  const subscribe = useCallback(
    (callback, delay = defaultClockDelay) => {
      if (typeof callback !== "function") return undefined;
      const subscription = { callback, delay, time: Date.now() + delay };
      subscriptions.set(subscription, subscription);

      //unsubscribe callback
      return () => subscriptions.delete(subscription);
    },
    [subscriptions]
  );

  const contextValue = useMemo(() => ({ subscribe }), [subscribe]);

  return (
    <IntervalContext.Provider value={contextValue}>
      {children}
    </IntervalContext.Provider>
  );
};

export 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.

```javascript
import { useContext, useEffect, useState } from "react";

import IntervalContext from "contexts/interval";

const useInterval = delay => {
  const [state, setState] = useState(Date.now());

  const { subscribe } = useContext(IntervalContext);

  useEffect(() => {
    const unsubscribe = subscribe(now => setState(now), delay);

    return unsubscribe;
  }, [delay, subscribe]);

  return state;
};

export default useInterval;
```

Now, the component integration require only minimal configuration.

```jsx
import { timeFormat } from "neetocommons/utils";

const TimeAgo = () => {
  useInterval(10000); // Rerender every 10 seconds

  // timeFormat.fromNow() returns the time
  // difference between given time and now.
  return <p>{timeFormat.fromNow(time)}</p>;
};
```

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](https://github.com/pmndrs/zustand) for all
shared state usages. The above universal timer implementation was refactored to
use a zustand store instead of React Context.

```javascript
import { useEffect, useMemo } from "react";

import { isEmpty, omit, prop } from "ramda";
import { v4 as uuid } from "uuid";
import { create } from "zustand";

const useTimerStore = create(() => ({}));

// Interval is created directly inside the module body,
// outside the components and hooks.
setInterval(() => {
  const currentState = useTimerStore.getState();
  const nextState = {};
  const now = Date.now();

  for (const key in currentState) {
    const { lastUpdated, interval } = currentState[key];
    // Check if delay is elapsed.
    const shouldUpdate = now - lastUpdated >= interval;
    if (shouldUpdate) nextState[key] = { lastUpdated: now, interval };
  }

  if (!isEmpty(nextState)) useTimerStore.setState(nextState);
}, 1000);

// `useInterval` was changed to `useTimer`.
const useTimer = (interval = 60) => {
  const key = useMemo(uuid, []);

  useEffect(() => {
    useTimerStore.setState({
      [key]: {
        lastUpdated: Date.now(),
        interval: 1000 * interval, // convert seconds to ms
      },
    });

    return () =>
      useTimerStore.setState(omit([key], useTimerStore.getState()), true);
  }, [interval, key]);

  return useTimerStore(prop(key));
};

export 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 the `useTimer` hook, we decided to cut down on the
external dependency `zustand` and instead migrate the implementation to use
React's new
[`useSyncExternalStore`](https://react.dev/reference/react/useSyncExternalStore)
hook. The `useSyncExternalStore` hook basically allows you to derive a React
state from external change events.

```javascript
import { useRef, useSyncExternalStore } from "react";

import { isNotEmpty } from "neetocist";

const subscriptions = [];
let interval = null;

const initiateInterval = () => {
  // Create new interval if there are no existing subscriptions.
  if (isNotEmpty(subscriptions)) return;
  interval = setInterval(() => {
    subscriptions.forEach(callback => callback());
  }, 1000);
};

const cleanupInterval = () => {
  // Cleanup existing interval if there are no more subscriptions
  if (isNotEmpty(subscriptions)) return;
  clearInterval(interval);
};

const subscribe = callback => {
  initiateInterval();
  subscriptions.push(callback);

  // Runs on unmout. Remove subscription from the list.
  return () => {
    subscriptions.splice(subscriptions.indexOf(callback), 1);
    cleanupInterval();
  };
};

const useTimer = (delay = 60) => {
  const lastUpdatedRef = useRef(Date.now());

  return useSyncExternalStore(subscribe, () => {
    const now = Date.now();
    let lastUpdated = lastUpdatedRef.current;
    // Calculate the time difference to derive new state
    // If specified delay elapsed, return new value for the state. If not, return last value (no state change)
    if (now - lastUpdated >= delay * 1000) lastUpdated = now;
    lastUpdatedRef.current = lastUpdated;

    return lastUpdated;
  });
};
```

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.

## Links

- [Human page](https://www.bigbinary.com/blog/universal-timer)
