3, 2, 1, 0 카운트 다운을 진행하는 컴포넌트를 동작시키기 위해 setInterval
을 사용해 3에서 1초마다 countDown을 동작하고, count가 0이 되었을 때, clearInterval
을 사용해 setInterval을 중지하는 로직을 구현 중이었다.
clearInterval 동작 여부를 구분하기 위해 isIntervalStop
이라는 식별자를 flag로 사용하였다.
const [count, setCount] = useState(3);
const [isStartCountDown, setIsStartCountDown] = useState(false);
let isIntervalStop = false;
const buttonClickHandler = () => {
setIsStartCountDown(true);
};
useEffect(() => {
if (isStartCountDown === true) {
const countDownInterval = setInterval(() => {
console.log("isIntervalStop:", isIntervalStop);
if (!isIntervalStop) {
setCount((prev) => prev - 1);
} else {
clearInterval(countDownInterval as NodeJS.Timeout);
setIsStartCountDown(false);
}
}, 1000);
}
}, [isStartCountDown, isIntervalStop]);
useEffect(() => {
if (count === 0) {
isIntervalStop = true;
}
}, [count]);
count가 0이 되었을 때, isIntervalStop
을 true로 변경하지만, isIntervalStop
은 state가 아니므로 isIntervalStop
의 변경을 useEffect에서 감지하지 못한다. 따라서 clearInterval
로직이 실행되지 않는다.
useEffect에서 flag의 변경을 감지해서 clearInterval
로직을 동작할 수 있도록 isIntervalStop
을 state로 선언하였다.
const [count, setCount] = useState(3);
const [isStartCountDown, setIsStartCountDown] = useState(false);
const [isIntervalStop, setIsIntervalStop] = useState(false);
const buttonClickHandler = () => {
setIsStartCountDown(true);
};
useEffect(() => {
if (isStartCountDown === true) {
const countDownInterval = setInterval(() => {
console.log("isIntervalStop:", isIntervalStop);
if (!isIntervalStop) {
setCount((prev) => prev - 1);
} else {
clearInterval(countDownInterval as NodeJS.Timeout);
setIsStartCountDown(false);
}
}, 1000);
}
}, [isStartCountDown, isIntervalStop]);
useEffect(() => {
if (count === 0) {
setIsIntervalStop(true);
}
}, [count]);
setIsIntervalStop(true)
가 실행되면, useEffect에서 isIntervalStop
의 변경을 감지하고, clearInterval
로직을 실행한다.
하지만 isIntervalStop
에 대한 console을 출력하면 true로 변경 되었다가 이내 다시 false로 출력되는 것을 볼 수 있다. 결과적으로 countDown은 멈추지 않고 계속 실행된다.
이를 확실히 알기 위해서는 react에서 state가 변경될 때, 어떤 동작을 수행하는지 알아볼 필요가 있다.
react 컴포넌트의 경우 부모 컴포넌트가 리렌더링될 때, props값이 변경될 때, state가 변경될 때 리렌더링된다.
isIntervalStop
을 state로 둘 경우 state를 변경하는 순간 useEffect 내부의 countDownInterval
로직이 다시 선언되고 실행된다. 그리고 기존에 실행 중이던 setInterval의 콜백함수는 종료되지 않고, 계속 실행된다.
이 때, 기존에 실행 중이던 setInterval의 콜백함수는 리렌더링되어 새로 생성된 isIntervalStop
이 아닌, 리렌더링되기 전, 종료되기 전의 컴포넌트의 isIntervalStop
을 참조하고 있다.
기존 실행 중이던 콜백함수의 실행컨텍스트의 렉시컬 환경은 리렌더링 전의 컴포넌트 함수 스코프의 렉시컬 환경을 참조하고 있고, 이 때의 컴포넌트 함수를 외부함수, 그리고 외부함수의 isIntervalStop
이라는 자유변수를 참조하고 있는 콜백함수는 클로저가 된다.
즉, isIntervalStop
state를 true로 변경하여도 클로저인 콜백함수는 false로 종료되어진 isIntervalStop
자유변수를 사용하기 때문에 종료되지 않고 계속 실행된다. 그리고 리렌더링 이후 새로 선언된 isIntervalStop
에 true가 할당되어, 리렌더링 이후 만들어진 countDownInterval
로직은 한번도 실행되지 않고 clearInterval
로 종료된다.
useRef 함수는 current 속성을 가지고 있는 객체를 반환하는데, 인자로 넘어온 초기값을 current 속성에 할당한다. current 속성의 값을 변경하여도, 리렌더링되지 않는다.
useRef로 isIntervalStop
을 선언하게 되면, isIntervalStop.current
를 true로 변경했을 때, 컴포넌트 함수가 리렌더링되지 않고, 기존에 setInterval
의 콜백함수도 true로 변경된 isIntervalStop.current
를 참조하므로, 문제없이 카운트다운이 종료된다.
const [count, setCount] = useState(3);
const [isStartCountDown, setIsStartCountDown] = useState(false);
const isIntervalStop = useRef(false);
const buttonClickHandler = () => {
setIsStartCountDown(true);
};
useEffect(() => {
if (isStartCountDown === true) {
const countDownInterval = setInterval(() => {
console.log("isIntervalStop:", isIntervalStop);
if (!isIntervalStop.current) {
setCount((prev) => prev - 1);
} else {
clearInterval(countDownInterval as NodeJS.Timeout);
setIsStartCountDown(false);
}
}, 1000);
}
}, [isStartCountDown]);
useEffect(() => {
if (count === 0) {
isIntervalStop.current = true;
}
}, [count]);
플래그를 통해 clearInterval
를 처리하는 로직이 한 useEffect에 몰려있어 복잡하다. useRef에 setInterval
를 할당하여 setInterval
를 시작하는 로직과, clearInterval
를 통해 inteval을 종료하는 로직을 다른 useEffect로 분리하였다.
const [count, setCount] = useState(3);
const [isStartCountDown, setIsStartCountDown] = useState(false);
const countDownInterval = useRef<NodeJS.Timer | null>(null);
const buttonClickHandler = () => {
setIsStartCountDown(true);
};
useEffect(() => {
if (isStartCountDown === true) {
countDownInterval.current = setInterval(() => {
setCount((prev) => prev - 1);
}, 1000);
}
}, [isStartCountDown]);
useEffect(() => {
if (count === 0) {
clearInterval(countDownInterval.current as NodeJS.Timer);
countDownInterval.current = null;
setIsStartCountDown(false);
}
}, [count]);
참조