useEffectEvent는 이벤트를 Effect로부터 분리할 수 있게 해주는 React Hook이에요.
const onEvent = useEffectEvent(callback)
useEffectEvent(callback)컴포넌트의 최상위 레벨에서 useEffectEvent를 호출해서 Effect Event를 생성하세요.
import { useEffectEvent, useEffect } from 'react';
function ChatRoom({ roomId, theme }) {
const onConnected = useEffectEvent(() => {
showNotification('Connected!', theme);
});
}
Effect Event는 Effect 로직의 일부이지만, 이벤트 핸들러처럼 동작해요. 항상 렌더링의 최신 값(props나 state 같은)을 "볼" 수 있지만 Effect를 다시 동기화하지 않기 때문에, Effect 의존성에서 제외돼요. Events를 Effects로부터 분리하기에서 더 알아보세요.
callback: Effect Event의 로직을 담은 함수예요. 이 함수는 원하는 만큼의 인자를 받고 어떤 값이든 반환할 수 있어요. 반환된 Effect Event 함수를 호출할 때, callback은 항상 호출 시점에 렌더링에서 커밋된 최신 값에 접근해요.useEffectEvent는 callback과 동일한 타입 시그니처를 가진 Effect Event 함수를 반환해요.
이 함수는 useEffect, useLayoutEffect, useInsertionEffect 내부에서, 또는 같은 컴포넌트의 다른 Effect Event에서 호출할 수 있어요.
useEffectEvent는 Hook이기 때문에, 컴포넌트의 최상위 레벨이나 자체 Hook에서만 호출할 수 있어요. 반복문이나 조건문 안에서는 호출할 수 없어요. 필요하다면, 새 컴포넌트를 추출해서 Effect Event를 그쪽으로 옮기세요.eslint-plugin-react-hooks 린터가 이 제한을 강제해요.useEffectEvent를 사용하지 마세요. 이것은 버그를 숨기고 코드를 이해하기 어렵게 만들어요. Effect에서 발생하는 이벤트 로직에만 사용하세요.useState의 set 함수나 ref와 달리, Effect Event 함수는 안정적인 정체성을 가지지 않아요. 정체성이 매 렌더링마다 의도적으로 변경돼요:
// 🔴 잘못됨: Effect Event를 의존성에 포함
useEffect(() => {
onSomething();
}, [onSomething]); // ESLint가 경고할 거예요
이것은 의도적인 설계 선택이에요. Effect Event는 같은 컴포넌트의 Effect 내부에서만 호출되도록 설계됐어요. 로컬에서만 호출할 수 있고 다른 컴포넌트에 전달하거나 의존성 배열에 포함할 수 없기 때문에, 안정적인 정체성은 쓸모가 없고 오히려 버그를 숨길 수 있어요.
안정적이지 않은 정체성은 런타임 단언 역할을 해요: 코드가 함수 정체성에 잘못 의존하면, 매 렌더링마다 Effect가 다시 실행되는 것을 보게 되어 버그가 명확해져요.
이 설계는 Effect Event가 개념적으로 특정 effect에 속하고, 반응성을 회피하기 위한 범용 API가 아니라는 것을 강화해요.
컴포넌트의 최상위 레벨에서 useEffectEvent를 호출해서 Effect Event를 생성하세요:
const onConnected = useEffectEvent(() => {
if (!muted) {
showNotification('Connected!');
}
});
useEffectEvent는 이벤트 콜백을 받아서 Effect Event를 반환해요. Effect Event는 Effect를 다시 연결하지 않고도 Effect 내부에서 호출할 수 있는 함수예요:
useEffect(() => {
const connection = createConnection(roomId);
connection.on('connected', onConnected);
connection.connect();
return () => {
connection.disconnect();
}
}, [roomId]);
onConnected가 Effect Event이기 때문에, muted와 onConnect가 Effect 의존성에 없어요.
주의
의존성을 건너뛰기 위해 Effect Event를 사용하지 마세요
"불필요하다고" 생각하는 의존성을 나열하지 않기 위해
useEffectEvent를 사용하고 싶을 수 있어요. 하지만 이것은 버그를 숨기고 코드를 이해하기 어렵게 만들어요:// 🔴 잘못됨: Effect Event로 의존성 숨기기 const logVisit = useEffectEvent(() => { log(pageUrl); }); useEffect(() => { logVisit() }, []); // pageUrl 누락은 로그를 놓친다는 의미예요값이 Effect를 다시 실행하게 해야 한다면, 의존성으로 유지하세요. Effect를 다시 트리거하지 않아야 하는 로직에만 Effect Event를 사용하세요.
Events를 Effects로부터 분리하기에서 더 알아보세요.
Effect에서 setInterval이나 setTimeout을 사용할 때, 값이 변경될 때마다 타이머를 재시작하지 않고 렌더링의 최신 값을 읽고 싶을 때가 많아요.
이 카운터는 현재 increment 값만큼 count를 매초 증가시켜요. onTick Effect Event는 인터벌을 재시작하지 않고 최신 count와 increment를 읽어요:
import { useState, useEffect, useEffectEvent } from 'react';
export default function Timer() {
const [count, setCount] = useState(0);
const [increment, setIncrement] = useState(1);
const onTick = useEffectEvent(() => {
setCount(count + increment);
});
useEffect(() => {
const id = setInterval(() => {
onTick();
}, 1000);
return () => {
clearInterval(id);
};
}, []);
return (
<>
<h1>
Counter: {count}
<button onClick={() => setCount(0)}>Reset</button>
</h1>
<hr />
<p>
Every second, increment by:
<button disabled={increment === 0} onClick={() => {
setIncrement(i => i - 1);
}}>–</button>
<b>{increment}</b>
<button onClick={() => {
setIncrement(i => i + 1);
}}>+</button>
</p>
</>
);
}
button { margin: 10px; }
타이머가 실행 중일 때 increment 값을 변경해보세요. 카운터는 즉시 새로운 increment 값을 사용하지만, 타이머는 재시작 없이 부드럽게 계속 작동해요.
Effect에서 이벤트 리스너를 설정할 때, 콜백에서 렌더링의 최신 값을 읽어야 할 때가 많아요. useEffectEvent 없이는, 의존성에 값을 포함해야 해서 변경될 때마다 리스너가 제거되고 다시 추가돼요.
이 예제는 커서를 따라가는 점을 보여주는데, "Can move"가 체크되어 있을 때만이에요. onMove Effect Event는 Effect를 다시 실행하지 않고 항상 최신 canMove 값을 읽어요:
import { useState, useEffect, useEffectEvent } from 'react';
export default function App() {
const [position, setPosition] = useState({ x: 0, y: 0 });
const [canMove, setCanMove] = useState(true);
const onMove = useEffectEvent(e => {
if (canMove) {
setPosition({ x: e.clientX, y: e.clientY });
}
});
useEffect(() => {
window.addEventListener('pointermove', onMove);
return () => window.removeEventListener('pointermove', onMove);
}, []);
return (
<>
<label>
<input
type="checkbox"
checked={canMove}
onChange={e => setCanMove(e.target.checked)}
/>
The dot is allowed to move
</label>
<hr />
<div style={{
position: 'absolute',
backgroundColor: 'pink',
borderRadius: '50%',
opacity: 0.6,
transform: `translate(${position.x}px, ${position.y}px)`,
pointerEvents: 'none',
left: -20,
top: -20,
width: 40,
height: 40,
}} />
</>
);
}
body {
height: 200px;
}
체크박스를 토글하고 커서를 움직여보세요. 점은 체크박스 상태에 즉시 반응하지만, 이벤트 리스너는 컴포넌트가 마운트될 때 한 번만 설정돼요.
useEffectEvent의 일반적인 사용 사례는 Effect에 대한 응답으로 무언가를 하고 싶지만, 그 "무언가"가 반응하고 싶지 않은 값에 의존할 때예요.
이 예제에서, 채팅 컴포넌트는 방에 연결하고 연결되면 알림을 표시해요. 사용자는 체크박스로 알림을 음소거할 수 있어요. 하지만 사용자가 설정을 변경할 때마다 채팅방에 다시 연결하고 싶지 않아요:
// package.json (숨김)
{
"dependencies": {
"react": "latest",
"react-dom": "latest",
"react-scripts": "latest",
"toastify-js": "1.12.0"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test --env=jsdom",
"eject": "react-scripts eject"
}
}
import { useState, useEffect, useEffectEvent } from 'react';
import { createConnection } from './chat.js';
import { showNotification } from './notifications.js';
function ChatRoom({ roomId, muted }) {
const onConnected = useEffectEvent((roomId) => {
console.log('✅ Connected to ' + roomId + ' (muted: ' + muted + ')');
if (!muted) {
showNotification('Connected to ' + roomId);
}
});
useEffect(() => {
const connection = createConnection(roomId);
console.log('⏳ Connecting to ' + roomId + '...');
connection.on('connected', () => {
onConnected(roomId);
});
connection.connect();
return () => {
console.log('❌ Disconnected from ' + roomId);
connection.disconnect();
}
}, [roomId]);
return <h1>Welcome to the {roomId} room!</h1>;
}
export default function App() {
const [roomId, setRoomId] = useState('general');
const [muted, setMuted] = useState(false);
return (
<>
<label>
Choose the chat room:{' '}
<select
value={roomId}
onChange={e => setRoomId(e.target.value)}
>
<option value="general">general</option>
<option value="travel">travel</option>
<option value="music">music</option>
</select>
</label>
<label>
<input
type="checkbox"
checked={muted}
onChange={e => setMuted(e.target.checked)}
/>
Mute notifications
</label>
<hr />
<ChatRoom
roomId={roomId}
muted={muted}
/>
</>
);
}
// src/chat.js
const serverUrl = 'https://localhost:1234';
export function createConnection(roomId) {
// A real implementation would actually connect to the server
let connectedCallback;
let timeout;
return {
connect() {
timeout = setTimeout(() => {
if (connectedCallback) {
connectedCallback();
}
}, 100);
},
on(event, callback) {
if (connectedCallback) {
throw Error('Cannot add the handler twice.');
}
if (event !== 'connected') {
throw Error('Only "connected" event is supported.');
}
connectedCallback = callback;
},
disconnect() {
clearTimeout(timeout);
}
};
}
// src/notifications.js
import Toastify from 'toastify-js';
import 'toastify-js/src/toastify.css';
export function showNotification(message, theme) {
Toastify({
text: message,
duration: 2000,
gravity: 'top',
position: 'right',
style: {
background: theme === 'dark' ? 'black' : 'white',
color: theme === 'dark' ? 'white' : 'black',
},
}).showToast();
}
label { display: block; margin-top: 10px; }
방을 전환해보세요. 채팅이 다시 연결되고 알림을 표시해요. 이제 알림을 음소거해보세요. muted가 Effect가 아닌 Effect Event 내부에서 읽히기 때문에, 채팅은 연결된 상태를 유지해요.
자체 커스텀 Hook 내부에서 useEffectEvent를 사용할 수 있어요. 이렇게 하면 일부 값을 비반응형으로 유지하면서 Effect를 캡슐화하는 재사용 가능한 Hook을 만들 수 있어요:
import { useState, useEffect, useEffectEvent } from 'react';
function useInterval(callback, delay) {
const onTick = useEffectEvent(callback);
useEffect(() => {
if (delay === null) {
return;
}
const id = setInterval(() => {
onTick();
}, delay);
return () => clearInterval(id);
}, [delay]);
}
function Counter({ incrementBy }) {
const [count, setCount] = useState(0);
useInterval(() => {
setCount(c => c + incrementBy);
}, 1000);
return (
<div>
<h2>Count: {count}</h2>
<p>Incrementing by {incrementBy} every second</p>
</div>
);
}
export default function App() {
const [incrementBy, setIncrementBy] = useState(1);
return (
<>
<label>
Increment by:{' '}
<select
value={incrementBy}
onChange={(e) => setIncrementBy(Number(e.target.value))}
>
<option value={1}>1</option>
<option value={5}>5</option>
<option value={10}>10</option>
</select>
</label>
<hr />
<Counter incrementBy={incrementBy} />
</>
);
}
label { display: block; margin-bottom: 8px; }
이 예제에서, useInterval은 인터벌을 설정하는 커스텀 Hook이에요. 전달된 callback이 Effect Event로 감싸지기 때문에, 매 렌더링마다 새로운 callback이 전달되더라도 인터벌이 재설정되지 않아요.
이 오류는 컴포넌트의 렌더링 단계 중에 Effect Event 함수를 호출하고 있다는 뜻이에요. Effect Event는 Effect나 다른 Effect Event 내부에서만 호출할 수 있어요.
function MyComponent({ data }) {
const onLog = useEffectEvent(() => {
console.log(data);
});
// 🔴 잘못됨: 렌더링 중에 호출
onLog();
// ✅ 올바름: Effect에서 호출
useEffect(() => {
onLog();
}, []);
return <div>{data}</div>;
}
렌더링 중에 로직을 실행해야 한다면, useEffectEvent로 감싸지 마세요. 로직을 직접 호출하거나 Effect로 옮기세요.
"useEffectEvent에서 반환된 함수는 의존성 배열에 포함되면 안 됩니다" 같은 경고가 보인다면, 의존성에서 Effect Event를 제거하세요:
const onSomething = useEffectEvent(() => {
// ...
});
// 🔴 잘못됨: 의존성에 Effect Event 포함
useEffect(() => {
onSomething();
}, [onSomething]);
// ✅ 올바름: 의존성에 Effect Event 없음
useEffect(() => {
onSomething();
}, []);
Effect Event는 의존성으로 나열하지 않고 Effect에서 호출되도록 설계됐어요. 함수 정체성이 의도적으로 안정적이지 않기 때문에 린터가 이를 강제해요. 포함하면 매 렌더링마다 Effect가 다시 실행될 거예요.
"... 은 React Hook useEffectEvent로 생성된 함수이고, Effect와 Effect Event에서만 호출할 수 있습니다" 같은 경고가 보인다면, 함수를 잘못된 곳에서 호출하고 있는 거예요:
const onSomething = useEffectEvent(() => {
console.log(value);
});
// 🔴 잘못됨: 이벤트 핸들러에서 호출
function handleClick() {
onSomething();
}
// 🔴 잘못됨: 자식 컴포넌트에 전달
return <Child onSomething={onSomething} />;
// ✅ 올바름: Effect에서 호출
useEffect(() => {
onSomething();
}, []);
Effect Event는 정의된 컴포넌트의 Effect에서 로컬로 사용되도록 특별히 설계됐어요. 이벤트 핸들러용 콜백이 필요하거나 자식에 전달해야 한다면, 일반 함수나 useCallback을 대신 사용하세요.