function useCountdown({
  countStart,
  countStop = 0,
  intervalMs = 1000,
  isIncrement = false,
}: UseCountdownOptions): UseCountdownReturn {
  const [count, setCount] = useState(countStart);
  const [running, setRunning] = useState(false);

  const reset: UseCountdownReturn['reset'] = useCallback(() => {
    setRunning(false);
    setCount(countStart);
  }, [countStart]);

  const start: UseCountdownReturn['start'] = useCallback(() => {
    setRunning(true);
  }, []);

  const stop: UseCountdownReturn['stop'] = useCallback(() => {
    setRunning(false);
  }, []);

  useEffect(() => {
    if (!running) return;

    const id = setInterval(() => {
      if (count === countStop) return stop();

      if (isIncrement) {
        setCount((prev) => prev + 1);
      } else {
        setCount((prev) => prev - 1);
      }
    }, intervalMs);

    return () => clearInterval(id);
  }, [count, countStop, intervalMs, isIncrement, running]);

  return { count, start, stop, reset };
}

Q1. 왜 새로운 인터벌을 만들어야 하나?

useCountdown 훅은 setInterval이 최신 count 값을 참조하도록 하기 위해, count가 변할 때마다 기존 인터벌을 제거하고 새로운 인터벌을 생성한다. 이는 클로저 때문이다.

useEffect 내부의 setInterval 콜백 함수는 자신이 만들어진 시점의 count 값을 기억한다. setCount로 상태가 업데이트되더라도, 콜백 함수는 여전히 예전 count 값을 참조하게 된다. 따라서, 매번 최신 count 값을 사용하려면 useEffect가 다시 실행되게하고 기존 인터벌은 폐기, 새로운 클로저를 가진 인터벌을 만들어야한다.

Q2. 수천 번의 인터벌 생성, 제거가 성능에 영향을 주지는 않을까?

문제를 해결하기 위해 useRef를 활용해볼 수 있다.

  • useRef를 사용해 콜백 함수를 저장한다. ref는 렌더링마다 값을 하므로, 항상 최신 콜백 함수를 참조할 수 있다.
  • setInterval 에서 저장된 최신 함수를 호출하면, 인터벌을 다시 만들 필요 없이 클로저 문제가 해결된다
function useInterval(callback: () => void, delay: number | null) {
  const savedCallback = useRef(callback);

  useEffect(() => {
    savedCallback.current = callback;
  }, [callback]);

  useEffect(() => {
    if (delay == null) {
      return;
    }

    const id = setInterval(() => {
      savedCallback.current();
    }, delay);

    return () => {
      clearInterval(id);
    };
  }, [delay]);
}
function useCountdown({
  countStart,
  countStop = 0,
  intervalMs = 1000,
  isIncrement = false,
}: UseCountdownOptions): UseCountdownReturn {
  const [count, setCount] = useState(countStart);
  const [running, setRunning] = useState(false);

  const reset: UseCountdownReturn['reset'] = useCallback(() => {
    setRunning(false);
    setCount(countStart);
  }, [countStart]);

  const start: UseCountdownReturn['start'] = useCallback(() => {
    setRunning(true);
  }, []);

  const stop: UseCountdownReturn['stop'] = useCallback(() => {
    setRunning(false);
  }, []);

  useInterval(() => {
    if (!running) return;

    if (count === countStop) return stop();

    if (isIncrement) {
      setCount(count + 1);
    } else {
      setCount(count - 1);
    }
  }, intervalMs);

  return { count, start, stop, reset };
}

클로저 이슈를 피하기 위해 setCount 함수형 업데이트도 필요가 없게 되었다.