useCallback 안에서 sessionStorage 값이 갱신되지 않는 이유
useCallback stale closure — 외부 store 파생값이 deps에 없을 때
난이도: ★★★☆☆
연관 노트: 비동기 반복문에서 React 상태가 꼬이는 이유
핵심 요약
- sessionStorage 파생값은 React state가 아니라서 변경해도 React가 모른다.
- 리렌더가 발생해도 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 파생값은 잡지 못하는 이유도 함께 이해할 것
↔ 같은 원리가 적용되는 곳
- 입력창이 사라질 때 값이 멋대로 저장되는 이유 — 클로저가 stale한 컴포넌트 상태를 캡처하는 같은 패턴
- router를 useMemo 의존성에 넣으면 재계산이 안 되는 이유 — “참조가 고정되어 이전 값을 본다”는 동일 원리. 클로저 stale 대신 Object.is 참조 비교 stale
- useEffect deps 누락 — useCallback과 동일한 원리. deps가 없으면 effect도 오래된 클로저를 쓴다