React hook 클로저 탈출기

🙈 Prologue

포스팅을 시작하기에 앞서 일단 선전포고를 하려고 한다. 이 에러는 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으로 분리하여 코드를 정리해보았다.

  • useLatestState.ts
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이 어떤 식으로 동작하는지, 왜 이런 클로저 문제가 일어나는 지에 대해 제대로 뜯어보지 않아 한켠에 찜찜함이 남아있다. 이건 제대로 공부해서 다음 포스팅 때 갈겨봐야겠다 !

좋은 웹페이지 즐겨찾기