포스팅을 시작하기에 앞서 일단 선전포고를 하려고 한다. 이 에러는 React Hook의 클로저 의존성으로 인해 발생한 에러인데, 이 포스팅에서는 React Hook을 사용할 때 필연적으로 마주하게 되는 클로저의존성을 어떻게 잘 탈피할 수 있을까에 방점을 맞추고 있다. 왜 이런 의존성을 갖는지에 대해서는 React의 Hook을 구현해보며 다음 포스트에서 좀 더 심도 깊게 다룰 예정이다 ! 일단 이번 포스팅에서는 어떤 상황을 마주했고, 이것을 어떻게 풀어나갔는지 이야기해보려 한다.
Youniverse개발을 하며 예상과는 달리 동작하는 코드를 마주했다.
useEffect(() => {
let timer = setInterval(() => {
console.log(messageIndex);
if (messages.length - 1 === messageIndex) {
router.push('/onboarding');
}else{
setMessageIndex(messageIndex + 1);
}
}, 1500);
return () => clearTimeout(timer);
}, []);
messageIndex가 +1이 되어서 messages의 길이와 같아지면 라우팅을 해야하는데, console에 1500ms간격으로 찍히는 messageIndex가 변하지 않고 계속 0이 찍히는 것을 발견하였다.
일단 당장 이 문제를 해결하기 위해 useEffect에 디펜던시로 messageIndex값을 넣었다.
useEffect(() => {
let timer = setInterval(() => {
if (messages.length - 1 === messageIndex) {
router.push('/onboarding');
} else {
setMessageIndex(messageIndex + 1);
}
}, 1500);
return () => clearTimeout(timer);
}, [messageIndex]);
이렇게 쓰고보니 여기서 setInterval되고, clear되는 과정이 불필요하게 반복되고 있는데 동작방식이 setTimeout과 다를 바가 없을 것 같아서 아래와 같이 수정을 하였다.
useEffect(() => {
setTimeout(() => {
if (messages.length - 1 === messageIndex) {
router.push('/onboarding');
} else {
setMessageIndex(messageIndex + 1);
}
}, 1500);
}, [messageIndex]);
이렇게 결국 얼레벌레 원하는대로 나오도록 구현은 했지만 해결 방식이 썩 마음에 들지 않았다. 만약 이 코드가 이렇게 했을 때 setTimeout()과 똑같이 동작하지 않고, setInterval()로 동작되어야만 했던 상황이라면 매 순간 클리어돼서 의도한대로 동작하지 않았을 것이기 때문이다. 그래서 의존성 배열에 messageIndex를 넣지 않고 이 상황을 해결할 수 있는 방법을 찾아보았다.
이렇게 최신의 messageIndex값이 아닌, 오래된 messageIndex값, 즉 0이 계속 콘솔에 찍히는 이유는 messageIndex 값이 클로저에 갇혀있기 때문이다. 이러한 stale-closure 이슈를 회피할 수 있는 방식을 찾아보니, ref를 이용하여 회피할 수 있다고 하여 아래와 같이 코드를 수정해보았다.
const latestValue = useRef(messageIndex);
useEffect(() => {
let timer = setInterval(() => {
console.log(latestValue.current);
if (messages.length - 1 === latestValue.current) {
router.push('/onboarding');
} else {
setMessageIndex((prev) => {
latestValue.current = prev + 1;
return prev + 1;
});
}
}, 1500);
return () => clearTimeout(timer);
}, []);
이처럼 stale값인 state가 아니라 ref를 통해 변수를 관리함으로써 클로저에 갇힌 변수를 구출해 원하는대로 동작될 수 있도록 꼼수를 부릴 수 있었다.
이렇게 최신의 state값으로 기대되는 값에 보다 쉽게 접근하기 위해 이 부분을 Hook으로 분리하여 코드를 정리해보았다.
import { Dispatch, MutableRefObject, SetStateAction, useRef, useState } from 'react';
function useLatestState<T>(initData: T): [T, Dispatch<SetStateAction<T>>, MutableRefObject<T>] {
const [state, setState] = useState<T>(initData);
const latestState = useRef(state);
latestState.current = state;
return [state, setState, latestState];
}
export default useLatestState;
const [messageIndex, setMessageIndex, freshMessageIndex] = useLatestState<number>(0);
useEffect(() => {
let timer = setInterval(() => {
console.log(freshMessageIndex.current);
if (messages.length - 1 === freshMessageIndex.current) {
router.push('/onboarding');
} else {
setMessageIndex(freshMessageIndex.current + 1);
}
}, 1500);
return () => clearTimeout(timer);
}, []);
이렇게 이 React Hook의 클로저 문제를 해결하였으나 아직 이 React Hook이 어떤 식으로 동작하는지, 왜 이런 클로저 문제가 일어나는 지에 대해 제대로 뜯어보지 않아 한켠에 찜찜함이 남아있다. 이건 제대로 공부해서 다음 포스팅 때 갈겨봐야겠다 !
https://www.rinae.dev/posts/a-complete-guide-to-useeffect-ko
useEffect와 관련된 글입니다. 도움이 많이 될 것 같아 공유합니다.
글 서두에 선전포고 너무 멋있네요 찜찜함을 해결한 다음 포스팅도 기대할게요~