DEV Community

BlackJosh007
BlackJosh007

Posted on

My Timer Kept Doubling — and It Took Me a Real Bug to Actually Understand useEffect

I finished my Tenzies capstone project (built with React) and I want to talk about the one bug that taught me more than any tutorial did.

🔗 Live demo: tenzies-six-silk.vercel.app
🔗 Code: github.com/BlackJosh007/tenzies

I was adding a timer — track how long it takes a player to win. Simple feature, I thought. I set up useState for the elapsed time, useRef for the interval ID, and a useEffect to start the interval once the player held their first die.

It worked... sometimes. Other times my timer would double up out of nowhere, ticking twice as fast. And at one point, after a few touches, it stopped running entirely.

Here's why.

The wrong belief I was carrying

I thought the cleanup function in useEffect only ran once — when the component unmounted. That's what I'd taken away from the tutorial. Knowledge, filed away, never tested.

What actually happens: the cleanup function runs before every re-run of the effect, not just on unmount. Every time your dependency changes, React cleans up the previous effect first, then runs the new one.

I didn't have that cleanup wired in properly. So every time my dependency array changed, a new setInterval was created — without the old one being cleared. Two intervals running at once. Then three. My timer wasn't buggy, it was doing exactly what I told it to do.

It got worse before it got better

My first instinct for triggering the timer was checking diceNo.includes({ isHeld: true }) inside the effect, with seconds as my dependency array. That's a double mistake: .includes doesn't do the deep comparison I wanted (switched to .some(die => die.isHeld) later), and using seconds as the dependency meant the effect was re-running every single tick — re-arming a new interval on top of the old one, every second.

I also had a second wrong belief here: I assumed that if I wrapped my interval logic in an if statement inside the effect, only the code inside the if block counted as "the effect." Not true — the entire function body passed to useEffect is the effect, condition or no condition. React doesn't care what's inside your if; it re-runs the whole thing whenever the dependency changes.

Once I fixed the dependency array (moved to diceNo, gated by a timerStarted ref so the interval only ever arms once) and got the cleanup returning clearInterval(intervalRef.current) properly, the doubling stopped.

The bonus realization

While staring at setInterval(() => setMilliSeconds(prev => prev + 10), 10), I genuinely wondered if React was re-rendering every 10ms because of it. Turns out — no. setInterval is handed off to the browser, not React. React only re-renders when the state update inside the callback actually fires, and it's the state update — not the timer itself — that's on React's clock.

Knowledge vs. understanding

I'd "learned" useEffect and its dependency array from a tutorial before this. I could've told you the definition. But I didn't understand it until a real bug forced me to. That's the difference — knowledge is something you can repeat, understanding is something you can debug.

One thing I changed my mind on

Last post, I said I wanted to add full keyboard navigation to this game. I'm not doing that anymore. Tenzies is an abstract, casual game — realistically, almost everyone playing it will be on a phone, not a desktop with a keyboard. Building out keyboard nav for a demographic that mostly won't use it didn't make sense once I actually thought about who's playing. Instead, I put that time into a timer, roll counter, restart button, and a highscore overlay — features that matter regardless of device.

Onto the next build.

Top comments (0)