useCallback 안에서 sessionStorage 값이 갱신되지 않는 이유

useCallback stale closure — 외부 store 파생값이 deps에 없을 때

reactstateclosure

난이도: ★★★☆☆
연관 노트: 비동기 반복문에서 React 상태가 꼬이는 이유


핵심 요약

  1. sessionStorage 파생값은 React state가 아니라서 변경해도 React가 모른다.
  2. 리렌더가 발생해도 deps에 없으면 useCallback이 재생성되지 않아, 콜백이 첫 렌더의 값을 클로저로 포획한 채 유지된다.

왜 헷갈렸나

“sessionStorage에 값을 세팅하고 setState도 호출했으니 리렌더됐을 텐데, 모달이 열릴 때 이미 최신 sessionStorage 값을 읽지 않을까?”라고 생각했음. 리렌더가 됐어도 useCallback이 재생성되지 않으면 콜백 내부의 클로저는 교체되지 않는다는 걸 놓쳤음.


메커니즘

1. 첫 렌더: isFeatureVariant = sessionStorage.get("flag") === "true" → false
           openModal = useCallback(() => {
             // isFeatureVariant = false 포획
           }, [/* isFeatureVariant 없음 */])

2. sessionStorage.set("flag", "true")  ← 동기 세팅
   setStatus("active")                 ← 리렌더 트리거

3. 리렌더: isFeatureVariant = sessionStorage.get("flag") === "true" → true
           컴포넌트 본문에서는 최신값을 읽음
           openModal deps 변화 없음 → useCallback 재생성 안 됨
           openModal 내부 클로저는 여전히 첫 렌더의 false를 들고 있음

4. status 변화 감지 → openModal() 호출
   → 클로저가 포획한 isFeatureVariant = false 사용
   → 모달이 이전 값 기준으로 렌더됨

핵심: 컴포넌트 본문의 변수는 매 렌더마다 새로 계산되지만, useCallback 내부 클로저는 deps에 있는 값이 바뀔 때만 교체된다. 두 isFeatureVariant는 서로 다른 클로저 인스턴스를 본다.


해결 패턴

// 컴포넌트 상단에서 sessionStorage 파생값을 변수로 선언
const isFeatureVariant =
  sessionStorage.get(storageKeys.featureFlag) === "true";

const openModal = useCallback(() => {
  const message = isFeatureVariant ? "변형 문구" : "기본 문구";
  overlay.open(() => <Modal message={message} />);
}, [overlay, isFeatureVariant]); // ← isFeatureVariant를 deps에 포함

동작 이유: sessionStorage는 동기 세팅이므로 setState로 리렌더가 발생하면 그 렌더에서 isFeatureVariant가 최신값을 읽는다. deps에 포함되어 있으므로 이 렌더에서 openModal이 재생성되고, 이후 effect가 openModal()을 호출하면 최신 클로저를 쓴다.


다음에 이 상황을 만나면

useCallback 내부에서 React state/context가 아닌 값(sessionStorage, localStorage, ref.current 등)을 참조한다면:

  • 해당 값이 deps에 포함되어 있는가?
  • 컴포넌트 상단에서 변수로 선언했는가? (내부에서 직접 읽으면 lint가 잡지 못함)
  • React state로 올릴 수 있는가? (가장 안전한 해결)

커넥팅 닷

← 선행 개념 (이걸 알아야 이해된다)

  • 비동기 반복문에서 React 상태가 꼬이는 이유 — stale closure의 원형. state가 렌더 시점에 스냅샷으로 캡처되는 원리를 먼저 이해해야 이 노트가 들어온다
  • JavaScript Closure — 함수가 생성될 때 주변 변수를 캡처하는 메커니즘. useCallback이 왜 재생성되어야 하는지의 뿌리

→ 확장 개념 (여기서 더 나아가면)

  • useSyncExternalStore — React 외부 store(sessionStorage, 전역 상태 등)를 구독하는 공식 패턴. deps 대신 subscribe를 등록해 React가 변화를 추적하게 만듦
  • eslint-plugin-react-hooks exhaustive-deps — 이런 버그를 정적으로 잡아주는 규칙. React state는 잡지만 sessionStorage 파생값은 잡지 못하는 이유도 함께 이해할 것

↔ 같은 원리가 적용되는 곳