useEffect 안에서 최신 props를 읽고 싶은데, 의존성 배열에 넣으면 Effect가 불필요하게 재실행됩니다. 의존성에서 빼면 린터가 경고하고, useRef로 우회하면 보일러플레이트가 늘어납니다. React 개발자라면 한 번쯤 겪어본 딜레마입니다.
useEffectEvent 는 이 문제를 근본적으로 해결합니다. React 19.2에서 stable로 전환된 이 Hook은 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는 하루아침에 만들어진 것이 아닙니다. 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
useEffectEvent는 커스텀 Hook 내부에서 특히 빛을 발합니다. 재사용 가능한 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을 완전히 대체할 수 있는 꿈의 Hook이 됩니다. 하지만 React의 Concurrent Mode에서 문제가 발견되었습니다.
Concurrent Mode에서 React는 렌더링을 중단하고 재시작할 수 있습니다. 만약 useEvent가 렌더링 중에 호출되면, 중단된 렌더의 stale한 값을 참조할 위험이 있었습니다. “항상 최신 값”이라는 약속을 렌더링 도중에는 보장할 수 없었던 것입니다.
React 팀은 세 가지 목표를 모두 달성하는 대신, 안전하게 보장할 수 있는 범위로 축소하는 결정을 내렸습니다.
- 이름을
useEffectEvent로 변경 - 사용 범위를 Effect 내부로 제한
- 안정적인 함수 참조 보장을 포기 (의도적으로 매 렌더링마다 변경)
덕분에 Effect 내부에서의 동작은 완벽하게 예측 가능해졌습니다.
📝 마무리
useEffectEvent는 React의 Effect 모델에서 오랫동안 존재했던 빈 공간을 채웁니다.
핵심 요약
useEffectEvent는 Effect 내부의 비반응형 로직을 분리하는 Hook입니다- 항상 최신 props/state에 접근하면서 Effect의 재실행을 트리거하지 않습니다
useRef패턴의 보일러플레이트를 제거하고, 린터가 정확성을 검증합니다- React 19.2에서 stable로 전환되어 프로덕션에서 사용할 수 있습니다
- Effect 내부에서만 호출 가능하며, props로 전달하거나 의존성에 포함하면 안 됩니다
다음 단계
- 기존 코드에서
useRef로 최신 값을 유지하는 패턴을useEffectEvent로 마이그레이션해 보세요 - 의존성 배열에서
eslint-disable주석을 사용한 곳이 있다면useEffectEvent로 대체할 수 있는지 검토해 보세요 - 커스텀 Hook에서 콜백을 받을 때
useEffectEvent를 활용하면 API가 훨씬 깔끔해집니다
참고 자료