React를 쓰다 보면 useEffect를 작성하면서 항상 마주치는 문제가 있다.
이 문제는 React 개발자들이 오랫동안 겪어온 대표적인 "Effect 설계 문제"이고,
이 문제를 해결하기 위해 React 팀에서 제안한 훅이 바로 useEffectEvent이다.
이 글에서는 useEffectEvent의 정의, 등장 배경, RFC/역사, 그리고 실제로 어떤 문제를 해결하는지 정리한다.
useEffectEvent는 React에서 제공하는 Hook으로,
Effect에서 사용되는 함수가 최신 props/state를 읽을 수 있도록 보장하면서도 dependency 배열에는 포함되지 않는 안정적인 함수를 만든다.
( React 19.2버전 부터 사용가능하다!! )
즉 다음을 동시에 만족하는 것이 핵심이다.
React에서 가장 흔한 문제는 오래된 클로저(stale closure) 이다.
예를 들어 다음 코드를 보자.
function Counter() {
const [count, setCount] = React.useState(0);
React.useEffect(() => {
const id = setInterval(() => {
console.log(count);
}, 1000);
return () => clearInterval(id);
}, []);
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
여기서 count는 0에서 증가하지만, 콘솔에는 계속 0만 출력된다.
왜냐하면 effect가 처음 실행될 때의 count=0을 closure로 캡처했고,
dependency 배열이 []라서 다시 실행되지 않기 때문이다.
이 stale closure 문제는 단순한 버그를 넘어, React 개발자들이 "Effect를 어떻게 설계해야 하는가"라는 구조적인 고민을 만들었다.
위에 방법을 해소하는 방법에는 무엇이 있을까 ?
React.useEffect(() => {
console.log(count);
}, [count]);
이건 최신 값을 보장하지만, 문제는 effect가 계속 재실행된다는 점이다.
const countRef = React.useRef(count);
React.useEffect(() => {
countRef.current = count;
}, [count]);
그리고 effect 안에서는
console.log(countRef.current);
이렇게 해결할 수 있다.
하지만 이 방식은 단점이 있다.
const handler = React.useCallback(() => {
console.log(count);
}, [count]);
하지만 이 방식은 결국 count가 바뀌면 callback이 새로 만들어지고, effect dependency 문제는 그대로 남는다.
React 팀이 보기에 stale closure는 단순 버그가 아니라 React의 선언형 모델과 effect의 관계에서 생기는 구조적 문제였다.
useEffectEvent가 해결하려는 핵심은 다음이다.
React 팀이 꾸준히 강조한 것
React 팀은 오랫동안 다음을 강조해왔다.
useEvent 제안 -> useEffectEvent로 발전
React 팀은 stale closure 문제를 해결하기 위해 예전부터 useEvent라는 이름으로 API를 고민해왔다.
useEffectEvent의 정의를 한 문장으로 정리하면
useEffectEvent는
Effect 내부에서 최신 state/props를 안전하게 읽을 수 있도록 만들어진, dependency 배열에 포함되지 않는 안정적인 callback을 생성하는 Hook이다.
문제 상황: effect 안에서 최신 값 읽기
ChatRoom 컴포넌트는 채팅방에 접속하는 상황을 가정한 예시다.
roomId가 바뀌면 새로운 채팅방에 연결해야 한다.message는 계속 변한다.connection.onMessage() 같은 이벤트 콜백을 등록해두고, 메시지가 오면 콜백을 실행한다.즉 이 코드는 "외부 시스템(WebSocket 같은 서버 연결)"을 React 컴포넌트가 관리하는 대표적인 패턴을 보여준다.
function ChatRoom({ roomId }: { roomId: string }) {
const [message, setMessage] = React.useState("");
React.useEffect(() => {
const connection = createConnection(roomId);
connection.onMessage(() => {
console.log("latest message:", message);
});
connection.connect();
return () => connection.disconnect();
}, [roomId]);
return (
<input value={message} onChange={e => setMessage(e.target.value)} />
);
}
여기서 message는 최신이 아닐 수 있다.
message를 dependency에 넣으면:
해결: useEffectEvent로 이벤트 로직 분리
function ChatRoom({ roomId }: { roomId: string }) {
const [message, setMessage] = React.useState("");
const onMessage = React.useEffectEvent(() => {
console.log("latest message:", message);
});
React.useEffect(() => {
const connection = createConnection(roomId);
connection.onMessage(onMessage);
connection.connect();
return () => connection.disconnect();
}, [roomId]);
return (
<input value={message} onChange={e => setMessage(e.target.value)} />
);
}
핵심 변화는 이거다.
1) 함수 identity가 안정적이다
useCallback과 다르게 dependency에 따라 함수가 새로 생성되지 않는다.
2) 최신 state/props를 읽을 수 있다
stale closure 문제가 해결된다.
3) effect dependency가 단순해진다
setup/cleanup을 위한 dependency만 남게 된다.
4) "Effect는 동기화"라는 React 철학을 강화한다
Effect 안에 섞여 있던 이벤트성 로직을 분리함으로써
React 팀이 의도한 올바른 설계 패턴을 자연스럽게 유도한다.
useEffectEvent는 "신뢰할 수 없는 외부 시스템"과 연결되는 코드에서 특히 유용하다.
대표적인 상황은 다음과 같다.
useEffectEvent는 단순히 "stale closure를 해결하는 훅"이 아니다.
React 팀이 오랫동안 고민했던 문제,