Synchronizing timers in React | Karol Działowski

Synchronizing timers in React

Problem statement

This blog post would be helpful if you have two components in different parts of your app that trigger some action on a given interval, but those two intervals should be synchronized.

Now take a closer look. Timing of button color change is slightly different than timing of progress bar. We'd like to have them synchronized like below.

Show me the code. I don't have time for reading all of this.
Sure: https://github.com/karlosos/synchronized-timers-react

Initial code

I assume that you're already using the useIntervalHook for setting up intervals. If not see: useInterval hook on usehooks-ts.

This is how our code for flashing button component (background changing element):

const FlashingLight = () => {
  const [isOn, setIsOn] = useState(false)
  const [index, setIndex] = useState(0)

  const colors = [
    "bg-red-500 shadow-red-500/50",
    "bg-blue-500 shadow-blue-500/50",
    "bg-green-500 shadow-green-500/50",
    "bg-pink-500 shadow-pink-500/50",
    "bg-purple-500 shadow-purple-500/50",
  ]

  const changeColor = () => {
    setIndex(index => (index + 1) % colors.length)
  }

  useInterval(
    () => {
      changeColor()
    },
    isOn ? 1000 : null,
  )

  return (
    <button
      className={`text-white font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 transition-colors shadow-lg ${colors[index]}`}
      onClick={() => {
        if (!isOn) {
          changeColor()
        }
        setIsOn(isOn => !isOn)
      }}
    >
      {isOn ? "Stop" : "Start"}
    </button>
  )
}

As you can see, we have isOn state and useInterval which changes the background color every 1 second if the isOn state is true.

Similarly, we have progress bar component:

const BOXES_COUNT = 10

const ProgressIndicator = () => {
  const [progress, setProgress] = useState(0)

  const updateProgress = () => {
    setProgress(index => (index + 1) % BOXES_COUNT)
  }

  useInterval(() => {
    updateProgress()
  }, 1000)

  return (
    <div className="flex gap-1">
      {[...Array(progress)].map(() => (
        <div className="w-4 h-4 bg-black border border-black" />
      ))}
      {[...Array(BOXES_COUNT - progress)].map(() => (
        <div className="w-4 h-4 bg-white border border-black" />
      ))}
    </div>
  )
}

This component always updates its state every other second.

Implementing useSynchronizedInterval

We could solve the issue by moving the state into the parent component, but it's not an ideal solution. Our components are perfectly encapsulated now, and we don't want to leak their state. In many situations moving state up is not feasible.

To overcome it, we introduce a new custom hook useSynchronizedInterval which creates buckets. Each bucket handles an interval that calls every callback from that bucket.

buckets

buckets

We will create a new use-synchronized-interval.ts file. Firstly define the buckets:

const buckets = {}

We need a function that setups a bucket if it doesn't exist, creates an interval and returns the created (or existing) bucket.

const setupBucket = (delay: number) => {
  let bucket = buckets[delay]
  if (!bucket) {
    bucket = {
      callbacks: [],
      delay,
      interval: setInterval(() => {
        bucket.callbacks.forEach(f => {
          f.current()
        })
      }, delay),
    }
    buckets[delay] = bucket
  }
  return bucket
}

Then we implement functions for adding/removing functions from buckets. Remember to clear interval when last function is removed from the bucket.

const addToIntervalBucket = function (delay: number, callback) {
  const bucket = setupBucket(delay)
  bucket.callbacks = [...bucket.callbacks, callback]
}

const removeFromIntervalBucket = function (delay: number, callback) {
  const bucket = setupBucket(delay)
  bucket.callbacks = bucket.callbacks.filter(c => c !== callback)
  if (bucket.callbacks.length === 0) {
    clearInterval(bucket.interval)
    delete buckets[delay]
  }
}

Lastly, we can wrap the functionality into a custom hook:

export const useSynchronizedInterval = (fn, delay: number | null) => {
  const callback = useRef(fn)

  useEffect(() => {
    callback.current = fn
  }, [fn])

  useEffect(() => {
    if (delay !== null) {
      addToIntervalBucket(delay, callback)

      return () => {
        removeFromIntervalBucket(delay, callback)
      }
    } else {
      return
    }
  }, [delay])
}

Complete use-synchronized-interval.ts with typescript types:

import { useEffect, useRef } from "react"

type IntervalFn = () => void
type Callback = React.MutableRefObject<IntervalFn>
type Bucket = {
  delay: number
  callbacks: Callback[]
  interval: number
}
type Buckets = Record<number, Bucket>

const buckets: Buckets = {}

const setupBucket = (delay: number): Bucket => {
  let bucket = buckets[delay]
  if (!bucket) {
    bucket = {
      callbacks: [],
      delay,
      interval: setInterval(() => {
        bucket.callbacks.forEach(f => {
          f.current()
        })
      }, delay),
    }
    buckets[delay] = bucket
  }
  return bucket
}

const addToIntervalBucket = function (delay: number, callback: Callback) {
  const bucket = setupBucket(delay)
  bucket.callbacks = [...bucket.callbacks, callback]
}

const removeFromIntervalBucket = function (delay: number, callback: Callback) {
  const bucket = setupBucket(delay)
  bucket.callbacks = bucket.callbacks.filter(c => c !== callback)
  if (bucket.callbacks.length === 0) {
    clearInterval(bucket.interval)
    delete buckets[delay]
  }
}

export const useSynchronizedInterval = (
  fn: IntervalFn,
  delay: number | null,
) => {
  const callback = useRef<IntervalFn>(fn)

  useEffect(() => {
    callback.current = fn
  }, [fn])

  useEffect(() => {
    if (delay !== null) {
      addToIntervalBucket(delay, callback)

      return () => {
        removeFromIntervalBucket(delay, callback)
      }
    } else {
      return
    }
  }, [delay])
}

Using hook in components

Now, instead of using useInterval, we should use useSynchronizedInterval, which solves the initial problem of desynced intervals.

Summary

This hook can be used when we need to synchronize intervals in multiple components. If you need to have separate buckets with the same delay values, then you can introduce id parameter: useSynchronizedInterval(fn, delay, id).

© karlosos 2020 Open sourced on