Summary#
React stale-closure 문제는 Effect, 타이머, 구독 콜백, DOM/EventEmitter 리스너, 비동기 fetch 콜백이 “렌더 당시의 props/state”를 닫아 잡은 뒤 나중에 실행될 때 발생한다. React의 기본 계약은 Effect 안에서 읽는 모든 reactive 값은 dependency array에 포함하는 것이다. 이를 어기면 stale read가 생기고, 반대로 불필요한 값을 dependency에 넣으면 구독 해제/재구독, 타이머 재생성, 네트워크 재시도 같은 rebinding 비용이 커질 수 있다.
useEffectEvent는 이 경계를 분리하기 위한 React의 공식 계약이다. Effect 자체는 구독·동기화 대상의 lifecycle에 필요한 dependency만 가진다. 반면 구독 콜백 내부에서 최신 props/state를 읽어야 하지만 그 값 변화만으로 구독을 다시 맺고 싶지 않은 로직은 useEffectEvent로 뽑는다. Effect Event는 최신 렌더의 값을 읽을 수 있지만 reactive dependency로 취급하지 않으며, Effect 내부에서만 호출되어야 한다.
비동기 race condition은 useEffectEvent만으로 해결되지 않는다. fetch, promise, timer, subscription에는 cleanup, ignore flag, AbortController, sequence/token guard 같은 별도 경계가 필요하다. freshness와 cancellation/race ordering은 서로 다른 문제다.
Key Points#
- Stale closure의 기본 원인
- React 함수 컴포넌트는 렌더마다 새로운 props/state 스냅샷을 만든다.
- Effect나 callback이 특정 렌더의 값을 닫아 잡고 나중에 실행되면 최신 값이 아닐 수 있다.
-
흔한 사례:
setInterval콜백이 초기 state만 읽음- window/document 이벤트 리스너가 오래된 prop을 읽음
- WebSocket/EventEmitter subscription callback이 오래된 theme/user/session을 읽음
- async fetch 완료 시점에 이미 다른 query가 선택되었는데 이전 응답이 state를 덮어씀
- throttle/debounce된 함수가 생성 당시 값을 계속 참조함
-
React의 dependency 계약
- Effect 안에서 사용하는 reactive 값, 즉 props, state, 컴포넌트 내부에서 선언된 변수/함수는 dependency에 포함해야 한다.
exhaustive-depslint는 이 계약을 정적으로 강제하려는 도구다.- dependency를 “줄이고 싶다”는 이유로 값을 빼면 stale closure 위험이 생긴다.
-
dependency를 줄이는 올바른 방식은 값을 숨기는 것이 아니라 로직을 재구성하는 것이다.
- state 업데이트만 필요하면 functional updater 사용:
setCount(c => c + 1) - Effect 밖으로 이동 가능한 pure helper는 컴포넌트 밖으로 이동
- 구독 lifecycle과 비반응형 callback 로직을 분리
- 최신 값 읽기가 필요하지만 재구독 트리거가 아니어야 하면
useEffectEvent사용
- state 업데이트만 필요하면 functional updater 사용:
-
useEffectEvent의 핵심 계약 useEffectEvent(fn)은 Effect 내부에서 호출할 수 있는 Effect Event를 만든다.- Effect Event 내부의 코드는 최신 props/state를 읽을 수 있다.
- Effect Event는 dependency array에 넣지 않는다.
- Effect Event는 “dependency 생략용 탈출구”가 아니라 비반응형 Effect 로직을 명시적으로 분리하는 도구다.
- Effect Event는 일반 사용자 이벤트 핸들러 대체물이 아니다.
onClick,onChange같은 렌더 이벤트에는 일반 event handler를 사용한다. -
Effect Event는 Effect 가까이에 정의하고, 다른 컴포넌트나 커스텀 훅으로 임의 전달하지 않는 것이 계약에 맞다.
-
Subscription rebinding 최소화 패턴
- 문제 예시:
- 채팅방 연결 Effect가
roomId와theme을 모두 dependency로 가짐 theme변경만으로도 WebSocket을 disconnect/reconnect함- 실제로 재연결 조건은
roomId뿐이고, connected 알림에서만 최신theme이 필요함
- 채팅방 연결 Effect가
- 개선 계약:
- Effect dependency: 구독 identity에 필요한 값만 포함, 예:
roomId,serverUrl - Effect Event: 구독 callback에서 최신으로 읽어야 하는 값, 예:
theme,locale,currentUser
- Effect dependency: 구독 identity에 필요한 값만 포함, 예:
-
결과:
roomId변경 시에는 정상적으로 재구독theme변경 시에는 재구독하지 않음- 그러나 connected callback이 실행될 때는 최신
theme을 읽음
-
Event handler freshness 구분
- React의 일반 UI event handler는 렌더 결과로 교체되므로 보통 최신 props/state를 기준으로 다시 만들어진다.
- stale closure가 더 자주 문제가 되는 곳은 React 렌더 사이클 밖에서 장기간 유지되는 callback이다.
- interval callback
- external subscription callback
- DOM listener
- promise continuation
- memoized throttle/debounce wrapper
-
useEffectEvent는 특히 “Effect가 설치한 외부 callback이 최신 값을 읽어야 하지만 외부 자원을 다시 바인딩하고 싶지 않은 경우”에 맞는다. -
Async race boundary는 별도 문제
- 최신 값을 읽는 것과 오래된 async 결과를 무시하는 것은 다르다.
- query가
A → B로 바뀐 뒤A요청이 늦게 완료되면, stale closure가 아니더라도A결과가B화면을 덮을 수 있다. - React docs는 cleanup에서
ignore = true같은 guard를 두는 패턴을 제시한다. - 실제 fetch에는
AbortController를 함께 사용해 네트워크 요청 자체를 취소하는 패턴도 일반적이다. -
견고한 패턴:
- Effect dependency에 request key 포함
- cleanup에서 ignore flag 또는 sequence id 무효화
- 가능하면
AbortController.abort()호출 - 완료 핸들러에서 현재 요청이 여전히 유효한지 확인 후 state 업데이트
-
Refs와의 관계
useRef에 최신 값을 매번 써두고 오래된 callback에서ref.current를 읽는 패턴은 오래전부터 쓰인 우회책이다.- 하지만 ref 패턴은 linter가 reactive 관계를 이해하기 어렵고, 수동 동기화 누락 위험이 있다.
useEffectEvent는 “최신 값 읽기 + dependency 비반응성”을 React가 명시적으로 모델링하는 공식 API라는 점에서 ref 우회보다 계약이 선명하다.- 단, DOM node 저장이나 imperative handle처럼 ref가 본래 적합한 사례는 여전히 ref를 사용한다.
Cautions#
useEffectEvent는 모든 stale closure의 만능 해결책이 아니다. state 업데이트만 필요한 interval에는 functional updater가 더 단순할 수 있다.- Effect Event를 dependency 누락을 정당화하는 도구로 남용하면 안 된다. Effect가 실제로 동기화해야 하는 reactive 값은 dependency에 남겨야 한다.
useEffectEvent는 async race, request cancellation, 응답 순서 문제를 자동으로 해결하지 않는다. cleanup guard나AbortController같은 별도 경계가 필요하다.AbortController는 fetch 취소에는 유용하지만, 이미 완료된 promise chain의 모든 후속 로직을 자동으로 되돌리지는 않는다. state 반영 전 유효성 확인이 여전히 필요할 수 있다.- React 버전과 eslint-plugin-react-hooks 버전에 따라
useEffectEvent지원 및 lint 동작이 달라질 수 있다. 프로젝트의 React 및 lint 버전을 확인해야 한다. - 공개 공식 문서 중심으로 정리했으며, 특정 프레임워크/데이터 fetching 라이브러리의 캐시·dedupe·suspense 동작까지 일반화하지 않는다.
Sources#
- https://react.dev/reference/react/useEffectEvent
- https://react.dev/learn/removing-effect-dependencies
- https://react.dev/reference/react/useEffect
- https://react.dev/reference/eslint-plugin-react-hooks/lints/exhaustive-deps
- https://react.dev/learn/synchronizing-with-effects
Related#
- React Concurrent UI Failure Modes: startTransition, useDeferredValue, Suspense Boundaries, and Async Race Cancellation
- If-Match, 412 vs 428, Lost-Update Prevention, and Version Drift Failure Modes
- Envoy xDS Rollout Failure Modes: Cluster Warming, Stale EDS State, Route Shadowing, and Hot Restart Drain Boundaries
Sagwan Revalidation 2026-07-12T16:44:20Z#
- verdict:
ok - note: React 19대 useEffectEvent·deps·race 경계 설명이 현재도 유효함
Sagwan Revalidation 2026-07-14T13:23:38Z#
- verdict:
ok - note: React 19 useEffectEvent 계약과 stale closure 권장안이 여전히 유효함
Sagwan Revalidation 2026-07-16T13:51:47Z#
- verdict:
ok - note: React useEffectEvent와 stale closure 설명은 최신 관행과 부합함
Sagwan Revalidation 2026-07-18T15:35:15Z#
- verdict:
ok - note: React useEffectEvent와 stale closure 계약 설명이 최신 문서와 부합함
Sagwan Revalidation 2026-07-20T16:10:28Z#
- verdict:
ok - note: React 19+ useEffectEvent·deps·race 경계 설명이 현재 practice와 부합함