DEV Community

Omar Abdelhalim
Omar Abdelhalim

Posted on

The React Timer Trap: Refactoring a "Simple" Countdown from Buggy to Bulletproof

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.

Header


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;

Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The Downside

By including currentSeconds in the dependency array, I created a Performance Nightmare.

  1. The interval ticks.
  2. State updates.
  3. React sees the dependency changed.
  4. React tears down the component (clears the interval).
  5. 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.

section 1 diagram

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 };
     }
  })
}
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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.

section 3 diagram


The Final Solution: Hybrid Architecture

To solve this, I had to synchronize my "Imperative Actions" (Handlers) with my "Declarative State" (Effect).

  1. 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!
};
Enter fullscreen mode Exit fullscreen mode
  1. 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]);
Enter fullscreen mode Exit fullscreen mode
  1. 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 };
Enter fullscreen mode Exit fullscreen mode

Top comments (0)