The Cursed useEffect I Wrote
We’ve all shipped hacky code at some point. I’m no different. This is from my work at Murf, building an AI voiceover studio with a timeline. I tend to avoid overengineering (hardly pragmatism, mostly me being lazy), so for the timeline feature, I built a simple container with three tracks, flexed my flexbox skills, and called it done.
Turns out, timelines are deceptively hard.
The Murf Studio has three tracks: AI voiceovers, videos, and audio/BGM. Each track renders waveforms or video sprites so users can scrub and align clips precisely. Nothing complex until someone says, “Can you zoom out a little bit?”
Welp, that’s what happened, and I had two days to ship. Before you see the dreaded useEffect, let’s walk through the
timeline basics.
How do timelines work?
At the core of the timeline is a simple idea: pixels per second, or pxPerSec. It controls how much time fits on the
screen.
100pxPerSecmeans 100 pixels on screen represents 1 second of audio/video- When you increase it, you zoom in (more detail, less duration visible)
- When you decrease it, you zoom out (less detail, more duration visible)
Everything depends on this value:
- canvas waveform rendering, which shows the correct segment of audio at the current zoom level
- video sprite positioning, which places the right thumbnails at the correct timestamps
- clip widths and alignment, which keep every block in sync with the timeline scale
So every time pxPerSec changes, a lot of things update. Some are cheap visual changes. Some are expensive rendering
and calculation work. Here is a quick interactive example:
50 px per second
The real problem
At first glance, it feels like a normal performance problem. Maybe the computations are slow? Maybe we need memoization? But that wasn’t it. The problem wasn’t only that some computations were expensive. The real problem was that they were happening too often.
Trackpad gestures and slider onChange handlers generate a ridiculous number of events. Even cheap computations become
expensive when you run them hundreds of times per second. The cherry on top: the DOM still needs to update in real time
as the value changes, or zooming feels broken.
I didn’t need faster code. I needed fewer updates.
The current setup looked roughly like this:
function Timeline({ pxPerSec }) {
return (
<Timeline>
<TimelineScale
pxPerSec={pxPerSec}
// Uses pxPerSec to space the second markers.
/>
<Track pxPerSec={pxPerSec}>
<TimelineBlock
pxPerSec={pxPerSec}
// Uses pxPerSec for visual width and position.
>
<Waveform
pxPerSec={pxPerSec}
// Slices waveform data based on pxPerSec.
// Adds extra values between samples when needed.
/>
</TimelineBlock>
</Track>
<Track pxPerSec={pxPerSec}>
<TimelineBlock
pxPerSec={pxPerSec}
// Same timeline math, different renderer.
>
<VideoSprite
pxPerSec={pxPerSec}
// Chooses and positions video thumbnails based on pxPerSec.
/>
</TimelineBlock>
</Track>
</Timeline>
)
}
Now imagine n blocks on each track. Each block triggers at least two kinds of work on every pxPerSec change:
- DOM width or position update
- Waveform or video data recalculation
That is roughly 2n updates per track, or about 6n updates overall.
Even with just 10 blocks, that is minimum ~60 pieces of work per single change. Now combine that with trackpad input firing tons of events per second. This quickly spirals out of control, especially when the studio is already running heavy features like a rich text editor, video preview, state diffing calculations, and synchronized audio and video playback.
What now?
This clearly wasn’t two days of work, but I still had to ship something. I needed a quick fix that would reduce the number of updates without turning into a huge refactor.
My ape brain had a simple idea:
Debounce it!
But how would debouncing work here? Debouncing means waiting until a burst of events has stopped before running the update. So do you debounce the value from these events? If you do that directly, the zoom feedback only shows after the user stops interacting.
Throttle sounds like the move, but the user in me wasn’t about to settle for anything under 60 fps on zoom interactions. And yeah, that bar is kinda unreal. Throttling limits how often work runs, but it still runs heavy work during the interaction. Big projects with tons of blocks means way more computation on every change, so even throttling starts to fall apart. End result? Not exactly the smooth UX you’d want.
Staring at VS Code late on a Friday night, I had an idea:
what if I cheat?
I cheat the updates. I cheat the system. I show users what they want most: a ridiculously fast UI that responds instantly. Then I delay the heavy work. The idea was to update only the visuals that make zoom feel responsive, like block widths and scale markers, and queue the expensive React updates until the user stops interacting.
Drum roll…
Presenting the cursed useEffect:
import { useEffect, useRef } from "react"
const useEffectDebounce = (effectHook, debounceEffect, dependencies, timeout = 500) => {
const debounceRef = useRef()
useEffect(() => {
clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => {
debounceEffect()
}, timeout)
effectHook()
return () => {
clearTimeout(debounceRef.current)
}
}, dependencies)
}
export default useEffectDebounce
It’s horrible, but it works.
In plain English, this hook runs effectHook() immediately on every change, then schedules debounceEffect() for
later. If a new change comes in before the timeout, the scheduled work is cancelled and scheduled again.
The idea is simple:
- Create a separate state value for
pxPerSec, let’s call itpxPerSecVisual - Drive it from slider and trackpad events
- Update this value immediately for visual feedback (block widths, scale markers, etc.)
The real pxPerSec is updated in a debounced way using useEffectDebounce. That means the expensive computations and
canvas work run after the user stops interacting.
We’ve effectively decoupled visuals from computation.
This does two things:
- Runs visual updates immediately (fast lane)
- Runs expensive updates later (slow lane)
Notice how the waveforms and sprites update only after I stop firing events, but the block widths and scale markers update immediately.
That’s the whole trick. The user first cares about feedback from the thing they’re doing right now. If that feedback isn’t snappy, the whole interaction starts feeling clunky, and the user gets frustrated before the actual result even shows up.
Reality check
Of course, this is not the best or cleanest solution.
Ideally, you would use a useDebouncedValue hook and derive a
clean debounced pxPerSec. In my case, I had a bunch of existing functions to run, so I needed debounced side
effects, not just a debounced value.
The real solution is quite different.
If I had to build this timeline again, I would start by decoupling React from the heavy work. Not every expensive update and computation needs to go through the React scheduler.
I would lean more into an imperative approach, with:
- time slicing for long work
- custom virtualization for a single scroll container with multiple tracks
- more nuanced throttling strategies that consider workload, not just a fixed delay
- and yes, debouncing where it actually helps
Basically, less “React everything”, more “control when work happens”.
Hmu on 𝕏 if you liked this blog, want to share thoughts, or want more blogs on any of the topics mentioned above.
