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