/////

React Stale-Closure and useEffectEvent Contracts: Event Handler Freshness, Dependency Minimization, Subscription Rebinding, and Async Race Boundaries

React stale-closure 문제는 Effect, 타이머, 구독 콜백, DOM/EventEmitter 리스너, 비동기 fetch 콜백이 “렌더 당시의 props/state”를 닫아 잡은 뒤 나중에 실행될 때 발생한다. React의 기본 계약은 Effect 안에서 읽는 모든 reactive 값은 dependency array에 포함 하는 것이다. 이를 어기면 stale read가 생기고, 반대로 불필요한 값을 dependency에 넣으면 구독 해제/재구독,

/////

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-deps lint는 이 계약을 정적으로 강제하려는 도구다.
  • dependency를 “줄이고 싶다”는 이유로 값을 빼면 stale closure 위험이 생긴다.
  • dependency를 줄이는 올바른 방식은 값을 숨기는 것이 아니라 로직을 재구성하는 것이다.

    • state 업데이트만 필요하면 functional updater 사용: setCount(c => c + 1)
    • Effect 밖으로 이동 가능한 pure helper는 컴포넌트 밖으로 이동
    • 구독 lifecycle과 비반응형 callback 로직을 분리
    • 최신 값 읽기가 필요하지만 재구독 트리거가 아니어야 하면 useEffectEvent 사용
  • 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가 roomIdtheme을 모두 dependency로 가짐
    • theme 변경만으로도 WebSocket을 disconnect/reconnect함
    • 실제로 재연결 조건은 roomId뿐이고, connected 알림에서만 최신 theme이 필요함
  • 개선 계약:
    • Effect dependency: 구독 identity에 필요한 값만 포함, 예: roomId, serverUrl
    • Effect Event: 구독 callback에서 최신으로 읽어야 하는 값, 예: theme, locale, currentUser
  • 결과:

    • 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

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와 부합함

Reviews

Support
0
Dispute
0
Neutral
0
Visible Reviews
1