useEffect 안에서 최신 props를 읽고 싶은데, 의존성 배열에 넣으면 Effect가 불필요하게 재실행됩니다. 의존성에서 빼면 린터가 경고하고, useRef로 우회하면 보일러플레이트가 늘어납니다.
useEffectEvent 는 이 문제를 해결하는 Hook이며, React 19.2에서 stable로 전환됐습니다. Effect 내부의 비반응형 로직을 분리합니다. 그래서 의존성 배열에 실제 반응형 값만 남기면서도 항상 최신 값을 읽을 수 있습니다.
이 글은 useEffectEvent의 등장 배경, API, 실전 예제, 주의사항을 차례로 다룹니다.
🤔 왜 useEffectEvent가 필요한가?
useEffect 의존성 배열의 딜레마
채팅방 연결 기능을 구현한다고 가정해 보겠습니다. roomId가 바뀌면 새로운 방에 재연결해야 하고, 연결 성공 시 현재 테마에 맞는 알림을 표시해야 합니다.
function ChatRoom({ roomId, theme }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on("connected", () => {
showNotification("Connected!", theme); // theme을 읽어야 함
});
connection.connect();
return () => connection.disconnect();
}, [roomId, theme]); // 🔴 theme 변경마다 재연결됨!
}
theme을 의존성 배열에 포함하면, 사용자가 다크 모드로 전환할 때마다 채팅 연결이 끊겼다가 다시 연결됩니다. 테마 변경과 채팅 연결은 관계가 없는데도 재연결이 일어납니다.
기존 해결책들의 한계
이 문제를 해결하기 위해 다양한 패턴이 시도되었습니다.
방법 1: 의존성 배열에서 제거 (린터 경고 무시)
useEffect(() => {
// ...
showNotification("Connected!", theme);
}, [roomId]); // eslint-disable-line react-hooks/exhaustive-deps
린터를 무시하면 당장은 동작하지만, theme이 stale closure에 갇혀 초기 값만 참조하는 버그가 발생합니다.
방법 2: useRef로 최신 값 유지
const themeRef = useRef(theme);
themeRef.current = theme; // 매 렌더링마다 수동 업데이트
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on("connected", () => {
showNotification("Connected!", themeRef.current);
});
connection.connect();
return () => connection.disconnect();
}, [roomId]);
동작은 하지만, 매번 ref를 선언하고 .current를 업데이트하는 보일러플레이트가 필요합니다. 값이 여러 개면 ref도 여러 개 만들어야 합니다. 린터도 이 패턴의 정확성을 검증하지 못합니다.
Stale Closure, 정확히 무엇인가
JavaScript의 클로저는 함수가 생성된 시점의 변수를 캡처합니다. useEffect의 콜백도 마찬가지입니다.
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
console.log(count); // 항상 0을 출력!
}, 1000);
return () => clearInterval(id);
}, []); // count를 의존성에 넣지 않음
}
의존성 배열이 []이므로 Effect는 마운트 시 한 번만 실행됩니다. 이때 콜백이 캡처한 count는 0이고, 이후 count가 아무리 변경되어도 콜백은 여전히 0을 참조합니다. 이것이 stale closure 문제입니다.
useEffectEvent는 이 문제를 해결합니다.
🔍 useEffectEvent란?
기본 개념
useEffectEvent는 Effect 내부에서 발생하는 비반응형(non-reactive) 로직을 별도의 “Effect Event”로 분리하는 Hook입니다.
특성은 두 가지입니다.
- 항상 최신 props/state에 접근합니다. 클로저에 갇히지 않습니다.
- 의존성 배열에 포함되지 않습니다. Effect의 재실행을 트리거하지 않습니다.
API
import { useEffectEvent } from "react";
const onEvent = useEffectEvent(callback);
파라미터
callback: Effect Event 로직을 담은 함수. 임의의 인수를 받을 수 있고 임의의 값을 반환할 수 있습니다.
반환값
callback과 동일한 타입 시그니처를 가진 Effect Event 함수
동작 원리
useEffectEvent로 래핑된 함수는 호출 시점에 가장 최근 커밋된 렌더링의 값을 읽습니다. 함수를 호출하는 순간의 props와 state를 봅니다.
function ChatRoom({ roomId, theme }) {
// theme이 바뀌어도 Effect는 재실행되지 않음
const onConnected = useEffectEvent(() => {
showNotification("Connected!", theme); // 항상 최신 theme
});
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on("connected", () => {
onConnected(); // Effect Event 호출
});
connection.connect();
return () => connection.disconnect();
}, [roomId]); // ✅ roomId만 의존성 - theme 변경은 재연결하지 않음
}
이제 theme이 변경되어도 채팅 연결은 유지되면서, 실제로 연결이 성공했을 때는 그 시점의 최신 theme 값으로 알림을 표시합니다.
탄생까지의 여정
useEffectEvent는 2018년 첫 논의부터 stable 전환까지 약 7년이 걸렸습니다.
| 시기 | 이벤트 |
|---|---|
| 2018년 11월 | Dan Abramov가 GitHub Issue #14099 제기 — useCallback()이 너무 자주 무효화되는 문제 |
| 2022년 5월 | useEvent RFC 제안 (reactjs/rfcs#220) |
| 2022년 9월 | useEffectEvent로 이름 변경, 범위를 Effect 내부 전용으로 축소 |
| 2024년 12월 | React 19 출시 (experimental 상태) |
| 2025년 10월 | React 19.2에서 stable로 전환 |
처음에는 useEvent라는 이름으로, Effect뿐 아니라 이벤트 핸들러에서도 사용할 수 있는 범용 Hook으로 설계되었습니다. 그런데 Concurrent Mode에서 렌더링 중간에 호출하면 잘못된 값을 참조하는 문제가 발견됐습니다. 그래서 범위를 Effect 내부 전용으로 줄이고 이름을 useEffectEvent로 바꿨습니다.
🛠️ 실전 예제
예제 1: 타이머와 최신 값
setInterval에서 최신 state를 참조하는 전형적인 패턴입니다.
function Timer() {
const [count, setCount] = useState(0);
const [increment, setIncrement] = useState(1);
const onTick = useEffectEvent(() => {
setCount(count + increment); // 항상 최신 count, increment를 읽음
});
useEffect(() => {
const id = setInterval(() => {
onTick();
}, 1000);
return () => clearInterval(id);
}, []); // ✅ 빈 의존성 - 타이머가 재시작되지 않음
return (
<div>
<p>Count: {count}</p>
<label>
Increment:
<input
type="number"
value={increment}
onChange={e => setIncrement(Number(e.target.value))}
/>
</label>
</div>
);
}
useEffectEvent 없이 이 코드를 작성하려면, count와 increment를 모두 의존성에 넣어야 합니다. 그러면 값이 바뀔 때마다 타이머가 초기화되어 간격이 불규칙해집니다.
예제 2: 분석(Analytics) 로깅
페이지 방문 시 URL과 장바구니 아이템 수를 로깅하는 상황입니다. URL이 바뀔 때만 로깅해야 하고, 장바구니 변경은 새로운 로그를 트리거하면 안 됩니다.
function ProductPage({ url, cartItems }) {
const onVisit = useEffectEvent((visitedUrl: string) => {
logAnalytics(visitedUrl, cartItems.length);
});
useEffect(() => {
onVisit(url);
}, [url]); // ✅ url 변경 시에만 로깅, cartItems 변경은 무시
}
이 예제는 url을 Effect Event의 인자로 전달합니다. url은 반응형으로 동작해야 하므로(URL이 바뀌면 로깅해야 하므로) 의존성 배열에 남기고, cartItems는 비반응형이므로 Effect Event 내에서 직접 읽습니다.
예제 3: 메시지 구독과 사용자 설정
채팅 메시지를 구독하되, 알림 스타일은 사용자 설정에 따라 달라지는 경우입니다.
function ChatNotifications({ roomId, userPreference }) {
const onMessage = useEffectEvent((msg: Message) => {
showToast(msg.text, { variant: userPreference });
});
useEffect(() => {
const connection = createConnection(roomId);
const unsubscribe = connection.subscribe(onMessage);
return () => unsubscribe();
}, [roomId]); // ✅ roomId 변경 시에만 재구독
}
userPreference가 “compact”에서 “detailed”로 바뀌어도 채팅 연결은 끊기지 않습니다. 새 메시지가 도착하면 그 시점의 최신 userPreference 값을 읽어 알림을 표시합니다.
예제 4: 커스텀 Hook — useInterval
커스텀 Hook에서 useEffectEvent를 쓰면 호출하는 쪽이 콜백 의존성을 관리하지 않아도 됩니다. 재사용 가능한 useInterval Hook을 만들어 보겠습니다.
function useInterval(callback: () => void, delay: number | null) {
const onTick = useEffectEvent(callback);
useEffect(() => {
if (delay === null) return;
const id = setInterval(() => {
onTick();
}, delay);
return () => clearInterval(id);
}, [delay]); // ✅ delay만 의존성
}
사용하는 쪽에서는 callback의 의존성을 신경 쓸 필요가 없습니다.
function AutoSave({ data }) {
const [savedAt, setSavedAt] = useState<Date | null>(null);
useInterval(() => {
saveToServer(data); // 항상 최신 data를 저장
setSavedAt(new Date());
}, 30000);
return <p>Last saved: {savedAt?.toLocaleTimeString() ?? "Not yet"}</p>;
}
예제 5: 이벤트 리스너와 조건부 로직
마우스 이동에 반응하되, canMove prop에 따라 동작을 제어하는 캔버스 컴포넌트입니다.
function Canvas({ canMove }) {
const [position, setPosition] = useState({ x: 0, y: 0 });
const onMove = useEffectEvent((e: PointerEvent) => {
if (canMove) {
setPosition({ x: e.clientX, y: e.clientY });
}
});
useEffect(() => {
window.addEventListener("pointermove", onMove);
return () => window.removeEventListener("pointermove", onMove);
}, []); // ✅ canMove 변경이 리스너 재등록을 트리거하지 않음
return (
<div
style={{
position: "absolute",
left: position.x,
top: position.y,
width: 20,
height: 20,
borderRadius: "50%",
background: "blue",
}}
/>
);
}
canMove를 의존성에 넣으면 true ↔ false 전환 시마다 이벤트 리스너가 해제되었다가 다시 등록됩니다. useEffectEvent를 사용하면 리스너는 한 번만 등록하고, 호출 시점에 canMove의 최신 값을 확인합니다.
예제 6: 폴링 대시보드
주기적으로 데이터를 가져오되, 필터 옵션이 다음 폴링에 자동 반영되어야 하는 대시보드입니다.
function Dashboard({ teamId, includeArchived }) {
const [data, setData] = useState(null);
const fetchData = useEffectEvent(async () => {
const response = await fetch(
`/api/team/${teamId}/tasks?archived=${includeArchived}`
);
setData(await response.json());
});
useEffect(() => {
fetchData(); // 즉시 첫 fetch
const intervalId = setInterval(fetchData, 10_000);
return () => clearInterval(intervalId);
}, [teamId]); // ✅ teamId 변경 시에만 폴링 재시작
}
includeArchived 체크박스를 토글해도 폴링 타이머는 초기화되지 않습니다. 다음 10초 주기가 되면 자동으로 최신 includeArchived 값이 반영된 요청이 나갑니다.
⚖️ useEffect vs useEffectEvent 비교
두 Hook의 역할과 차이를 정리합니다.
| 측면 | useEffect | useEffectEvent |
|---|---|---|
| 목적 | 의존성 변경 시 사이드 이펙트 실행 | Effect 내 비반응형 로직 래핑 |
| 의존성 | 모든 반응형 값을 나열해야 함 | 의존성 배열에서 제외됨 |
| 최신 값 접근 | 의존성 배열을 통해 업데이트 | 호출 시점에 항상 최신 값 |
| 함수 정체성 | 렌더링 간 안정적 (cleanup/setup 기준) | 매 렌더링마다 변경 (의도적 설계) |
| 호출 위치 | React가 자동 호출 | Effect 또는 다른 Effect Event 내부에서만 |
| 재실행 조건 | 의존성 변경 시 | 해당 없음 (Effect 내부에서 명시적으로 호출) |
useCallback과 무엇이 다른가?
useCallback과 혼동하기 쉽지만, 반응성과 사용 범위가 다릅니다.
// useCallback: 의존성이 변경되면 새 함수 생성 → 반응형
const handleClick = useCallback(() => {
console.log(count);
}, [count]);
// useEffectEvent: 의존성 없음, 항상 최신 값 → 비반응형
const onTick = useEffectEvent(() => {
console.log(count);
});
| 측면 | useCallback | useEffectEvent |
|---|---|---|
| 반응성 | 반응형 (의존성 변경 시 새 참조) | 비반응형 (의존성에서 제외) |
| 사용 범위 | 어디서든 사용 가능 | Effect 내부에서만 호출 가능 |
| 주요 용도 | 자식 컴포넌트에 콜백 전달 시 리렌더링 최적화 | Effect 내 비반응형 로직 분리 |
기존 useRef 패턴과의 비교
useEffectEvent 이전에는 useRef로 최신 값을 유지하는 패턴이 일반적이었습니다.
// 🔴 기존 useRef 패턴 — 보일러플레이트가 많고 린터 지원 없음
function ChatRoom({ roomId, theme }) {
const themeRef = useRef(theme);
themeRef.current = theme;
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on("connected", () => {
showNotification("Connected!", themeRef.current);
});
connection.connect();
return () => connection.disconnect();
}, [roomId]);
}
// ✅ useEffectEvent — 깔끔하고 린터가 정확성 검증
function ChatRoom({ roomId, theme }) {
const onConnected = useEffectEvent(() => {
showNotification("Connected!", theme);
});
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on("connected", () => onConnected());
connection.connect();
return () => connection.disconnect();
}, [roomId]);
}
useEffectEvent를 쓰면 다음이 달라집니다.
- 수동
.current업데이트 불필요 eslint-plugin-react-hooksv6.0.0+에서 린트 규칙 지원- 의도가 코드에 명시적으로 드러남
⚠️ 주의사항과 규칙
호출 위치 제한
useEffectEvent로 만든 함수는 Effect(useEffect, useLayoutEffect, useInsertionEffect) 또는 다른 Effect Event 내부에서만 호출할 수 있습니다.
// 🔴 렌더링 중 호출 — 런타임 에러
function Component({ theme }) {
const onConnected = useEffectEvent(() => {
showNotification("Connected!", theme);
});
onConnected(); // ❌ 렌더링 중 직접 호출
return <div>...</div>;
}
// 🔴 이벤트 핸들러에서 호출 — 허용되지 않음
function Component() {
const onEvent = useEffectEvent(() => {
/* ... */
});
const handleClick = () => {
onEvent(); // ❌ Effect 밖에서 호출
};
}
// ✅ Effect 내부에서 호출
function Component() {
const onEvent = useEffectEvent(() => {
/* ... */
});
useEffect(() => {
onEvent(); // ✅
}, []);
}
다른 컴포넌트에 전달 금지
Effect Event는 다른 컴포넌트나 Hook에 props로 전달할 수 없습니다.
// 🔴 잘못된 사용
function Parent() {
const onTick = useEffectEvent(() => {
/* ... */
});
return <Child onTick={onTick} />; // ❌ props로 전달 금지
}
// ✅ 올바른 사용 — 같은 컴포넌트의 Effect에서 사용
function Component() {
const onTick = useEffectEvent(() => {
/* ... */
});
useEffect(() => {
const id = setInterval(onTick, 1000);
return () => clearInterval(id);
}, []);
}
의존성 배열에 포함 금지
Effect Event는 의도적으로 매 렌더링마다 함수 정체성이 변경됩니다. 의존성 배열에 넣으면 매 렌더링마다 Effect가 재실행되어, useEffectEvent를 사용하는 의미가 사라집니다.
// 🔴 의존성 배열에 포함 — 무한 재실행
const onTick = useEffectEvent(() => {
/* ... */
});
useEffect(() => {
const id = setInterval(onTick, 1000);
return () => clearInterval(id);
}, [onTick]); // ❌ 매 렌더링마다 Effect가 재실행됨
안티패턴: 의존성 회피 목적으로 사용
useEffectEvent는 실제로 비반응형인 로직을 분리하는 도구입니다. 반응해야 할 값의 의존성을 회피하는 용도로 사용하면 버그가 됩니다.
// 🔴 안티패턴: 반응해야 할 값을 숨김
const logVisit = useEffectEvent(() => {
log(pageUrl); // pageUrl 변경에 반응해야 하는데 무시됨!
});
useEffect(() => {
logVisit();
}, []); // pageUrl이 바뀌어도 로깅되지 않음 — 버그!
// ✅ 올바른 패턴: 반응형 값은 인자로, 비반응형 값만 Effect Event 내부에서
const logVisit = useEffectEvent((visitedUrl: string) => {
log(visitedUrl, theme); // theme만 비반응형
});
useEffect(() => {
logVisit(url);
}, [url]); // ✅ url 변경에 정상 반응
판단 기준은 한 가지 질문입니다. “이 값이 바뀌면 Effect가 다시 실행되어야 하는가?”
- Yes → 의존성 배열에 넣고, Effect Event의 인자로 전달
- No → Effect Event 내부에서 직접 읽기
💡 실전 팁
선언 위치
Effect Event는 관련 useEffect 바로 위에 선언합니다. 그러면 로직을 위에서 아래로 읽을 수 있습니다.
function ChatRoom({ roomId, theme, soundEnabled }) {
// Effect Event들을 관련 Effect 바로 위에 선언
const onConnected = useEffectEvent(() => {
showNotification("Connected!", theme);
if (soundEnabled) playSound("connect");
});
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on("connected", () => onConnected());
connection.connect();
return () => connection.disconnect();
}, [roomId]);
}
네이밍 컨벤션
Effect Event 이름은 on 접두사를 사용하여 이벤트 핸들러와 유사하게 짓는 것이 관례입니다. “이 Effect에서 어떤 이벤트가 발생하는가?”를 기준으로 이름을 짓습니다.
onConnected— 연결 성공 시onTick— 타이머 틱 시onMessage— 메시지 수신 시onVisit— 페이지 방문 시
필요 환경
useEffectEvent를 사용하려면 다음 환경이 필요합니다.
- React 19.2 이상 (stable 버전)
- eslint-plugin-react-hooks v6.0.0 이상 (린트 규칙 지원)
🧭 useEvent에서 useEffectEvent로: 범위 축소의 이유
useEffectEvent의 역사를 이해하면 설계 의도가 더 명확해집니다.
원래 2022년에 제안된 useEvent RFC는 세 가지를 동시에 제공하려 했습니다.
- 의존성 배열 불필요
- 안정적인 함수 참조 (메모이제이션)
- 항상 최신 값 접근
세 가지를 모두 만족하면 useCallback을 완전히 대체할 수 있습니다. 하지만 React의 Concurrent Mode에서 문제가 발견되었습니다.
Concurrent Mode에서 React는 렌더링을 중단하고 재시작할 수 있습니다. 만약 useEvent가 렌더링 중에 호출되면, 중단된 렌더의 stale한 값을 참조할 위험이 있었습니다. 렌더링 도중에는 “항상 최신 값”을 보장할 수 없었습니다.
React 팀은 세 가지 목표를 모두 달성하는 대신, 안전하게 보장할 수 있는 범위로 축소하는 결정을 내렸습니다.
- 이름을
useEffectEvent로 변경 - 사용 범위를 Effect 내부로 제한
- 안정적인 함수 참조 보장을 포기 (의도적으로 매 렌더링마다 변경)
그 결과 Effect 내부에서의 동작은 예측할 수 있게 됐습니다.
📝 마무리
useEffectEvent는 Effect 안에서 최신 값을 읽되 재실행은 막아야 하는 경우를 공식 API로 처리합니다.
핵심 요약
useEffectEvent는 Effect 내부의 비반응형 로직을 분리하는 Hook입니다- 항상 최신 props/state에 접근하면서 Effect의 재실행을 트리거하지 않습니다
useRef패턴의 보일러플레이트를 제거하고, 린터가 정확성을 검증합니다- React 19.2에서 stable로 전환되어 프로덕션에서 사용할 수 있습니다
- Effect 내부에서만 호출 가능하며, props로 전달하거나 의존성에 포함하면 안 됩니다
다음 단계
- 기존 코드에서
useRef로 최신 값을 유지하는 패턴을useEffectEvent로 마이그레이션해 보세요 - 의존성 배열에서
eslint-disable주석을 사용한 곳이 있다면useEffectEvent로 대체할 수 있는지 검토해 보세요 - 커스텀 Hook에서 콜백을 받는다면
useEffectEvent로 감싸 호출하는 쪽의 의존성 관리를 없애 보세요
참고 자료