React hook 클로저 탈출기

devstone·2022년 4월 10일
21

React Study

목록 보기
6/7
post-thumbnail

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

profile
개발하는 돌멩이

7개의 댓글

comment-user-thumbnail
2022년 4월 10일

글 서두에 선전포고 너무 멋있네요 찜찜함을 해결한 다음 포스팅도 기대할게요~

1개의 답글
comment-user-thumbnail
2022년 4월 14일

https://www.rinae.dev/posts/a-complete-guide-to-useeffect-ko

useEffect와 관련된 글입니다. 도움이 많이 될 것 같아 공유합니다.

1개의 답글
comment-user-thumbnail
2022년 4월 14일

좋은포스팅이네요 ㅎㅎ 내용도좋구요
클로져이슈는 class component에서 hooks로 넘어올때도 중요한 부분이었습니다

답글 달기
comment-user-thumbnail
2022년 4월 20일

좋은 글 감사합니다 :)

답글 달기
comment-user-thumbnail
2024년 1월 26일

클로저 트랩이라고 부르지만, 정확히는 클로저가 아니라 memorization hook에 관한게 아닌가 싶습니다.
간단히 deps의 값이 변경되지 않으면 해당 함수가 memorize 된 이전 함수를 사용하는 문제인거죠.

답글 달기