Weird bug in React custom useCountDown hook under strict mode

55 Views Asked by At

I wrote a custom React useCountDown hook to count down a number (seconds) until zero. The counting is triggered by start() method attached in a button onClick listener.

It works well without <React.StrictMode>, but not working under strict mode.

import { useCallback, useEffect, useState } from "react";

export default function useCountDown(countdownSeconds = 10) {
  const [timing, setTiming] = useState(false);
  const [seconds, setSeconds] = useState(countdownSeconds);

  const reset = useCallback(() => {
    setTiming(true);
    setSeconds(countdownSeconds);
  }, [countdownSeconds]);

  const start = useCallback(() => setTiming(true), []);

  useEffect(() => {
    let timer: number | undefined;

    function countdown() {
      setSeconds((preSecond) => {
        if (preSecond <= 1) {
          setTiming(false);
          return countdownSeconds;
        } else {
          timer = setTimeout(countdown, 1000);
          return preSecond - 1;
        }
      });
    }
    if (timing) {
      timer = setTimeout(countdown, 1000);
    }
    return () => {
      clearTimeout(timer);
    };
  }, [timing, countdownSeconds]);

  return {
    start,
    seconds,
    timing,
    reset,
  };
}

The count is quickly decreased to zero at no interval (1s) and never stop the counting, under the strict mode. However, it's running well as expected when I removed the <React.StrictMode>.

I found the countdown() executed too many times without interval. Anything wrong in the code? How can I fix the problem?

The codesandbox

I know another approach will be working under the strict mode, like below one. But I'm trying to figure out what went wrong with the above approach.

useEffect(() => {
    let timer: NodeJS.Timeout
    if (timing) {
      timer = setTimeout(() => {
        if (seconds > 0) {
          setSeconds(seconds - 1)
        } else {
          onCountdownEndRef.current?.()
          setSeconds(countdownSeconds)
          setTiming(false)
        }
      }, 1000)
    }
    return () => clearTimeout(timer)
  }, [seconds, timing, countdownSeconds])

====== Edit =====

Even if I moved the setTiming(false) out of the updater function, it doesn't work either.

  useEffect(() => {
    seconds === 0 && setTiming(false);
  }, [seconds]);

  useEffect(() => {
    let timer: number | undefined;

    function countdown() {
      setSeconds((preSecond) => {
        if (preSecond <= 1) {
          return countdownSeconds;
        } else {
          timer = setTimeout(countdown, 1000);
          return preSecond - 1;
        }
      });
    }
    if (timing) {
      timer = setTimeout(countdown, 1000);
    }
    return () => {
      clearTimeout(timer);
    };
  }, [timing, countdownSeconds]);
1

There are 1 best solutions below

2
Konrad On

Isn't it easier to use refs?

export default function useCountDown(countdownSeconds = 10) {
  const seconds = useRef(countdownSeconds);
  const interval = useRef();
  const [, update] = useReducer((x) => !x, 0);

  const reset = useCallback(() => {
    seconds.current = countdownSeconds;
    update();
  }, [countdownSeconds]);

  const start = useCallback(() => {
    interval.current = setInterval(() => {
      if (seconds.current < 1) {
        clearInterval(interval.current);
        return;
      }
      seconds.current -= 1;
      update();
    }, 1000);
  }, []);

  return {
    start,
    seconds: seconds.current,
    reset
  };
}