Timer in React Component Keeps Increasing State of the timerRef a useRef variable on Re-mount Despite Cleanup

I am developing a multiplayer quiz game using React. The game includes a CountdownTimer component that tracks the time left for answering each question and displays it using a circular countdown UI. However, I have encountered an issue where the timer’s internal reference (timerRef) continues to accumulate state and does not clean up properly when the component unmounts and remounts. This behavior leads to performance issues and potential memory leaks.

I am using the following code for the CountdownTimer component:

Here is a codesandbox link: https://codesandbox.io/p/devbox/7jwt9h

/* eslint-disable react/prop-types */
import { useState, useEffect, useRef, useCallback } from "react";
import { useGameContext } from "../context/GameContext";

const CountdownTimer = ({
  onTimeUp,
  question,
  isLeaderboardTrue,
  leaderboardTime,
}) => {
  const { timeLeft, setTimeLeft, settings } = useGameContext();
  const [isActive, setIsActive] = useState(false);
  const timerRef = useRef(null);
  const hasCalledTimeUp = useRef(false);

  const calculateRemainingTime = useCallback(() => {
    if (isLeaderboardTrue) {
      return leaderboardTime;
    }
    const remainingTime = Math.max(
      0,
      Math.floor((question.endTime - Date.now()) / 1000)
    );
    return remainingTime;
  }, [question, leaderboardTime, isLeaderboardTrue]);

  const startTimer = useCallback(() => {
    if (timerRef.current) {
      console.log(`Timer already started, ${timerRef.current}`);
      return;
    }

    timerRef.current = setInterval(() => {
      setTimeLeft((prevSeconds) => {
        if (prevSeconds > 0) {
          console.log(`Updating time: ${prevSeconds - 1}`);
          return prevSeconds - 1;
        } else {
          clearInterval(timerRef.current);
          console.log("Clearing timer:", timerRef.current);
          timerRef.current = null;
          if (!hasCalledTimeUp.current) {
            hasCalledTimeUp.current = true;
            onTimeUp();
          }
          return 0;
        }
      });
    }, 1000);
    console.log("Timer started:", timerRef.current);
  }, [setTimeLeft, onTimeUp]);

  useEffect(() => {
    if (isActive) {
      startTimer();
    }

    return () => {
      if (timerRef.current) {
        console.log(
          "Cleaning up timer before starting a new one:",
          timerRef.current
        );
        clearInterval(timerRef.current);
        timerRef.current = null;
      }
    };
  }, [isActive, startTimer]);

  useEffect(() => {
    const remainingTime = calculateRemainingTime();
    console.log(`Calculating remaining time: ${remainingTime}`);

    setTimeLeft(remainingTime);
    hasCalledTimeUp.current = false;
    setIsActive(true);

    // Cleanup timer on component unmount or when dependencies change
    return () => {
      setIsActive(false);
      if (timerRef.current) {
        clearInterval(timerRef.current);
        console.log("Cleaning up timer on unmount:", timerRef.current);
        timerRef.current = null;
      }
    };
  }, [
    isLeaderboardTrue,
    calculateRemainingTime,
    leaderboardTime,
    settings,
    setTimeLeft,
    question,
  ]);

  const calculateCircleDashArray = () => {
    const radius = 45;
    const circumference = 2 * Math.PI * radius;
    const totalTime = isLeaderboardTrue ? leaderboardTime : question.timeLimit;
    const percentage = timeLeft / totalTime;
    return `${circumference * percentage} ${circumference}`;
  };

  return (
    <div className="relative flex items-center justify-center p-10">
      <svg className="w-20 h-20" viewBox="0 0 100 100">
        <circle
          className="text-gray-300"
          strokeWidth="5"
          stroke="currentColor"
          fill="transparent"
          r="45"
          cx="50"
          cy="50"
        />
        <circle
          className="text-purple-500"
          strokeWidth="5"
          strokeDasharray={calculateCircleDashArray()}
          strokeLinecap="round"
          stroke="currentColor"
          fill="transparent"
          r="45"
          cx="50"
          cy="50"
          transform="rotate(-90 50 50)"
        />
      </svg>
      <div className="absolute text-2xl text-purple-500">{timeLeft}</div>
    </div>
  );
};
export default CountdownTimer;

QuestionPage.jsx, I import the countdown timer in this page

import { useCallback } from "react";
import CountdownTimer from "../../components/CountdownTimer";
import Question from "../../components/questionComponents/Question";
import { useGameContext } from "../../context/GameContext";
import { useNavigate, useOutletContext, useParams } from "react-router-dom";

function QuestionPage() {
  const [questionData] = useOutletContext();
  const {
    scores,
    currentQuestionIndex,
    totalQuestions,
    setShowResults,
    setShowCorrectAnswer,
    isLeaderboard,
  } = useGameContext();
  const navigate = useNavigate();
  const { gameId } = useParams();

  const updateStateAndNavigate = useCallback(async () => {
    setShowResults(true);
    setShowCorrectAnswer(true);

    // Ensure state updates are applied before navigating
    await new Promise((resolve) => setTimeout(resolve, 0));

    navigate(`/game/${gameId}/results`);
  }, [setShowResults, setShowCorrectAnswer, gameId, navigate]);

  const handleTimeUp = useCallback(async () => {
    try {
      console.log("object");
      await updateStateAndNavigate();
    } catch (error) {
      console.error("Failed to handle time up:", error);
    }
  }, [updateStateAndNavigate]);

  return (
    <>
      {!isLeaderboard && (
        <>
          <CountdownTimer onTimeUp={handleTimeUp} question={questionData} />
          <div className="absolute top-20 left-4 text-2xl text-purple-500">
            Score: {scores}
          </div>
          <div className="absolute top-20 right-4 text-2xl text-purple-500">
            {currentQuestionIndex}/{totalQuestions}
          </div>

          <Question
            question={questionData.question}
            // totalQuestions={totalQuestions}
            isFirstQuestion={currentQuestionIndex === 1}
          />
        </>
      )}
    </>
  );
}

export default QuestionPage;

LeaderBoardPage.jsx, I also import it on this leaderboard page.

import { useCallback } from "react";
import CountdownTimer from "../../components/CountdownTimer";
import Leaderboard from "../../components/LeadersBoard.jsx";
import { useGameContext } from "../../context/GameContext";
import { useWebSocketContext } from "../../context/WebSocketContext";
import { useNavigate, useParams } from "react-router-dom";

function LeadersBoardPage() {
  const navigate = useNavigate();
  const { gameId } = useParams();
  const {
    setIsLeaderboard,
    setShowResults,
    updateScore,
    setUserAnswer,
    setCurrentQuestionIndex,
    totalQuestions,
    currentQuestionIndex,
  } = useGameContext();
  const { sendMessage } = useWebSocketContext();

  const handleTimeUp = useCallback(async () => {
    setIsLeaderboard(false);
    setUserAnswer("");
    setShowResults(false);
    updateScore();

    const nextIndex = currentQuestionIndex + 1;

    if (nextIndex < totalQuestions) {
      setCurrentQuestionIndex(nextIndex);
      sendMessage(JSON.stringify({ type: "requestNextQuestion", gameId }));
      navigate(`/game/${gameId}`);
    } else {
      setCurrentQuestionIndex(0);
      navigate(`/game/${gameId}/podium`);
    }
  }, [
    setIsLeaderboard,
    setShowResults,
    navigate,
    setCurrentQuestionIndex,
    setUserAnswer,
    gameId,
    totalQuestions,
    currentQuestionIndex,
    updateScore,
    sendMessage,
  ]);

  return (
    <>
      <CountdownTimer
        onTimeUp={handleTimeUp}
        isLeaderboardTrue={true}
        leaderboardTime={7}
      />
      <Leaderboard />
    </>
  );
}

export default LeadersBoardPage;

The Problem:

  1. The timerRef continues to increase even after the component unmounts and remounts.

  2. I am trying to ensure that the timer is cleaned up properly when the component unmounts, so there are no state updates or memory leaks when the component is re-rendered.

  3. I’ve implemented a cleanup function in the useEffect to clear the interval, but it seems to be ineffective.
    What I’ve Tried:

    1. Used useRef to track the timer and avoid unnecessary re-renders.

    2. Added cleanup in the useEffect to clear the timer when the component unmounts.

    3. Checked the logs — the clearInterval function runs, but the timer keeps accumulating.

My Question:

How can I ensure that the timer is properly cleaned up and reset when the component unmounts and remounts without causing state to accumulate or memory issues?

Any help is appreciated. Thank you!

These are the game state from devtools component and console logs

1 State:
2 Ref:1467
3 Ref:false
4 Callback:ƒ () {}
5 Callback:ƒ () {}
6 Effect:ƒ () {}
7 Effect:ƒ () {}
And here are the console logs
 Updating time: 5
CountdownTimer.jsx:52 Updating time: 5
CountdownTimer.jsx:52 Updating time: 4
CountdownTimer.jsx:52 Updating time: 4
CountdownTimer.jsx:52 Updating time: 3
CountdownTimer.jsx:52 Updating time: 3
CountdownTimer.jsx:52 Updating time: 2
CountdownTimer.jsx:52 Updating time: 2
CountdownTimer.jsx:52 Updating time: 1
CountdownTimer.jsx:52 Updating time: 1
CountdownTimer.jsx:52 Updating time: 0
CountdownTimer.jsx:52 Updating time: 0
GamePage.jsx:30 {type: 'answer', data: {…}}
CountdownTimer.jsx:56 Clearing timer: 79
QuestionPage.jsx:32 object
CountdownTimer.jsx:25 Calculating remaining time: 7
CountdownTimer.jsx:25 Calculating remaining time: 7
CountdownTimer.jsx:66 Timer started: 88
CountdownTimer.jsx:52 Updating time: 6
CountdownTimer.jsx:52 Updating time: 6
CountdownTimer.jsx:75 Cleaning up timer: 88
CountdownTimer.jsx:66 Timer started: 90
CountdownTimer.jsx:52 Updating time: 5
CountdownTimer.jsx:52 Updating time: 5
CountdownTimer.jsx:75 Cleaning up timer: 90
CountdownTimer.jsx:66 Timer started: 91
CountdownTimer.jsx:52 Updating time: 4
CountdownTimer.jsx:52 Updating time: 4
CountdownTimer.jsx:75 Cleaning up timer: 91
CountdownTimer.jsx:66 Timer started: 92
CountdownTimer.jsx:52 Updating time: 3
CountdownTimer.jsx:52 Updating time: 3
CountdownTimer.jsx:75 Cleaning up timer: 92
CountdownTimer.jsx:66 Timer started: 93
CountdownTimer.jsx:52 Updating time: 2
CountdownTimer.jsx:52 Updating time: 2
CountdownTimer.jsx:75 Cleaning up timer: 93
CountdownTimer.jsx:66 Timer started: 94
CountdownTimer.jsx:52 Updating time: 1
CountdownTimer.jsx:52 Updating time: 1
CountdownTimer.jsx:75 Cleaning up timer: 94
CountdownTimer.jsx:66 Timer started: 95
CountdownTimer.jsx:52 Updating time: 0
CountdownTimer.jsx:52 Updating time: 0
CountdownTimer.jsx:75 Cleaning up timer: 95
CountdownTimer.jsx:66 Timer started: 96
CountdownTimer.jsx:56 Clearing timer: 96
CountdownTimer.jsx:56 Clearing timer: null
CountdownTimer.jsx:66 Timer started: 98
CountdownTimer.jsx:35 Cleaning up timer on unmount: 98
GamePage.jsx:30 {type: 'leaderboard', data: Array(1)}
GamePage.jsx:30 {type: 'leaderboard', data: Array(1)}
GamePage.jsx:30 {type: 'question', data: {…}}
CountdownTimer.jsx:25 Calculating remaining time: 9
CountdownTimer.jsx:25 Calculating remaining time: 9
CountdownTimer.jsx:66 Timer started: 103
CountdownTimer.jsx:52 Updating time: 8
CountdownTimer.jsx:52 Updating time: 8
CountdownTimer.jsx:52 Updating time: 7
CountdownTimer.jsx:52 Updating time: 7
CountdownTimer.jsx:52 Updating time: 6
CountdownTimer.jsx:52 Updating time: 6
CountdownTimer.jsx:52 Updating time: 5
CountdownTimer.jsx:52 Updating time: 5
CountdownTimer.jsx:52 Updating time: 4
CountdownTimer.jsx:52 Updating time: 4
CountdownTimer.jsx:52 Updating time: 3
CountdownTimer.jsx:52 Updating time: 3
CountdownTimer.jsx:52 Updating time: 2
CountdownTimer.jsx:52 Updating time: 2
CountdownTimer.jsx:52 Updating time: 1
CountdownTimer.jsx:52 Updating time: 1
CountdownTimer.jsx:52 Updating time: 0
CountdownTimer.jsx:52 Updating time: 0
GamePage.jsx:30 {type: 'answer', data: {…}}
CountdownTimer.jsx:56 Clearing timer: 103
QuestionPage.jsx:32 object
CountdownTimer.jsx:25 Calculating remaining time: 7
CountdownTimer.jsx:25 Calculating remaining time: 7
CountdownTimer.jsx:66 Timer started: 112
CountdownTimer.jsx:52 Updating time: 6
CountdownTimer.jsx:52 Updating time: 6
CountdownTimer.jsx:75 Cleaning up timer: 112
CountdownTimer.jsx:66 Timer started: 114
CountdownTimer.jsx:52 Updating time: 5
CountdownTimer.jsx:52 Updating time: 5
CountdownTimer.jsx:75 Cleaning up timer: 114
CountdownTimer.jsx:66 Timer started: 115
CountdownTimer.jsx:52 Updating time: 4
CountdownTimer.jsx:52 Updating time: 4
CountdownTimer.jsx:75 Cleaning up timer: 115
CountdownTimer.jsx:66 Timer started: 116
CountdownTimer.jsx:52 Updating time: 3
CountdownTimer.jsx:52 Updating time: 3
CountdownTimer.jsx:75 Cleaning up timer: 116
CountdownTimer.jsx:66 Timer started: 117
CountdownTimer.jsx:52 Updating time: 2
CountdownTimer.jsx:52 Updating time: 2
CountdownTimer.jsx:75 Cleaning up timer: 117
CountdownTimer.jsx:66 Timer started: 121
CountdownTimer.jsx:52 Updating time: 1
CountdownTimer.jsx:52 Updating time: 1
CountdownTimer.jsx:75 Cleaning up timer: 121
CountdownTimer.jsx:66 Timer started: 124
CountdownTimer.jsx:52 Updating time: 0
CountdownTimer.jsx:52 Updating time: 0
CountdownTimer.jsx:75 Cleaning up timer: 124
CountdownTimer.jsx:66 Timer started: 128
CountdownTimer.jsx:56 Clearing timer: 128
CountdownTimer.jsx:56 Clearing timer: null
CountdownTimer.jsx:66 Timer started: 133
CountdownTimer.jsx:35 Cleaning up timer on unmount: 133
GamePage.jsx:30 {type: 'leaderboard', data: Array(1)}
GamePage.jsx:30 {type: 'leaderboard', data: Array(1)}
GamePage.jsx:30 {type: 'question', data: {…}}
CountdownTimer.jsx:25 Calculating remaining time: 9
CountdownTimer.jsx:25 Calculating remaining time: 9
CountdownTimer.jsx:66 Timer started: 138
backendManager.js:1 Could not find Fiber with id "306"
overrideMethod @ console.js:288
findCurrentFiberUsingSlowPathById @ renderer.js:2960
inspectElementRaw @ renderer.js:3235
inspectElement @ renderer.js:3738
(anonymous) @ agent.js:420
emit @ events.js:37
(anonymous) @ bridge.js:292
listener @ backendManager.js:1
postMessage
handleMessageFromDevtools @ proxy.js:1
Show 8 more frames
Show lessUnderstand this warning
CountdownTimer.jsx:52 Updating time: 8