이벤트 핸들러는 같은 상호작용을 다시 수행할 때만 다시 실행돼요. 이벤트 핸들러와 달리, Effect는 읽고 있는 어떤 값(예: prop이나 state 변수)이 마지막 렌더링 때와 다르면 다시 동기화해요. 때로는 두 가지 동작을 섞고 싶을 때도 있어요: 어떤 값에는 반응해서 다시 실행되지만, 다른 값에는 반응하지 않는 Effect 말이에요. 이 페이지에서는 그걸 어떻게 하는지 알려드릴게요.
먼저, 이벤트 핸들러와 Effect의 차이를 다시 한번 정리해볼게요.
채팅방 컴포넌트를 구현한다고 상상해보세요. 요구사항이 이렇게 생겼다고 해봐요:
이미 코드를 구현했다고 해봅시다. 근데 이 코드를 어디에 둬야 할지 잘 모르겠어요. 이벤트 핸들러를 써야 할까요, 아니면 Effect를 써야 할까요? 이 질문에 답해야 할 때마다, 왜 이 코드가 실행되어야 하는지를 생각해보세요.
사용자 입장에서 메시지를 보내는 건, 특정 "Send" 버튼이 클릭되었기 때문에 일어나야 해요. 만약 다른 시점에 또는 다른 이유로 메시지를 보내면 사용자가 꽤 화가 날 거예요. 그래서 메시지 보내기는 이벤트 핸들러여야 해요. 이벤트 핸들러를 쓰면 특정 상호작용을 처리할 수 있어요:
function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');
// ...
function handleSendClick() {
sendMessage(message);
}
// ...
return (
<>
<input value={message} onChange={e => setMessage(e.target.value)} />
<button onClick={handleSendClick}>Send</button>
</>
);
}
이벤트 핸들러를 사용하면, sendMessage(message)가 사용자가 버튼을 누를 때만 실행된다는 걸 확신할 수 있어요.
컴포넌트를 채팅방에 연결된 상태로 유지해야 한다는 것도 기억하세요. 그 코드는 어디에 둬야 할까요?
이 코드를 실행하는 이유는 특정한 상호작용이 아니에요. 사용자가 왜, 어떻게 채팅방 화면으로 이동했는지는 중요하지 않아요. 이제 사용자가 그 화면을 보고 있고 상호작용할 수 있으니까, 컴포넌트는 선택된 채팅 서버에 연결된 상태를 유지해야 해요. 채팅방 컴포넌트가 앱의 초기 화면이고 사용자가 아무런 상호작용도 하지 않았더라도, 여전히 연결해야 하거든요. 그래서 이건 Effect예요:
function ChatRoom({ roomId }) {
// ...
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
connection.disconnect();
};
}, [roomId]);
// ...
}
이 코드를 쓰면, 사용자가 수행한 특정 상호작용에 관계없이 현재 선택된 채팅 서버에 항상 활성 연결이 있다는 걸 확신할 수 있어요. 사용자가 앱을 열기만 했든, 다른 방을 선택했든, 다른 화면으로 갔다가 돌아왔든, Effect는 컴포넌트가 현재 선택된 방과 동기화된 상태를 유지하도록 보장하고, 필요할 때마다 다시 연결할 거예요.
// App.js
import { useState, useEffect } from 'react';
import { createConnection, sendMessage } from './chat.js';
const serverUrl = 'https://localhost:1234';
function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => connection.disconnect();
}, [roomId]);
function handleSendClick() {
sendMessage(message);
}
return (
<>
<h1>Welcome to the {roomId} room!</h1>
<input value={message} onChange={e => setMessage(e.target.value)} />
<button onClick={handleSendClick}>Send</button>
</>
);
}
export default function App() {
const [roomId, setRoomId] = useState('general');
const [show, setShow] = 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>
<button onClick={() => setShow(!show)}>
{show ? 'Close chat' : 'Open chat'}
</button>
{show && <hr />}
{show && <ChatRoom roomId={roomId} />}
</>
);
}
// chat.js
export function sendMessage(message) {
console.log('🔵 You sent: ' + message);
}
export function createConnection(serverUrl, roomId) {
// A real implementation would actually connect to the server
return {
connect() {
console.log('✅ Connecting to "' + roomId + '" room at ' + serverUrl + '...');
},
disconnect() {
console.log('❌ Disconnected from "' + roomId + '" room at ' + serverUrl);
}
};
}
직관적으로 말하면, 이벤트 핸들러는 항상 "수동으로" 트리거된다고 할 수 있어요. 예를 들어 버튼을 클릭하는 것처럼요. 반면에 Effect는 "자동"이에요: 동기화 상태를 유지하기 위해 필요한 만큼 실행되고 다시 실행되죠.
이걸 더 정확하게 생각하는 방법이 있어요.
컴포넌트 본문 안에서 선언된 props, state, 그리고 변수들을 반응형 값(reactive values)이라고 불러요. 이 예제에서 serverUrl은 반응형 값이 아니지만, roomId와 message는 반응형 값이에요. 이것들은 렌더링 데이터 흐름에 참여하거든요:
const serverUrl = 'https://localhost:1234';
function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');
// ...
}
이런 반응형 값들은 리렌더링으로 인해 변경될 수 있어요. 예를 들어, 사용자가 message를 수정하거나 드롭다운에서 다른 roomId를 선택할 수 있죠. 이벤트 핸들러와 Effect는 변경에 다르게 반응해요:
이 차이를 설명하기 위해 이전 예제를 다시 살펴볼게요.
이 코드 줄을 보세요. 이 로직이 반응형이어야 할까요, 아닐까요?
// ...
sendMessage(message);
// ...
사용자 입장에서, message가 변경된 것이 메시지를 보내고 싶다는 의미는 아니에요. 사용자가 타이핑 중이라는 뜻일 뿐이에요. 다시 말해서, 메시지를 보내는 로직은 반응형이면 안 돼요. 반응형 값이 변경되었다는 이유만으로 다시 실행되면 안 되거든요. 그래서 이벤트 핸들러에 속하는 거예요:
function handleSendClick() {
sendMessage(message);
}
이벤트 핸들러는 반응형이 아니니까, sendMessage(message)는 사용자가 Send 버튼을 클릭할 때만 실행될 거예요.
이제 이 줄들로 돌아가볼게요:
// ...
const connection = createConnection(serverUrl, roomId);
connection.connect();
// ...
사용자 입장에서, roomId가 변경된 것은 다른 방에 연결하고 싶다는 의미예요. 다시 말해서, 방에 연결하는 로직은 반응형이어야 해요. 이 코드 줄들이 반응형 값을 "따라가면서" 그 값이 다르면 다시 실행되길 원하는 거예요. 그래서 Effect에 속하는 거죠:
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
connection.disconnect()
};
}, [roomId]);
Effect는 반응형이기 때문에, createConnection(serverUrl, roomId)와 connection.connect()는 roomId의 모든 고유한 값에 대해 실행될 거예요. Effect가 채팅 연결을 현재 선택된 방에 동기화된 상태로 유지해주는 거죠.
반응형 로직과 비반응형 로직을 섞고 싶을 때 상황이 좀 더 까다로워져요.
예를 들어, 사용자가 채팅에 연결되었을 때 알림을 보여주고 싶다고 상상해보세요. 올바른 색상으로 알림을 보여줄 수 있도록 props에서 현재 테마(dark 또는 light)를 읽어요:
function ChatRoom({ roomId, theme }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on('connected', () => {
showNotification('Connected!', theme);
});
connection.connect();
// ...
그런데 theme은 반응형 값이에요 (리렌더링의 결과로 변경될 수 있으니까요), 그리고 Effect가 읽는 모든 반응형 값은 의존성으로 선언되어야 해요. 이제 theme을 Effect의 의존성으로 지정해야 해요:
function ChatRoom({ roomId, theme }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on('connected', () => {
showNotification('Connected!', theme);
});
connection.connect();
return () => {
connection.disconnect()
};
}, [roomId, theme]); // ✅ 모든 의존성이 선언됨
// ...
이 예제를 가지고 놀면서 이 사용자 경험에 어떤 문제가 있는지 찾아보세요:
// App.js
import { useState, useEffect } from 'react';
import { createConnection, sendMessage } from './chat.js';
import { showNotification } from './notifications.js';
const serverUrl = 'https://localhost:1234';
function ChatRoom({ roomId, theme }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on('connected', () => {
showNotification('Connected!', theme);
});
connection.connect();
return () => connection.disconnect();
}, [roomId, theme]);
return <h1>Welcome to the {roomId} room!</h1>
}
export default function App() {
const [roomId, setRoomId] = useState('general');
const [isDark, setIsDark] = 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={isDark}
onChange={e => setIsDark(e.target.checked)}
/>
Use dark theme
</label>
<hr />
<ChatRoom
roomId={roomId}
theme={isDark ? 'dark' : 'light'}
/>
</>
);
}
// chat.js
export function createConnection(serverUrl, 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);
}
};
}
// 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();
}
roomId가 변경되면 예상대로 채팅이 다시 연결돼요. 하지만 theme도 의존성이기 때문에, 다크 테마와 라이트 테마 사이를 전환할 때마다 채팅도 다시 연결돼요. 이건 좋지 않죠!
다시 말해서, Effect 안에 있긴 하지만 (Effect는 반응형이니까) 이 줄이 반응형이 되는 걸 원하지 않아요:
// ...
showNotification('Connected!', theme);
// ...
이 비반응형 로직을 주변의 반응형 Effect에서 분리하는 방법이 필요해요.
참고: 이 섹션에서는 아직 안정 버전의 React로 릴리스되지 않은 실험적인 API를 설명해요. 그래서 아직 프로덕션에서는 사용할 수 없을 수 있어요.
useEffectEvent라는 특별한 Hook을 사용해서 이 비반응형 로직을 Effect에서 추출하세요:
import { useEffect, useEffectEvent } from 'react';
function ChatRoom({ roomId, theme }) {
const onConnected = useEffectEvent(() => {
showNotification('Connected!', theme);
});
// ...
여기서 onConnected는 Effect Event라고 불려요. Effect 로직의 일부이긴 하지만, 이벤트 핸들러처럼 동작해요. 안의 로직은 반응형이 아니고, 항상 props와 state의 최신 값을 "볼" 수 있어요.
이제 Effect 안에서 onConnected Effect Event를 호출할 수 있어요:
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]); // ✅ 모든 의존성이 선언됨
// ...
이렇게 하면 문제가 해결돼요. Effect의 의존성 목록에서 theme을 제거해야 한다는 점에 주목하세요. 왜냐하면 Effect에서 더 이상 직접 사용되지 않으니까요. 또한 onConnected를 의존성에 추가할 필요도 없어요. Effect Event는 반응형이 아니기 때문에 의존성에서 빠져야 해요.
새로운 동작이 예상대로 작동하는지 확인해보세요:
// App.js
import { useState, useEffect } from 'react';
import { useEffectEvent } from 'react';
import { createConnection, sendMessage } from './chat.js';
import { showNotification } from './notifications.js';
const serverUrl = 'https://localhost:1234';
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]);
return <h1>Welcome to the {roomId} room!</h1>
}
export default function App() {
const [roomId, setRoomId] = useState('general');
const [isDark, setIsDark] = 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={isDark}
onChange={e => setIsDark(e.target.checked)}
/>
Use dark theme
</label>
<hr />
<ChatRoom
roomId={roomId}
theme={isDark ? 'dark' : 'light'}
/>
</>
);
}
// chat.js
export function createConnection(serverUrl, 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);
}
};
}
// 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();
}
Effect Event는 이벤트 핸들러와 매우 비슷하다고 생각할 수 있어요. 주요 차이점은 이벤트 핸들러는 사용자 상호작용에 반응해서 실행되는 반면, Effect Event는 Effect에서 여러분이 직접 트리거한다는 거예요. Effect Event를 사용하면 Effect의 반응성과 반응형이면 안 되는 코드 사이의 "체인을 끊을" 수 있어요.
Effect Event를 사용하면 의존성 린터를 억제하고 싶은 유혹이 들 수 있는 많은 패턴을 고칠 수 있어요.
예를 들어, 페이지 방문을 로깅하는 Effect가 있다고 해봐요:
function Page() {
useEffect(() => {
logVisit();
}, []);
// ...
}
나중에 사이트에 여러 경로(route)를 추가했어요. 이제 Page 컴포넌트가 현재 경로를 가진 url prop을 받아요. logVisit 호출의 일부로 url을 전달하고 싶지만, 의존성 린터가 불평하네요:
function Page({ url }) {
useEffect(() => {
logVisit(url);
}, []); // 🔴 React Hook useEffect has a missing dependency: 'url'
// ...
}
코드가 뭘 해야 하는지 생각해보세요. 각 URL이 다른 페이지를 나타내니까, 다른 URL에 대해 별도의 방문을 로깅하고 싶어요. 다시 말해서, 이 logVisit 호출은 url에 대해 반응형이어야 해요. 그래서 이 경우에는 의존성 린터를 따르고 url을 의존성으로 추가하는 게 맞아요:
function Page({ url }) {
useEffect(() => {
logVisit(url);
}, [url]); // ✅ 모든 의존성이 선언됨
// ...
}
이제 모든 페이지 방문과 함께 장바구니에 있는 아이템 수를 포함하고 싶다고 해봐요:
function Page({ url }) {
const { items } = useContext(ShoppingCartContext);
const numberOfItems = items.length;
useEffect(() => {
logVisit(url, numberOfItems);
}, [url]); // 🔴 React Hook useEffect has a missing dependency: 'numberOfItems'
// ...
}
Effect 안에서 numberOfItems를 사용했으니까 린터가 의존성에 추가하라고 요청해요. 하지만 logVisit 호출이 numberOfItems에 대해 반응형이 되길 원하지 않아요. 사용자가 장바구니에 뭔가를 넣어서 numberOfItems가 변경되더라도, 이것이 사용자가 페이지를 다시 방문했다는 의미는 아니에요. 다시 말해서, 페이지 방문은 어떤 의미에서 "이벤트"예요. 특정 시점에 일어나는 거죠.
코드를 두 부분으로 나누세요:
function Page({ url }) {
const { items } = useContext(ShoppingCartContext);
const numberOfItems = items.length;
const onVisit = useEffectEvent(visitedUrl => {
logVisit(visitedUrl, numberOfItems);
});
useEffect(() => {
onVisit(url);
}, [url]); // ✅ 모든 의존성이 선언됨
// ...
}
여기서 onVisit은 Effect Event예요. 안의 코드는 반응형이 아니에요. 그래서 numberOfItems (또는 다른 반응형 값!)를 사용해도 주변 코드가 변경 시 다시 실행될 걱정 없이 쓸 수 있어요.
반면에 Effect 자체는 반응형으로 남아 있어요. Effect 안의 코드가 url prop을 사용하니까, 다른 url로 리렌더링할 때마다 Effect가 다시 실행될 거예요. 그러면 차례로 onVisit Effect Event가 호출되겠죠.
결과적으로 url이 변경될 때마다 logVisit을 호출하게 되고, 항상 최신 numberOfItems를 읽어요. 하지만 numberOfItems만 변경되면 코드가 다시 실행되지 않아요.
참고
onVisit()을 인자 없이 호출하고 안에서url을 읽으면 안 되냐고 궁금할 수 있어요:const onVisit = useEffectEvent(() => { logVisit(url, numberOfItems); }); useEffect(() => { onVisit(); }, [url]);이렇게 해도 동작하긴 하지만,
url을 Effect Event에 명시적으로 전달하는 게 더 좋아요.url을 Effect Event의 인자로 전달하면, 다른url을 가진 페이지를 방문하는 것이 사용자 입장에서 별개의 "이벤트"라는 것을 명시하는 거예요.visitedUrl은 발생한 "이벤트"의 일부인 거죠:const onVisit = useEffectEvent(visitedUrl => { logVisit(visitedUrl, numberOfItems); }); useEffect(() => { onVisit(url); }, [url]);Effect Event가 명시적으로
visitedUrl을 "요청"하기 때문에, 이제 실수로 Effect의 의존성에서url을 제거할 수 없어요.url의존성을 제거하면 (서로 다른 페이지 방문이 하나로 카운트되게 되니까) 린터가 경고할 거예요.onVisit이url에 대해 반응형이길 원하니까,url을 안에서 읽는 대신 (거기서는 반응형이 아닐 테니까) Effect 에서 전달하는 거예요.이건 Effect 안에 비동기 로직이 있을 때 특히 중요해요:
const onVisit = useEffectEvent(visitedUrl => { logVisit(visitedUrl, numberOfItems); }); useEffect(() => { setTimeout(() => { onVisit(url); }, 5000); // 방문 로깅을 지연시킴 }, [url]);여기서
onVisit안의url은 최신url에 해당해요 (이미 변경되었을 수도 있어요). 하지만visitedUrl은 이 Effect (와 이onVisit호출)가 실행되게 한 원래의url에 해당해요.
기존 코드베이스에서 가끔 린트 규칙이 이렇게 억제된 걸 볼 수 있어요:
function Page({ url }) {
const { items } = useContext(ShoppingCartContext);
const numberOfItems = items.length;
useEffect(() => {
logVisit(url, numberOfItems);
// 🔴 이렇게 린터를 억제하는 건 피하세요:
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [url]);
// ...
}
린터를 절대 억제하지 않는 걸 권장해요.
규칙을 억제하는 것의 첫 번째 단점은, 코드에 새로운 반응형 의존성을 도입했을 때 Effect가 그것에 "반응"해야 한다고 React가 더 이상 경고해주지 않는다는 거예요. 앞의 예제에서 React가 알려줬기 때문에 url을 의존성에 추가했잖아요. 린터를 비활성화하면 그 Effect에 대한 향후 편집에서 더 이상 그런 알림을 받지 못할 거예요. 이건 버그로 이어지죠.
린터를 억제해서 생긴 혼란스러운 버그의 예를 하나 보여드릴게요. 이 예제에서 handleMove 함수는 점이 커서를 따라가야 하는지 결정하기 위해 현재 canMove state 변수 값을 읽어야 해요. 그런데 handleMove 안에서 canMove가 항상 true예요.
왜 그런지 알겠나요?
import { useState, useEffect } from 'react';
export default function App() {
const [position, setPosition] = useState({ x: 0, y: 0 });
const [canMove, setCanMove] = useState(true);
function handleMove(e) {
if (canMove) {
setPosition({ x: e.clientX, y: e.clientY });
}
}
useEffect(() => {
window.addEventListener('pointermove', handleMove);
return () => window.removeEventListener('pointermove', handleMove);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
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,
}} />
</>
);
}
이 코드의 문제는 의존성 린터를 억제한 데 있어요. 억제를 제거하면, 이 Effect가 handleMove 함수에 의존해야 한다는 걸 알 수 있어요. 이건 말이 돼요: handleMove는 컴포넌트 본문 안에서 선언되었으니까 반응형 값이거든요. 모든 반응형 값은 의존성으로 지정되어야 해요. 그렇지 않으면 시간이 지남에 따라 오래된(stale) 값이 될 수 있어요!
원래 코드의 작성자가 React에게 Effect가 어떤 반응형 값에도 의존하지 않는다고 ([]) "거짓말"을 한 거예요. 그래서 React가 canMove가 변경된 후에 (그리고 handleMove도 함께) Effect를 다시 동기화하지 않은 거예요. React가 Effect를 다시 동기화하지 않았기 때문에, 리스너로 붙어있는 handleMove는 초기 렌더링 때 만들어진 handleMove 함수예요. 초기 렌더링 때 canMove는 true였으니까, 초기 렌더링의 handleMove는 영원히 그 값을 보게 되는 거죠.
린터를 절대 억제하지 않으면, 오래된 값 문제를 절대 겪지 않을 거예요.
useEffectEvent를 사용하면 린터에게 "거짓말"할 필요가 없고, 코드가 예상대로 동작해요:
import { useState, useEffect } from 'react';
import { 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,
}} />
</>
);
}
이건 useEffectEvent가 항상 정답이라는 뜻은 아니에요. 반응형이 되길 원하지 않는 코드 줄에만 적용해야 해요. 위의 샌드박스에서는 Effect의 코드가 canMove에 대해 반응형이 되길 원하지 않았어요. 그래서 Effect Event를 추출하는 게 합리적이었죠.
린터 억제의 다른 올바른 대안들은 Effect 의존성 제거하기를 읽어보세요.
Effect Event를 사용할 수 있는 방법은 매우 제한적이에요:
예를 들어, Effect Event를 이렇게 선언하고 전달하지 마세요:
function Timer() {
const [count, setCount] = useState(0);
const onTick = useEffectEvent(() => {
setCount(count + 1);
});
useTimer(onTick, 1000); // 🔴 피하세요: Effect Event를 전달하기
return <h1>{count}</h1>
}
function useTimer(callback, delay) {
useEffect(() => {
const id = setInterval(() => {
callback();
}, delay);
return () => {
clearInterval(id);
};
}, [delay, callback]); // "callback"을 의존성에 지정해야 함
}
대신, 항상 Effect Event를 그것을 사용하는 Effect 바로 옆에서 선언하세요:
function Timer() {
const [count, setCount] = useState(0);
useTimer(() => {
setCount(count + 1);
}, 1000);
return <h1>{count}</h1>
}
function useTimer(callback, delay) {
const onTick = useEffectEvent(() => {
callback();
});
useEffect(() => {
const id = setInterval(() => {
onTick(); // ✅ 좋아요: Effect 안에서 로컬로만 호출됨
}, delay);
return () => {
clearInterval(id);
};
}, [delay]); // "onTick"(Effect Event)을 의존성으로 지정할 필요 없음
}
Effect Event는 Effect 코드의 비반응형 "조각"이에요. 그것들을 사용하는 Effect 바로 옆에 있어야 해요.
이 Timer 컴포넌트는 매초 증가하는 count state 변수를 유지해요. 증가하는 값은 increment state 변수에 저장되어 있어요. increment 변수는 플러스, 마이너스 버튼으로 제어할 수 있어요.
하지만 플러스 버튼을 아무리 많이 클릭해도, 카운터는 여전히 매초 1씩 증가해요. 이 코드에 뭐가 잘못된 걸까요? Effect 코드 안에서 increment가 왜 항상 1인 걸까요? 실수를 찾아서 고쳐보세요.
힌트: 이 코드를 고치려면 규칙을 따르기만 하면 돼요.
import { useState, useEffect } from 'react';
export default function Timer() {
const [count, setCount] = useState(0);
const [increment, setIncrement] = useState(1);
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + increment);
}, 1000);
return () => {
clearInterval(id);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
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>
</>
);
}
보통 그렇듯이, Effect에서 버그를 찾을 때는 린터 억제를 검색하는 것부터 시작하세요.
억제 코멘트를 제거하면, React가 이 Effect의 코드가 increment에 의존하는데 어떤 반응형 값에도 의존하지 않는다고 ([]) React에게 "거짓말"했다고 알려줄 거예요. increment를 의존성 배열에 추가하세요:
import { useState, useEffect } from 'react';
export default function Timer() {
const [count, setCount] = useState(0);
const [increment, setIncrement] = useState(1);
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + increment);
}, 1000);
return () => {
clearInterval(id);
};
}, [increment]);
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>
</>
);
}
이제 increment가 변경되면, React가 Effect를 다시 동기화하고, 인터벌이 재시작될 거예요.
이 Timer 컴포넌트는 매초 증가하는 count state 변수를 유지해요. 증가하는 값은 increment state 변수에 저장되어 있고, 플러스와 마이너스 버튼으로 제어할 수 있어요. 예를 들어, 플러스 버튼을 아홉 번 누르면 count가 이제 매초 1이 아니라 10씩 증가하는 걸 볼 수 있어요.
이 사용자 인터페이스에 작은 문제가 있어요. 1초에 한 번보다 빠르게 플러스 또는 마이너스 버튼을 계속 누르면, 타이머 자체가 멈춘 것처럼 보여요. 마지막으로 버튼을 누른 후 1초가 지나야 다시 시작되거든요. 왜 이런 일이 일어나는지 찾아서, 타이머가 중단 없이 매초 똑딱거리도록 고쳐보세요.
힌트: 타이머를 설정하는 Effect가 increment 값에 "반응"하는 것 같아요. setCount를 호출하기 위해 현재 increment 값을 사용하는 줄이 정말 반응형이어야 할까요?
import { useState, useEffect } from 'react';
import { useEffectEvent } from 'react';
export default function Timer() {
const [count, setCount] = useState(0);
const [increment, setIncrement] = useState(1<);
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + increment);
}, 1000);
return () => {
clearInterval(id);
};
}, [increment]);
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>
</>
);
}
문제는 Effect 안의 코드가 increment state 변수를 사용한다는 거예요. Effect의 의존성이니까 increment가 변경될 때마다 Effect가 다시 동기화되고, 인터벌이 클리어돼요. 인터벌이 발동할 기회를 갖기 전에 매번 클리어해버리면, 타이머가 멈춘 것처럼 보이는 거예요.
이 문제를 해결하려면 Effect에서 onTick Effect Event를 추출하세요:
import { useState, useEffect } from 'react';
import { useEffectEvent } from 'react';
export default function Timer() {
const [count, setCount] = useState(0);
const [increment, setIncrement] = useState(1);
const onTick = useEffectEvent(() => {
setCount(c => c + 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>
</>
);
}
onTick은 Effect Event이기 때문에, 안의 코드는 반응형이 아니에요. increment가 변경되더라도 어떤 Effect도 트리거하지 않아요.
이 예제에서는 인터벌 딜레이를 커스터마이즈할 수 있어요. delay state 변수에 저장되어 있고, 두 개의 버튼으로 업데이트돼요. 그런데 delay가 1000밀리초(즉, 1초)가 될 때까지 "plus 100 ms" 버튼을 눌러도, 타이머가 여전히 매우 빠르게 (100ms마다) 증가하는 걸 볼 수 있어요. delay 변경이 무시되는 것 같아요. 버그를 찾아서 고쳐보세요.
힌트: Effect Event 안의 코드는 반응형이 아니에요. setInterval 호출이 다시 실행되길 원하는 경우가 있을까요?
import { useState, useEffect } from 'react';
import { useEffectEvent } from 'react';
export default function Timer() {
const [count, setCount] = useState(0);
const [increment, setIncrement] = useState(1);
const [delay, setDelay] = useState(100);
const onTick = useEffectEvent(() => {
setCount(c => c + increment);
});
const onMount = useEffectEvent(() => {
return setInterval(() => {
onTick();
}, delay);
});
useEffect(() => {
const id = onMount();
return () => {
clearInterval(id);
}
}, []);
return (
<>
<h1>
Counter: {count}
<button onClick={() => setCount(0)}>Reset</button>
</h1>
<hr />
<p>
Increment by:
<button disabled={increment === 0} onClick={() => {
setIncrement(i => i - 1);
}}>–</button>
<b>{increment}</b>
<button onClick={() => {
setIncrement(i => i + 1);
}}>+</button>
</p>
<p>
Increment delay:
<button disabled={delay === 100} onClick={() => {
setDelay(d => d - 100);
}}>–100 ms</button>
<b>{delay} ms</b>
<button onClick={() => {
setDelay(d => d + 100);
}}>+100 ms</button>
</p>
</>
);
}
위 예제의 문제는 코드가 실제로 어떻게 동작해야 하는지 고려하지 않고 onMount라는 Effect Event를 추출했다는 거예요. Effect Event는 특정한 이유가 있을 때만 추출해야 해요: 코드의 일부를 비반응형으로 만들고 싶을 때요. 하지만 setInterval 호출은 delay state 변수에 대해 반응형이어야 해요. delay가 변경되면 인터벌을 처음부터 다시 설정하고 싶으니까요! 이 코드를 고치려면 모든 반응형 코드를 다시 Effect 안으로 넣으세요:
import { useState, useEffect } from 'react';
import { useEffectEvent } from 'react';
export default function Timer() {
const [count, setCount] = useState(0);
const [increment, setIncrement] = useState(1);
const [delay, setDelay] = useState(100);
const onTick = useEffectEvent(() => {
setCount(c => c + increment);
});
useEffect(() => {
const id = setInterval(() => {
onTick();
}, delay);
return () => {
clearInterval(id);
}
}, [delay]);
return (
<>
<h1>
Counter: {count}
<button onClick={() => setCount(0)}>Reset</button>
</h1>
<hr />
<p>
Increment by:
<button disabled={increment === 0} onClick={() => {
setIncrement(i => i - 1);
}}>–</button>
<b>{increment}</b>
<button onClick={() => {
setIncrement(i => i + 1);
}}>+</button>
</p>
<p>
Increment delay:
<button disabled={delay === 100} onClick={() => {
setDelay(d => d - 100);
}}>–100 ms</button>
<b>{delay} ms</b>
<button onClick={() => {
setDelay(d => d + 100);
}}>+100 ms</button>
</p>
</>
);
}
일반적으로, 코드의 목적이 아니라 타이밍에 초점을 맞춘 onMount 같은 함수는 의심해봐야 해요. 처음에는 "더 설명적"으로 느껴질 수 있지만, 실제 의도를 흐리게 해요. 경험칙으로, Effect Event는 사용자 입장에서 일어나는 일에 해당해야 해요. 예를 들어 onMessage, onTick, onVisit, onConnected 같은 게 좋은 Effect Event 이름이에요. 안의 코드는 아마 반응형일 필요가 없겠죠. 반면에 onMount, onUpdate, onUnmount, onAfterRender 같은 건 너무 일반적이어서 반응형이어야 하는 코드를 실수로 넣기 쉬워요. 그래서 Effect Event의 이름을 코드가 언제 실행되는지가 아니라 사용자가 무슨 일이 일어났다고 생각하는지에 따라 지어야 해요.
채팅방에 참여하면 이 컴포넌트가 알림을 보여줘요. 하지만 알림을 즉시 보여주지 않아요. 대신, 사용자가 UI를 둘러볼 시간을 주기 위해 알림이 인위적으로 2초 지연돼요.
이건 거의 잘 동작하지만, 버그가 있어요. 드롭다운을 "general"에서 "travel"로, 그다음 "music"으로 아주 빠르게 바꿔보세요. 충분히 빠르게 하면 두 개의 알림이 보이는데 (예상대로!), 둘 다 "Welcome to music"이라고 나와요.
"general"에서 "travel"로, 그다음 "music"으로 빠르게 전환할 때, 첫 번째 알림은 "Welcome to travel"이고 두 번째는 "Welcome to music"이 되도록 고쳐보세요. (추가 챌린지로, 이미 올바른 방을 보여주도록 만들었다면, 마지막 알림만 표시되도록 코드를 변경해보세요.)
힌트: Effect는 어떤 방에 연결했는지 알고 있어요. Effect Event에 전달하고 싶은 정보가 있을까요?
// App.js
import { useState, useEffect } from 'react';
import { useEffectEvent } from 'react';
import { createConnection, sendMessage } from './chat.js';
import { showNotification } from './notifications.js';
const serverUrl = 'https://localhost:1234';
function ChatRoom({ roomId, theme }) {
const onConnected = useEffectEvent(() => {
showNotification('Welcome to ' + roomId, theme);
});
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on('connected', () => {
setTimeout(() => {
onConnected();
}, 2000);
});
connection.connect();
return () => connection.disconnect();
}, [roomId]);
return <h1>Welcome to the {roomId} room!</h1>
}
export default function App() {
const [roomId, setRoomId] = useState('general');
const [isDark, setIsDark] = 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={isDark}
onChange={e => setIsDark(e.target.checked)}
/>
Use dark theme
</label>
<hr />
<ChatRoom
roomId={roomId}
theme={isDark ? 'dark' : 'light'}
/>
</>
);
}
// chat.js
export function createConnection(serverUrl, 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);
}
};
}
// 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();
}
Effect Event 안에서 roomId는 Effect Event가 호출된 시점의 값이에요.
Effect Event가 2초 지연으로 호출돼요. travel 방에서 music 방으로 빠르게 전환하면, travel 방의 알림이 표시될 때쯤 roomId는 이미 "music"이에요. 그래서 두 알림 모두 "Welcome to music"이라고 나오는 거예요.
이 문제를 고치려면, Effect Event 안에서 최신 roomId를 읽는 대신, 아래의 connectedRoomId처럼 Effect Event의 파라미터로 만드세요. 그리고 Effect에서 onConnected(roomId)를 호출해서 roomId를 전달하세요:
// App.js
import { useState, useEffect } from 'react';
import { useEffectEvent } from 'react';
import { createConnection, sendMessage } from './chat.js';
import { showNotification } from './notifications.js';
const serverUrl = 'https://localhost:1234';
function ChatRoom({ roomId, theme }) {
const onConnected = useEffectEvent(connectedRoomId => {
showNotification('Welcome to ' + connectedRoomId, theme);
});
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on('connected', () => {
setTimeout(() => {
onConnected(roomId);
}, 2000);
});
connection.connect();
return () => connection.disconnect();
}, [roomId]);
return <h1>Welcome to the {roomId} room!</h1>
}
export default function App() {
const [roomId, setRoomId] = useState('general');
const [isDark, setIsDark] = 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={isDark}
onChange={e => setIsDark(e.target.checked)}
/>
Use dark theme
</label>
<hr />
<ChatRoom
roomId={roomId}
theme={isDark ? 'dark' : 'light'}
/>
</>
);
}
roomId가 "travel"로 설정된 Effect ("travel" 방에 연결된)는 "travel"에 대한 알림을 보여줄 거예요. roomId가 "music"으로 설정된 Effect ("music" 방에 연결된)는 "music"에 대한 알림을 보여줄 거예요. 다시 말해서 connectedRoomId는 (반응형인) Effect에서 오고, theme은 항상 최신 값을 사용해요.
추가 챌린지를 해결하려면, 알림 timeout ID를 저장하고 Effect의 클린업 함수에서 클리어하면 돼요:
// App.js
import { useState, useEffect } from 'react';
import { useEffectEvent } from 'react';
import { createConnection, sendMessage } from './chat.js';
import { showNotification } from './notifications.js';
const serverUrl = 'https://localhost:1234';
function ChatRoom({ roomId, theme }) {
const onConnected = useEffectEvent(connectedRoomId => {
showNotification('Welcome to ' + connectedRoomId, theme);
});
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
let notificationTimeoutId;
connection.on('connected', () => {
notificationTimeoutId = setTimeout(() => {
onConnected(roomId);
}, 2000);
});
connection.connect();
return () => {
connection.disconnect();
if (notificationTimeoutId !== undefined) {
clearTimeout(notificationTimeoutId);
}
};
}, [roomId]);
return <h1>Welcome to the {roomId} room!</h1>
}
export default function App() {
const [roomId, setRoomId] = useState('general');
const [isDark, setIsDark] = 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={isDark}
onChange={e => setIsDark(e.target.checked)}
/>
Use dark theme
</label>
<hr />
<ChatRoom
roomId={roomId}
theme={isDark ? 'dark' : 'light'}
/>
</>
);
}
이렇게 하면 방을 변경할 때 이미 예약되었지만 아직 표시되지 않은 알림이 취소돼요.