I recently felt a bit rusty doing more and more of my coding using LLMs. So I wanted to take a break and dive deeper into react. To preserve my sanity a bit.
I recently tackled a common react challenge: Build a Countdown Timer.
It sounds trivial. Just useState for the time and setInterval to tick it down, right? I quickly learned that mixing the mutable nature of JavaScript intervals with the declarative nature of React State is a recipe for edge cases, race conditions, and performance issues.
Here is the story of my refactoring journey—from a dependency trap to a production-grade solution—and the crucial lessons I learned along the way.
My very first try to one-shot the component
import { Fragment, useRef, useEffect, useState } from "react";
const ONE_SECOND = 1000;
const Timer = () => {
const [isRunning, setIsRunning] = useState(false);
const [seconds, setSeconds] = useState(0);
const [minutes, setMinutes] = useState(0);
const [currentSeconds, setCurrentSeconds] = useState(0);
const [currentminutes, setCurrentMinutes] = useState(0);
const timer = useRef();
const runTimer = (mins, secs) => {
if (secs === 0 && mins === 0) {
setIsRunning(false);
clearInterval(timer.current);
return;
}
if (secs > 0) {
setCurrentSeconds(secs);
}
if (mins > 0) {
setCurrentMinutes(mins);
}
};
const onStart = () => {
setIsRunning(true);
setCurrentMinutes(minutes);
setCurrentSeconds(seconds);
runTimer(minutes, seconds);
};
const toggleTimer = () => {
if (isRunning) {
clearInterval(timer.current);
setCurrentMinutes(currentminutes);
setCurrentSeconds(currentSeconds);
} else {
const minutes =
currentminutes === 1 && currentSeconds === 0
? 0
: parseInt(currentminutes) * 60;
const totalSeconds = parseInt(currentSeconds) + parseInt(minutes) - 1;
const numberOfSeconds = totalSeconds % 60;
const numberOfMinutes = (totalSeconds - numberOfSeconds) / 60;
runTimer(numberOfMinutes, numberOfSeconds);
}
setIsRunning(!isRunning);
};
const onRest = () => {
setIsRunning(false);
clearInterval(timer.current);
setCurrentSeconds(0);
setCurrentMinutes(0);
setMinutes(0);
setSeconds(0);
};
useEffect(() => {
if (isRunning) {
timer.current = setInterval(() => {
if (currentSeconds === 0 && currentminutes === 0) {
setIsRunning(false);
clearInterval(timer.current);
return;
}
if (currentSeconds > 0) {
setCurrentSeconds(currentSeconds - 1);
return;
}
if (currentminutes > 0) {
setCurrentMinutes(currentminutes - 1);
setCurrentSeconds(59);
}
}, ONE_SECOND);
} else {
clearInterval(timer.current);
setCurrentMinutes(currentminutes);
setCurrentSeconds(currentSeconds);
}
return () => {
clearInterval(timer.current);
};
}, [
isRunning,
currentminutes,
currentSeconds,
setCurrentSeconds,
setCurrentMinutes,
setIsRunning,
]);
return (
<Fragment>
<label>
<input
type="number"
value={minutes}
onChange={(e) => {
if (isRunning) {
return;
}
setMinutes(e.target.value);
}}
/>
Minutes
</label>
<label>
<input
type="number"
value={seconds}
onChange={(e) => {
if (isRunning) {
return;
}
const value = e.target.value;
if (value >= 60) {
const numberOfSeconds = value % 60;
const numberOfMinutes = (value - numberOfSeconds) / 60;
const totalNumberOfMinutes = parseInt(minutes) + numberOfMinutes;
setMinutes(totalNumberOfMinutes);
setSeconds(Math.max(numberOfSeconds, 0));
} else {
setSeconds(value);
}
}}
/>
Seconds
</label>
<button onClick={onStart}>START</button>
<button onClick={toggleTimer}>PAUSE / RESUME</button>
<button onClick={onRest}>RESET</button>
<h1 data-testid="running-clock">
{parseInt(currentminutes || 0)
.toLocaleString()
.padStart(2, "0")}
:
{parseInt(currentSeconds || 0)
.toLocaleString()
.padStart(2, "0")}
</h1>
</Fragment>
);
};
export default Timer;
Iteration 1: The "Dependency Trap"
My first approach was intuitive. I used a useEffect to handle the ticking. Every time the seconds changed, I wanted the effect to run.
// ❌ The Anti-Pattern
useEffect(() => {
if (isRunning) {
timer.current = setInterval(() => {
// logic to decrease time...
}, 1000)
}
return () => clearInterval(timer.current)
}, [isRunning, currentSeconds]); // <--- THE PROBLEM
The Downside
By including currentSeconds in the dependency array, I created a Performance Nightmare.
- The interval ticks.
- State updates.
- React sees the dependency changed.
- React tears down the component (clears the interval).
- React re-mounts the effect (starts a brand new interval). The Result: I wasn't actually running a continuous timer. I was stopping and starting a new timer every single second. This leads to time drift and unnecessary CPU overhead.
Iteration 2: The "Recursive Side-Effect"
To fix the dependency issue, I moved the logic into a helper function and tried to call it recursively.
// ❌ The Side Effect Error
const tick = () => {
setState(prev => {
if (prev.seconds > 0) {
runTimer(); // <--- SIDE EFFECT INSIDE STATE UPDATE
return { ...prev, seconds: prev.seconds - 1 };
}
})
}
The Caveat
I fell victim to React Strict Mode. In development, React invokes state updater functions twice to detect impurities. Because I was calling runTimer() (a side effect) inside the updater, my timer was initializing twice for every tick.
The Result: The timer would tick down exponentially faster, or "steal" seconds from the user, while creating memory leaks with "ghost" intervals I couldn't clear.
Iteration 3: The "Dead on Arrival" Bug
I realized I needed to separate the "State Update" from the "Interval Logic." I switched to an imperative approach where onStart triggers the interval. However, I kept a useEffect to act as a safety net.
// ❌ The Race Condition
const onStart = () => {
runTimer(); // Starts the interval
// I forgot to set isRunning: true immediately!
};
useEffect(() => {
if (!state.isRunning) {
clearTimer(); // <--- Kills the timer I just started
}
}, [state.isRunning]);
The Downside
This was a classic State vs. Ref Race Condition.
I clicked Start. The Interval started (via Ref).
React re-rendered. State was still isRunning: false (because state updates are batched/async).
The useEffect saw false and immediately killed the interval I just started.
The Result: The button did nothing. The timer was dead on arrival.
The Final Solution: Hybrid Architecture
To solve this, I had to synchronize my "Imperative Actions" (Handlers) with my "Declarative State" (Effect).
- Immediate State Feedback I ensured isRunning is set to true synchronously in the handler, so the next render knows we are live.
const onStart = () => {
setState(prev => ({ ...prev, isRunning: true })); // Flag it!
runTimer(); // Run it!
};
- The Split Effect Pattern (Crucial!) I initially tried to combine my cleanup logic into one useEffect, but that caused bugs where the cleanup of the previous render killed the new timer. I split the effects into two distinct responsibilities:
// ✅ Effect 1: The "Unmount" Safety Net
// Only runs when the component is destroyed (navigating away)
useEffect(() => {
return () => clearTimer();
}, []);
// ✅ Effect 2: The "Logic" Controller
// Only runs when the running state changes
useEffect(() => {
if (!state.isRunning) {
clearTimer();
}
}, [state.isRunning]);
- Type Safety I stopped using parseInt inside my logic. Instead, I cast inputs to Number() the moment the user types them. This makes the entire application predictable—no more NaN bugs.
Summary of Upsides
Performance: The interval is established once and persists. No tearing down and rebuilding.
Accuracy: We rely on setInterval timing rather than React render cycles.
Safety: We have a fail-safe cleanup if the user navigates away, preventing memory leaks.
UX: Inputs are disabled while running, and the timer feels responsive.
Conclusion
Building a timer teaches you that Refs and State are parallel worlds. Refs are for things that happen now (like Intervals), and State is for what the user sees. The trick to a bug-free timer is ensuring those two worlds strictly agree with each other at every millisecond.
Finished component
import { Fragment, useEffect, useRef, useState } from "react";
const ONE_SECOND = 1000;
const Timer = () => {
const [state, setState] = useState({
isRunning: false,
seconds: 0,
minutes: 0,
currentSeconds: 0,
currentMinutes: 0,
});
const timer = useRef();
const clearTimer = () => {
if (timer.current) {
clearInterval(timer.current);
}
};
const onSecondsChange = (e) => {
if (state.isRunning) {
return;
}
const value = Number(e.target.value);
if (value >= 60) {
const numberOfSeconds = value % 60;
const numberOfMinutes = (value - numberOfSeconds) / 60;
setState((prev) => ({
...prev,
minutes: prev.minutes + numberOfMinutes,
seconds: Math.max(numberOfSeconds, 0),
}));
} else {
setState((prev) => ({ ...prev, seconds: value }));
}
};
const onMinutesChange = (e) => {
if (state.isRunning) {
return;
}
setState((prev) => ({ ...prev, minutes: Number(e.target.value) }));
};
const onRest = () => {
clearTimer();
setState((prev) => ({
...prev,
isRunning: false,
currentSeconds: 0,
currentMinutes: 0,
minutes: 0,
seconds: 0,
}));
};
const tick = () => {
setState((prev) => {
if (prev.currentSeconds === 0 && prev.currentMinutes === 0) {
return {
...prev,
isRunning: false,
};
}
if (prev.currentSeconds > 0) {
return {
...prev,
isRunning: true,
currentSeconds: prev.currentSeconds - 1,
};
}
if (prev.currentMinutes > 0) {
return {
...prev,
isRunning: true,
currentMinutes: prev.currentMinutes - 1,
currentSeconds: 59,
};
}
return {
...prev,
isRunning: true,
};
});
};
const runTimer = () => {
clearTimer();
timer.current = setInterval(() => {
tick();
}, ONE_SECOND);
};
const onStart = () => {
setState((prev) => ({
...prev,
currentMinutes: prev.minutes,
currentSeconds: prev.seconds,
isRunning: true,
}));
runTimer();
};
const onToggle = () => {
if (state.isRunning) {
setState((prev) => ({
...prev,
isRunning: false,
}));
} else {
setState((prev) => ({ ...prev, isRunning: true }));
runTimer();
}
};
useEffect(() => {
return () => {
clearTimer();
};
}, []);
useEffect(() => {
if (!state.isRunning) {
clearTimer();
}
}, [state.isRunning]);
return (
<Fragment>
<label>
<input
disabled={state.isRunning}
min={0}
type="number"
value={state.minutes}
onChange={onMinutesChange}
/>
Minutes
</label>
<label>
<input
disabled={state.isRunning}
min={0}
type="number"
value={state.seconds}
onChange={onSecondsChange}
/>
Seconds
</label>
<button onClick={onStart}>START</button>
<button onClick={onToggle}>PAUSE / RESUME</button>
<button onClick={onRest}>RESET</button>
<h1 data-testid="running-clock">
{(state.currentMinutes || 0).toLocaleString().padStart(2, "0")}:
{(state.currentSeconds || 0).toLocaleString().padStart(2, "0")}
</h1>
</Fragment>
);
};
export { Timer };



Top comments (0)