Effect를 작성할 때, 린터는 Effect가 읽는 모든 반응형 값(props와 state 같은 것들)을 Effect의 의존성 배열에 포함했는지 확인해줘요. 이렇게 하면 Effect가 컴포넌트의 최신 props와 state와 동기화된 상태를 유지할 수 있어요. 불필요한 의존성이 있으면 Effect가 너무 자주 실행되거나, 심지어 무한 루프를 만들 수도 있어요. 이 가이드를 따라서 Effect에서 불필요한 의존성을 검토하고 제거하는 방법을 배워봐요.
Effect를 작성할 때, 먼저 Effect가 수행하려는 작업을 시작하고 중지하는 방법을 명시해요:
const serverUrl = 'https://localhost:1234';
function ChatRoom({ roomId }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => connection.disconnect();
// ...
}
그런 다음, Effect의 의존성을 비워두면 ([]), 린터가 올바른 의존성을 제안해줘요:
import { useState, useEffect } from 'react';
import { createConnection } from './chat.js';
const serverUrl = 'https://localhost:1234';
function ChatRoom({ roomId }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => connection.disconnect();
}, []); // <-- 여기 실수를 고쳐보세요!
return <h1>Welcome to the {roomId} room!</h1>;
}
export default function App() {
const [roomId, setRoomId] = useState('general');
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>
<hr />
<ChatRoom roomId={roomId} />
</>
);
}
export function createConnection(serverUrl, roomId) {
// 실제 구현이라면 정말로 서버에 연결할 거예요
return {
connect() {
console.log('✅ Connecting to "' + roomId + '" room at ' + serverUrl + '...');
},
disconnect() {
console.log('❌ Disconnected from "' + roomId + '" room at ' + serverUrl);
}
};
}
input { display: block; margin-bottom: 20px; }
button { margin-left: 10px; }
린터가 알려주는 대로 채워 넣으세요:
function ChatRoom({ roomId }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => connection.disconnect();
}, [roomId]); // ✅ 모든 의존성이 선언되었어요
// ...
}
Effect는 반응형 값에 "반응"해요. roomId는 반응형 값이기 때문에 (리렌더링으로 인해 변경될 수 있어요), 린터는 이것을 의존성으로 지정했는지 확인해요. roomId가 다른 값을 받으면, React는 Effect를 재동기화할 거예요. 이렇게 하면 채팅이 선택된 방에 연결된 상태를 유지하고 드롭다운에 "반응"하게 돼요:
import { useState, useEffect } from 'react';
import { createConnection } from './chat.js';
const serverUrl = 'https://localhost:1234';
function ChatRoom({ roomId }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => connection.disconnect();
}, [roomId]);
return <h1>Welcome to the {roomId} room!</h1>;
}
export default function App() {
const [roomId, setRoomId] = useState('general');
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>
<hr />
<ChatRoom roomId={roomId} />
</>
);
}
export function createConnection(serverUrl, roomId) {
// 실제 구현이라면 정말로 서버에 연결할 거예요
return {
connect() {
console.log('✅ Connecting to "' + roomId + '" room at ' + serverUrl + '...');
},
disconnect() {
console.log('❌ Disconnected from "' + roomId + '" room at ' + serverUrl);
}
};
}
input { display: block; margin-bottom: 20px; }
button { margin-left: 10px; }
Effect의 의존성을 "선택"할 수 없다는 점을 주목하세요. Effect 코드에서 사용되는 모든 반응형 값은 의존성 목록에 선언되어야 해요. 의존성 목록은 주변 코드에 의해 결정돼요:
const serverUrl = 'https://localhost:1234';
function ChatRoom({ roomId }) { // 이것은 반응형 값이에요
useEffect(() => {
const connection = createConnection(serverUrl, roomId); // 이 Effect는 그 반응형 값을 읽어요
connection.connect();
return () => connection.disconnect();
}, [roomId]); // ✅ 그래서 그 반응형 값을 Effect의 의존성으로 지정해야 해요
// ...
}
반응형 값에는 props와 컴포넌트 내부에서 직접 선언된 모든 변수와 함수가 포함돼요. roomId는 반응형 값이기 때문에, 의존성 목록에서 제거할 수 없어요. 린터가 허용하지 않을 거예요:
const serverUrl = 'https://localhost:1234';
function ChatRoom({ roomId }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => connection.disconnect();
}, []); // 🔴 React Hook useEffect has a missing dependency: 'roomId'
// ...
}
그리고 린터가 맞아요! roomId는 시간이 지남에 따라 변경될 수 있기 때문에, 이건 코드에 버그를 만들 수 있어요.
의존성을 제거하려면, 린터에게 그것이 의존성일 필요가 없다는 것을 "증명"하세요. 예를 들어, roomId를 컴포넌트 밖으로 이동시켜서 그것이 반응형이 아니며 리렌더링 시 변경되지 않는다는 것을 증명할 수 있어요:
const serverUrl = 'https://localhost:1234';
const roomId = 'music'; // 더 이상 반응형 값이 아니에요
function ChatRoom() {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => connection.disconnect();
}, []); // ✅ 모든 의존성이 선언되었어요
// ...
}
이제 roomId는 반응형 값이 아니고 (리렌더링 시 변경될 수 없어요), 의존성일 필요가 없어요:
import { useState, useEffect } from 'react';
import { createConnection } from './chat.js';
const serverUrl = 'https://localhost:1234';
const roomId = 'music';
export default function ChatRoom() {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => connection.disconnect();
}, []);
return <h1>Welcome to the {roomId} room!</h1>;
}
export function createConnection(serverUrl, roomId) {
// 실제 구현이라면 정말로 서버에 연결할 거예요
return {
connect() {
console.log('✅ Connecting to "' + roomId + '" room at ' + serverUrl + '...');
},
disconnect() {
console.log('❌ Disconnected from "' + roomId + '" room at ' + serverUrl);
}
};
}
input { display: block; margin-bottom: 20px; }
button { margin-left: 10px; }
이것이 이제 빈 ([]) 의존성 배열을 지정할 수 있는 이유예요. Effect가 정말로 어떤 반응형 값에도 의존하지 않으므로, 컴포넌트의 props나 state가 변경될 때 정말로 다시 실행될 필요가 없어요.
작업 흐름에서 패턴을 발견했을 수도 있어요:
마지막 부분이 중요해요. 의존성을 변경하고 싶다면, 먼저 주변 코드를 변경하세요. 의존성 목록은 Effect 코드에서 사용하는 모든 반응형 값의 목록이라고 생각할 수 있어요. 그 목록에 무엇을 넣을지 선택하는 게 아니에요. 목록이 코드를 설명하는 거예요. 의존성 목록을 변경하려면 코드를 변경하세요.
이건 방정식을 푸는 것처럼 느껴질 수 있어요. 목표(예를 들어, 의존성 제거)로 시작해서, 그 목표와 일치하는 코드를 "찾아야" 해요. 모든 사람이 방정식 푸는 걸 재미있어하는 건 아니고, Effect를 작성하는 것도 마찬가지예요! 다행히도, 아래에서 시도해볼 수 있는 일반적인 해결책 목록이 있어요.
기존 코드베이스가 있다면, 이렇게 린터를 억제하는 Effect가 있을 수 있어요:
useEffect(() => {
// ...
// 🔴 이렇게 린터를 억제하는 걸 피하세요:
// eslint-ignore-next-line react-hooks/exhaustive-deps
}, []);
의존성이 코드와 일치하지 않으면, 버그가 발생할 위험이 매우 높아요. 린터를 억제하면, Effect가 의존하는 값에 대해 React에게 "거짓말"을 하는 거예요.
대신, 아래 기법들을 사용하세요.
린터를 억제하면 찾아서 고치기 매우 어려운 직관적이지 않은 버그가 발생해요. 여기 하나의 예시가 있어요:
import { useState, useEffect } from 'react';
export default function Timer() {
const [count, setCount] = useState(0);
const [increment, setIncrement] = useState(1);
function onTick() {
setCount(count + increment);
}
useEffect(() => {
const id = setInterval(onTick, 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>
</>
);
}
button { margin: 10px; }
Effect를 "마운트 시에만" 실행하고 싶다고 가정해봐요. 빈 ([]) 의존성이 그렇게 한다는 걸 읽어서, 린터를 무시하고 강제로 []를 의존성으로 지정했어요.
이 카운터는 두 버튼으로 설정 가능한 양만큼 매초 증가해야 했어요. 하지만 이 Effect가 아무것에도 의존하지 않는다고 React에게 "거짓말"을 했기 때문에, React는 초기 렌더링의 onTick 함수를 계속 사용해요. 그 렌더링 동안, count는 0이었고 increment는 1이었어요. 이것이 그 렌더링의 onTick이 항상 매초 setCount(0 + 1)을 호출하고, 항상 1을 보게 되는 이유예요. 이런 버그는 여러 컴포넌트에 걸쳐 있을 때 고치기가 더 어려워져요.
린터를 무시하는 것보다 항상 더 나은 해결책이 있어요! 이 코드를 고치려면, onTick을 의존성 목록에 추가해야 해요. (인터벌이 한 번만 설정되도록 하려면, onTick을 Effect Event로 만드세요.)
의존성 린트 에러를 컴파일 에러처럼 다루는 것을 권장해요. 억제하지 않으면, 이런 버그를 절대 보지 않을 거예요. 이 페이지의 나머지 부분에서는 이 경우와 다른 경우들에 대한 대안을 설명해요.
코드를 반영하기 위해 Effect의 의존성을 조정할 때마다, 의존성 목록을 살펴보세요. 이 의존성들 중 하나라도 변경될 때 Effect가 다시 실행되는 게 타당한가요? 때때로 대답은 "아니오"예요:
올바른 해결책을 찾으려면, Effect에 대한 몇 가지 질문에 답해야 해요. 하나씩 살펴봐요.
가장 먼저 생각해야 할 것은 이 코드가 애초에 Effect여야 하는지 여부예요.
폼을 상상해봐요. 제출 시, submitted state 변수를 true로 설정해요. POST 요청을 보내고 알림을 표시해야 해요. 이 로직을 submitted가 true가 되는 것에 "반응"하는 Effect 안에 넣었어요:
function Form() {
const [submitted, setSubmitted] = useState(false);
useEffect(() => {
if (submitted) {
// 🔴 피하세요: 이벤트별 로직이 Effect 안에 있어요
post('/api/register');
showNotification('Successfully registered!');
}
}, [submitted]);
function handleSubmit() {
setSubmitted(true);
}
// ...
}
나중에, 현재 테마에 따라 알림 메시지를 스타일링하고 싶어서 현재 테마를 읽어요. theme는 컴포넌트 본문에서 선언되므로 반응형 값이고, 의존성으로 추가해요:
function Form() {
const [submitted, setSubmitted] = useState(false);
const theme = useContext(ThemeContext);
useEffect(() => {
if (submitted) {
// 🔴 피하세요: 이벤트별 로직이 Effect 안에 있어요
post('/api/register');
showNotification('Successfully registered!', theme);
}
}, [submitted, theme]); // ✅ 모든 의존성이 선언되었어요
function handleSubmit() {
setSubmitted(true);
}
// ...
}
이렇게 하면 버그가 생겨요. 먼저 폼을 제출한 다음 Dark와 Light 테마를 전환한다고 상상해봐요. theme가 변경되고, Effect가 다시 실행되므로, 같은 알림을 다시 표시할 거예요!
여기서 문제는 이게 애초에 Effect가 되어서는 안 된다는 거예요. 폼을 제출하는 것에 대한 응답으로 이 POST 요청을 보내고 알림을 표시하고 싶은데, 그건 특정 상호작용이에요. 특정 상호작용에 대한 응답으로 어떤 코드를 실행하려면, 그 로직을 해당 이벤트 핸들러에 직접 넣으세요:
function Form() {
const theme = useContext(ThemeContext);
function handleSubmit() {
// ✅ 좋아요: 이벤트별 로직이 이벤트 핸들러에서 호출돼요
post('/api/register');
showNotification('Successfully registered!', theme);
}
// ...
}
이제 코드가 이벤트 핸들러에 있으므로, 반응형이 아니에요 -- 사용자가 폼을 제출할 때만 실행될 거예요. 이벤트 핸들러와 Effect 중에서 선택하기와 불필요한 Effect를 삭제하는 방법에 대해 더 읽어보세요.
스스로에게 물어봐야 할 다음 질문은 Effect가 관련 없는 여러 가지 일을 하고 있는지예요.
사용자가 도시와 지역을 선택해야 하는 배송 폼을 만들고 있다고 상상해봐요. 선택된 country에 따라 서버에서 cities 목록을 가져와서 드롭다운에 표시해요:
function ShippingForm({ country }) {
const [cities, setCities] = useState(null);
const [city, setCity] = useState(null);
useEffect(() => {
let ignore = false;
fetch(`/api/cities?country=${country}`)
.then(response => response.json())
.then(json => {
if (!ignore) {
setCities(json);
}
});
return () => {
ignore = true;
};
}, [country]); // ✅ 모든 의존성이 선언되었어요
// ...
이것은 Effect에서 데이터를 가져오는 좋은 예시예요. country prop에 따라 cities state를 네트워크와 동기화하고 있어요. ShippingForm이 표시되자마자 그리고 country가 변경될 때마다 (어떤 상호작용이 원인이든) fetch해야 하므로 이벤트 핸들러에서는 할 수 없어요.
이제 현재 선택된 city에 대한 areas를 가져와야 하는 도시 지역을 위한 두 번째 select 박스를 추가한다고 해봐요. 같은 Effect 안에서 지역 목록을 위한 두 번째 fetch 호출을 추가하는 것으로 시작할 수 있어요:
function ShippingForm({ country }) {
const [cities, setCities] = useState(null);
const [city, setCity] = useState(null);
const [areas, setAreas] = useState(null);
useEffect(() => {
let ignore = false;
fetch(`/api/cities?country=${country}`)
.then(response => response.json())
.then(json => {
if (!ignore) {
setCities(json);
}
});
// 🔴 피하세요: 단일 Effect가 두 개의 독립적인 프로세스를 동기화해요
if (city) {
fetch(`/api/areas?city=${city}`)
.then(response => response.json())
.then(json => {
if (!ignore) {
setAreas(json);
}
});
}
return () => {
ignore = true;
};
}, [country, city]); // ✅ 모든 의존성이 선언되었어요
// ...
하지만 이제 Effect가 city state 변수를 사용하므로, city를 의존성 목록에 추가해야 했어요. 그런데 그 결과로 문제가 생겼어요: 사용자가 다른 도시를 선택하면, Effect가 다시 실행되고 fetchCities(country)를 호출할 거예요. 결과적으로, 도시 목록을 불필요하게 여러 번 다시 가져오게 돼요.
이 코드의 문제는 관련 없는 두 가지 다른 것을 동기화하고 있다는 거예요:
country prop에 기반하여 cities state를 네트워크와 동기화하고 싶어요.city state에 기반하여 areas state를 네트워크와 동기화하고 싶어요.로직을 두 개의 Effect로 분리하세요. 각각은 동기화해야 하는 prop에 반응해요:
function ShippingForm({ country }) {
const [cities, setCities] = useState(null);
useEffect(() => {
let ignore = false;
fetch(`/api/cities?country=${country}`)
.then(response => response.json())
.then(json => {
if (!ignore) {
setCities(json);
}
});
return () => {
ignore = true;
};
}, [country]); // ✅ 모든 의존성이 선언되었어요
const [city, setCity] = useState(null);
const [areas, setAreas] = useState(null);
useEffect(() => {
if (city) {
let ignore = false;
fetch(`/api/areas?city=${city}`)
.then(response => response.json())
.then(json => {
if (!ignore) {
setAreas(json);
}
});
return () => {
ignore = true;
};
}
}, [city]); // ✅ 모든 의존성이 선언되었어요
// ...
이제 첫 번째 Effect는 country가 변경될 때만 다시 실행되고, 두 번째 Effect는 city가 변경될 때 다시 실행돼요. 목적별로 분리했어요: 두 개의 다른 것이 두 개의 분리된 Effect로 동기화돼요. 두 개의 분리된 Effect는 두 개의 분리된 의존성 목록을 가지므로, 의도치 않게 서로를 트리거하지 않아요.
최종 코드는 원본보다 길지만, 이러한 Effect를 분리하는 것은 여전히 옳아요. 각 Effect는 독립적인 동기화 프로세스를 나타내야 해요. 이 예시에서, 하나의 Effect를 삭제해도 다른 Effect의 로직이 망가지지 않아요. 이것은 그것들이 다른 것을 동기화한다는 것을 의미하고, 분리하는 게 좋아요. 중복이 걱정된다면, 반복되는 로직을 커스텀 Hook으로 추출하여 이 코드를 개선할 수 있어요.
이 Effect는 새 메시지가 도착할 때마다 새로 생성된 배열로 messages state 변수를 업데이트해요:
function ChatRoom({ roomId }) {
const [messages, setMessages] = useState([]);
useEffect(() => {
const connection = createConnection();
connection.connect();
connection.on('message', (receivedMessage) => {
setMessages([...messages, receivedMessage]);
});
// ...
messages 변수를 사용해서 모든 기존 메시지로 시작하는 새 배열을 생성하고, 끝에 새 메시지를 추가해요. 하지만 messages는 Effect가 읽는 반응형 값이므로, 의존성이어야 해요:
function ChatRoom({ roomId }) {
const [messages, setMessages] = useState([]);
useEffect(() => {
const connection = createConnection();
connection.connect();
connection.on('message', (receivedMessage) => {
setMessages([...messages, receivedMessage]);
});
return () => connection.disconnect();
}, [roomId, messages]); // ✅ 모든 의존성이 선언되었어요
// ...
그리고 messages를 의존성으로 만드는 것은 문제를 일으켜요.
메시지를 받을 때마다, setMessages()는 컴포넌트가 받은 메시지를 포함하는 새 messages 배열로 리렌더링하게 만들어요. 하지만 이 Effect는 이제 messages에 의존하므로, 이것은 또한 Effect를 재동기화할 거예요. 그래서 모든 새 메시지가 채팅을 다시 연결하게 만들 거예요. 사용자가 좋아하지 않을 거예요!
문제를 해결하려면, Effect 안에서 messages를 읽지 마세요. 대신, 업데이터 함수를 setMessages에 전달하세요:
function ChatRoom({ roomId }) {
const [messages, setMessages] = useState([]);
useEffect(() => {
const connection = createConnection();
connection.connect();
connection.on('message', (receivedMessage) => {
setMessages(msgs => [...msgs, receivedMessage]);
});
return () => connection.disconnect();
}, [roomId]); // ✅ 모든 의존성이 선언되었어요
// ...
이제 Effect가 messages 변수를 전혀 읽지 않는 것을 주목하세요. msgs => [...msgs, receivedMessage] 같은 업데이터 함수만 전달하면 돼요. React는 업데이터 함수를 큐에 넣고 다음 렌더링 중에 msgs 인자를 제공할 거예요. 이것이 Effect 자체가 더 이상 messages에 의존할 필요가 없는 이유예요. 이 수정의 결과로, 채팅 메시지를 받는 것이 더 이상 채팅을 다시 연결하게 만들지 않을 거예요.
isMuted가 true가 아닐 때 사용자가 새 메시지를 받으면 소리를 재생하고 싶다고 가정해봐요:
function ChatRoom({ roomId }) {
const [messages, setMessages] = useState([]);
const [isMuted, setIsMuted] = useState(false);
useEffect(() => {
const connection = createConnection();
connection.connect();
connection.on('message', (receivedMessage) => {
setMessages(msgs => [...msgs, receivedMessage]);
if (!isMuted) {
playSound();
}
});
// ...
Effect가 이제 코드에서 isMuted를 사용하므로, 의존성에 추가해야 해요:
function ChatRoom({ roomId }) {
const [messages, setMessages] = useState([]);
const [isMuted, setIsMuted] = useState(false);
useEffect(() => {
const connection = createConnection();
connection.connect();
connection.on('message', (receivedMessage) => {
setMessages(msgs => [...msgs, receivedMessage]);
if (!isMuted) {
playSound();
}
});
return () => connection.disconnect();
}, [roomId, isMuted]); // ✅ 모든 의존성이 선언되었어요
// ...
문제는 isMuted가 변경될 때마다 (예를 들어, 사용자가 "Muted" 토글을 누를 때), Effect가 재동기화되고 채팅에 다시 연결될 거예요. 이건 원하는 사용자 경험이 아니에요! (이 예시에서는 린터를 비활성화해도 작동하지 않아요 -- 그렇게 하면 isMuted가 이전 값에 "고착"될 거예요.)
이 문제를 해결하려면, 반응형이어서는 안 되는 로직을 Effect에서 빼내야 해요. 이 Effect가 isMuted의 변경에 "반응"하지 않길 원해요. 이 비반응형 로직 조각을 Effect Event로 이동하세요:
import { useState, useEffect, useEffectEvent } from 'react';
function ChatRoom({ roomId }) {
const [messages, setMessages] = useState([]);
const [isMuted, setIsMuted] = useState(false);
const onMessage = useEffectEvent(receivedMessage => {
setMessages(msgs => [...msgs, receivedMessage]);
if (!isMuted) {
playSound();
}
});
useEffect(() => {
const connection = createConnection();
connection.connect();
connection.on('message', (receivedMessage) => {
onMessage(receivedMessage);
});
return () => connection.disconnect();
}, [roomId]); // ✅ 모든 의존성이 선언되었어요
// ...
Effect Event를 사용하면 Effect를 반응형 부분(roomId 같은 반응형 값과 그 변경에 "반응"해야 하는 부분)과 비반응형 부분(최신 값만 읽는 부분, onMessage가 isMuted를 읽는 것처럼)으로 나눌 수 있어요. 이제 Effect Event 안에서 isMuted를 읽으므로, Effect의 의존성일 필요가 없어요. 결과적으로, "Muted" 설정을 켜고 끌 때 채팅이 다시 연결되지 않아서 원래 문제가 해결돼요!
컴포넌트가 prop으로 이벤트 핸들러를 받을 때 비슷한 문제가 발생할 수 있어요:
function ChatRoom({ roomId, onReceiveMessage }) {
const [messages, setMessages] = useState([]);
useEffect(() => {
const connection = createConnection();
connection.connect();
connection.on('message', (receivedMessage) => {
onReceiveMessage(receivedMessage);
});
return () => connection.disconnect();
}, [roomId, onReceiveMessage]); // ✅ 모든 의존성이 선언되었어요
// ...
부모 컴포넌트가 매 렌더링마다 다른 onReceiveMessage 함수를 전달한다고 가정해봐요:
<ChatRoom
roomId={roomId}
onReceiveMessage={receivedMessage => {
// ...
}}
/>
onReceiveMessage가 의존성이므로, 부모가 리렌더링될 때마다 Effect가 재동기화될 거예요. 이렇게 하면 채팅에 다시 연결될 거예요. 이를 해결하려면, 호출을 Effect Event로 래핑하세요:
function ChatRoom({ roomId, onReceiveMessage }) {
const [messages, setMessages] = useState([]);
const onMessage = useEffectEvent(receivedMessage => {
onReceiveMessage(receivedMessage);
});
useEffect(() => {
const connection = createConnection();
connection.connect();
connection.on('message', (receivedMessage) => {
onMessage(receivedMessage);
});
return () => connection.disconnect();
}, [roomId]); // ✅ 모든 의존성이 선언되었어요
// ...
Effect Event는 반응형이 아니므로, 의존성으로 지정할 필요가 없어요. 결과적으로, 부모 컴포넌트가 매 리렌더링마다 다른 함수를 전달해도 채팅이 다시 연결되지 않을 거예요.
이 예시에서, roomId가 변경될 때마다 방문을 로그하고 싶어요. 모든 로그에 현재 notificationCount를 포함하고 싶지만, notificationCount의 변경이 로그 이벤트를 트리거하는 것은 원하지 않아요.
해결책은 다시 비반응형 코드를 Effect Event로 분리하는 거예요:
function Chat({ roomId, notificationCount }) {
const onVisit = useEffectEvent(visitedRoomId => {
logVisit(visitedRoomId, notificationCount);
});
useEffect(() => {
onVisit(roomId);
}, [roomId]); // ✅ 모든 의존성이 선언되었어요
// ...
}
로직이 roomId에 대해 반응형이기를 원하므로, Effect 안에서 roomId를 읽어요. 하지만 notificationCount의 변경이 추가 방문을 로그하는 것을 원하지 않으므로, Effect Event 안에서 notificationCount를 읽어요. Effect Event를 사용하여 Effect에서 최신 props와 state를 읽는 것에 대해 더 알아보세요.
때때로, Effect가 특정 값에 "반응"하기를 원하지만, 그 값이 원하는 것보다 더 자주 변경되어요 -- 사용자 관점에서 실제 변경을 반영하지 않을 수 있어요. 예를 들어, 컴포넌트 본문에서 options 객체를 생성한 다음 Effect 안에서 그 객체를 읽는다고 해봐요:
function ChatRoom({ roomId }) {
// ...
const options = {
serverUrl: serverUrl,
roomId: roomId
};
useEffect(() => {
const connection = createConnection(options);
connection.connect();
// ...
이 객체는 컴포넌트 본문에서 선언되므로, 반응형 값이에요. Effect 안에서 이런 반응형 값을 읽을 때, 의존성으로 선언해요. 이렇게 하면 Effect가 변경에 "반응"하게 돼요:
// ...
useEffect(() => {
const connection = createConnection(options);
connection.connect();
return () => connection.disconnect();
}, [options]); // ✅ 모든 의존성이 선언되었어요
// ...
의존성으로 선언하는 것이 중요해요! 예를 들어, roomId가 변경되면, Effect가 새 options로 채팅에 다시 연결할 것을 보장해요. 하지만 위 코드에도 문제가 있어요. 보려면, 아래 샌드박스에서 입력을 타이핑해보고 콘솔에서 무슨 일이 일어나는지 보세요:
import { useState, useEffect } from 'react';
import { createConnection } from './chat.js';
const serverUrl = 'https://localhost:1234';
function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');
// 문제를 시연하기 위해 린터를 일시적으로 비활성화해요
// eslint-disable-next-line react-hooks/exhaustive-deps
const options = {
serverUrl: serverUrl,
roomId: roomId
};
useEffect(() => {
const connection = createConnection(options);
connection.connect();
return () => connection.disconnect();
}, [options]);
return (
<>
<h1>Welcome to the {roomId} room!</h1>
<input value={message} onChange={e => setMessage(e.target.value)} />
</>
);
}
export default function App() {
const [roomId, setRoomId] = useState('general');
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>
<hr />
<ChatRoom roomId={roomId} />
</>
);
}
export function createConnection({ serverUrl, roomId }) {
// 실제 구현이라면 정말로 서버에 연결할 거예요
return {
connect() {
console.log('✅ Connecting to "' + roomId + '" room at ' + serverUrl + '...');
},
disconnect() {
console.log('❌ Disconnected from "' + roomId + '" room at ' + serverUrl);
}
};
}
input { display: block; margin-bottom: 20px; }
button { margin-left: 10px; }
위 샌드박스에서, 입력은 message state 변수만 업데이트해요. 사용자 관점에서, 이것은 채팅 연결에 영향을 주지 않아야 해요. 하지만 message를 업데이트할 때마다, 컴포넌트가 리렌더링해요. 컴포넌트가 리렌더링될 때, 내부 코드가 처음부터 다시 실행돼요.
ChatRoom 컴포넌트의 매 리렌더링마다 새 options 객체가 처음부터 생성돼요. React는 options 객체가 마지막 렌더링 중에 생성된 options 객체와 다른 객체라는 것을 인식해요. 이것이 (options에 의존하는) Effect를 재동기화하고, 타이핑할 때 채팅이 다시 연결되는 이유예요.
이 문제는 객체와 함수에만 영향을 미쳐요. JavaScript에서, 새로 생성된 각 객체와 함수는 다른 모든 것과 구별되는 것으로 간주돼요. 내부 내용이 같을 수 있다는 것은 중요하지 않아요!
// 첫 번째 렌더링 중
const options1 = { serverUrl: 'https://localhost:1234', roomId: 'music' };
// 다음 렌더링 중
const options2 = { serverUrl: 'https://localhost:1234', roomId: 'music' };
// 이것들은 두 개의 다른 객체예요!
console.log(Object.is(options1, options2)); // false
객체와 함수 의존성은 Effect가 필요 이상으로 자주 재동기화되게 만들 수 있어요.
이것이 가능하면 객체와 함수를 Effect의 의존성으로 피해야 하는 이유예요. 대신, 컴포넌트 밖으로 이동하거나, Effect 안으로 이동하거나, 원시 값을 추출하세요.
객체가 props나 state에 의존하지 않는다면, 그 객체를 컴포넌트 밖으로 이동할 수 있어요:
const options = {
serverUrl: 'https://localhost:1234',
roomId: 'music'
};
function ChatRoom() {
const [message, setMessage] = useState('');
useEffect(() => {
const connection = createConnection(options);
connection.connect();
return () => connection.disconnect();
}, []); // ✅ 모든 의존성이 선언되었어요
// ...
이렇게 하면 린터에게 그것이 반응형이 아니라는 것을 증명할 수 있어요. 리렌더링의 결과로 변경될 수 없으므로, 의존성일 필요가 없어요. 이제 ChatRoom을 리렌더링해도 Effect가 재동기화되지 않을 거예요.
이것은 함수에도 작동해요:
function createOptions() {
return {
serverUrl: 'https://localhost:1234',
roomId: 'music'
};
}
function ChatRoom() {
const [message, setMessage] = useState('');
useEffect(() => {
const options = createOptions();
const connection = createConnection(options);
connection.connect();
return () => connection.disconnect();
}, []); // ✅ 모든 의존성이 선언되었어요
// ...
createOptions는 컴포넌트 밖에서 선언되므로, 반응형 값이 아니에요. 이것이 Effect의 의존성에 지정될 필요가 없고, Effect가 재동기화되지 않는 이유예요.
객체가 roomId prop처럼 리렌더링의 결과로 변경될 수 있는 반응형 값에 의존한다면, 컴포넌트 밖으로 끌어낼 수 없어요. 하지만 생성을 Effect 코드 안으로 이동할 수 있어요:
const serverUrl = 'https://localhost:1234';
function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');
useEffect(() => {
const options = {
serverUrl: serverUrl,
roomId: roomId
};
const connection = createConnection(options);
connection.connect();
return () => connection.disconnect();
}, [roomId]); // ✅ 모든 의존성이 선언되었어요
// ...
이제 options가 Effect 안에서 선언되므로, Effect의 의존성이 아니에요. 대신, Effect가 사용하는 유일한 반응형 값은 roomId예요. roomId가 객체나 함수가 아니므로, 의도치 않게 다르지 않을 것을 확신할 수 있어요. JavaScript에서, 숫자와 문자열은 내용으로 비교돼요:
// 첫 번째 렌더링 중
const roomId1 = 'music';
// 다음 렌더링 중
const roomId2 = 'music';
// 이 두 문자열은 같아요!
console.log(Object.is(roomId1, roomId2)); // true
이 수정 덕분에, 입력을 편집해도 채팅이 더 이상 다시 연결되지 않아요:
import { useState, useEffect } from 'react';
import { createConnection } from './chat.js';
const serverUrl = 'https://localhost:1234';
function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');
useEffect(() => {
const options = {
serverUrl: serverUrl,
roomId: roomId
};
const connection = createConnection(options);
connection.connect();
return () => connection.disconnect();
}, [roomId]);
return (
<>
<h1>Welcome to the {roomId} room!</h1>
<input value={message} onChange={e => setMessage(e.target.value)} />
</>
);
}
export default function App() {
const [roomId, setRoomId] = useState('general');
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>
<hr />
<ChatRoom roomId={roomId} />
</>
);
}
export function createConnection({ serverUrl, roomId }) {
// 실제 구현이라면 정말로 서버에 연결할 거예요
return {
connect() {
console.log('✅ Connecting to "' + roomId + '" room at ' + serverUrl + '...');
},
disconnect() {
console.log('❌ Disconnected from "' + roomId + '" room at ' + serverUrl);
}
};
}
input { display: block; margin-bottom: 20px; }
button { margin-left: 10px; }
하지만 예상대로 roomId 드롭다운을 변경하면 다시 연결돼요.
이것은 함수에도 작동해요:
const serverUrl = 'https://localhost:1234';
function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');
useEffect(() => {
function createOptions() {
return {
serverUrl: serverUrl,
roomId: roomId
};
}
const options = createOptions();
const connection = createConnection(options);
connection.connect();
return () => connection.disconnect();
}, [roomId]); // ✅ 모든 의존성이 선언되었어요
// ...
Effect 안에서 로직 조각을 그룹화하기 위해 자신만의 함수를 작성할 수 있어요. Effect 안에서 선언하는 한, 반응형 값이 아니므로 Effect의 의존성일 필요가 없어요.
때때로, props에서 객체를 받을 수 있어요:
function ChatRoom({ options }) {
const [message, setMessage] = useState('');
useEffect(() => {
const connection = createConnection(options);
connection.connect();
return () => connection.disconnect();
}, [options]); // ✅ 모든 의존성이 선언되었어요
// ...
여기서 위험은 부모 컴포넌트가 렌더링 중에 객체를 생성할 거라는 거예요:
<ChatRoom
roomId={roomId}
options={{
serverUrl: serverUrl,
roomId: roomId
}}
/>
이렇게 하면 부모 컴포넌트가 리렌더링될 때마다 Effect가 다시 연결될 거예요. 이를 고치려면, Effect 밖에서 객체로부터 정보를 읽고, 객체와 함수 의존성을 피하세요:
function ChatRoom({ options }) {
const [message, setMessage] = useState('');
const { roomId, serverUrl } = options;
useEffect(() => {
const connection = createConnection({
roomId: roomId,
serverUrl: serverUrl
});
connection.connect();
return () => connection.disconnect();
}, [roomId, serverUrl]); // ✅ 모든 의존성이 선언되었어요
// ...
로직이 약간 반복적이에요 (Effect 밖에서 객체로부터 일부 값을 읽고, 그 다음 Effect 안에서 같은 값을 가진 객체를 생성해요). 하지만 Effect가 실제로 어떤 정보에 의존하는지 매우 명확하게 만들어요. 부모 컴포넌트에 의해 객체가 의도치 않게 재생성되면, 채팅이 다시 연결되지 않을 거예요. 하지만 options.roomId나 options.serverUrl이 실제로 다르면, 채팅이 다시 연결될 거예요.
함수에도 같은 접근 방식이 작동할 수 있어요. 예를 들어, 부모 컴포넌트가 함수를 전달한다고 가정해봐요:
<ChatRoom
roomId={roomId}
getOptions={() => {
return {
serverUrl: serverUrl,
roomId: roomId
};
}}
/>
의존성으로 만드는 것을 피하려면 (그리고 리렌더링 시 다시 연결되게 하는 것을 피하려면), Effect 밖에서 호출하세요. 이렇게 하면 객체가 아닌 roomId와 serverUrl 값을 얻고, Effect 안에서 읽을 수 있어요:
function ChatRoom({ getOptions }) {
const [message, setMessage] = useState('');
const { roomId, serverUrl } = getOptions();
useEffect(() => {
const connection = createConnection({
roomId: roomId,
serverUrl: serverUrl
});
connection.connect();
return () => connection.disconnect();
}, [roomId, serverUrl]); // ✅ 모든 의존성이 선언되었어요
// ...
이것은 렌더링 중에 호출하는 것이 안전하므로 순수 함수에만 작동해요. 함수가 이벤트 핸들러이지만 그 변경이 Effect를 재동기화하는 것을 원하지 않는다면, 대신 Effect Event로 래핑하세요.
이 Effect는 매초 틱하는 인터벌을 설정해요. 이상한 일이 발생하는 것을 알아챘어요: 틱할 때마다 인터벌이 파괴되고 재생성되는 것 같아요. 인터벌이 계속 재생성되지 않도록 코드를 고치세요.
이 Effect의 코드가 count에 의존하는 것 같아요. 이 의존성이 필요 없는 방법이 있을까요? 그 값에 의존성을 추가하지 않고 이전 값에 기반하여 count state를 업데이트하는 방법이 있어야 해요.
import { useState, useEffect } from 'react';
export default function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
console.log('✅ Creating an interval');
const id = setInterval(() => {
console.log('⏰ Interval tick');
setCount(count + 1);
}, 1000);
return () => {
console.log('❌ Clearing an interval');
clearInterval(id);
};
}, [count]);
return <h1>Counter: {count}</h1>
}
Effect 안에서 count state를 count + 1로 업데이트하고 싶어요. 하지만 이렇게 하면 Effect가 count에 의존하게 되고, 매 틱마다 변경되므로, 매 틱마다 인터벌이 재생성되는 이유예요.
이를 해결하려면, 업데이터 함수를 사용하고 setCount(count + 1) 대신 setCount(c => c + 1)을 작성하세요:
import { useState, useEffect } from 'react';
export default function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
console.log('✅ Creating an interval');
const id = setInterval(() => {
console.log('⏰ Interval tick');
setCount(c => c + 1);
}, 1000);
return () => {
console.log('❌ Clearing an interval');
clearInterval(id);
};
}, []);
return <h1>Counter: {count}</h1>
}
Effect 안에서 count를 읽는 대신, c => c + 1 명령("이 숫자를 증가시켜!")을 React에 전달해요. React는 다음 렌더링에 적용할 거예요. 그리고 Effect 안에서 더 이상 count 값을 읽을 필요가 없으므로, Effect의 의존성을 비워둘 수 있어요 ([]). 이렇게 하면 매 틱마다 Effect가 인터벌을 재생성하는 것을 방지해요.
이 예시에서, "Show"를 누르면 환영 메시지가 페이드 인돼요. 애니메이션은 1초가 걸려요. "Remove"를 누르면, 환영 메시지가 즉시 사라져요. 페이드 인 애니메이션의 로직은 animation.js 파일에 일반 JavaScript 애니메이션 루프로 구현되어 있어요. 그 로직을 변경할 필요는 없어요. 서드파티 라이브러리로 취급할 수 있어요. Effect는 DOM 노드를 위한 FadeInAnimation 인스턴스를 생성하고, 애니메이션을 제어하기 위해 start(duration) 또는 stop()을 호출해요. duration은 슬라이더로 제어돼요. 슬라이더를 조정해서 애니메이션이 어떻게 변경되는지 보세요.
이 코드는 이미 작동하지만, 변경하고 싶은 게 있어요. 현재, duration state 변수를 제어하는 슬라이더를 이동하면, 애니메이션이 다시 트리거돼요. Effect가 duration 변수에 "반응"하지 않도록 동작을 변경하세요. "Show"를 누르면, Effect는 슬라이더의 현재 duration을 사용해야 해요. 하지만 슬라이더 이동 자체가 애니메이션을 다시 트리거해서는 안 돼요.
Effect 안에 반응형이어서는 안 되는 코드 라인이 있나요? 비반응형 코드를 Effect 밖으로 어떻게 이동할 수 있을까요?
import { useState, useEffect, useRef } from 'react';
import { useEffectEvent } from 'react';
import { FadeInAnimation } from './animation.js';
function Welcome({ duration }) {
const ref = useRef(null);
useEffect(() => {
const animation = new FadeInAnimation(ref.current);
animation.start(duration);
return () => {
animation.stop();
};
}, [duration]);
return (
<h1
ref={ref}
style={{
opacity: 0,
color: 'white',
padding: 50,
textAlign: 'center',
fontSize: 50,
backgroundImage: 'radial-gradient(circle, rgba(63,94,251,1) 0%, rgba(252,70,107,1) 100%)'
}}
>
Welcome
</h1>
);
}
export default function App() {
const [duration, setDuration] = useState(1000);
const [show, setShow] = useState(false);
return (
<>
<label>
<input
type="range"
min="100"
max="3000"
value={duration}
onChange={e => setDuration(Number(e.target.value))}
/>
<br />
Fade in duration: {duration} ms
</label>
<button onClick={() => setShow(!show)}>
{show ? 'Remove' : 'Show'}
</button>
<hr />
{show && <Welcome duration={duration} />}
</>
);
}
export class FadeInAnimation {
constructor(node) {
this.node = node;
}
start(duration) {
this.duration = duration;
if (this.duration === 0) {
// 즉시 끝으로 점프해요
this.onProgress(1);
} else {
this.onProgress(0);
// 애니메이션 시작
this.startTime = performance.now();
this.frameId = requestAnimationFrame(() => this.onFrame());
}
}
onFrame() {
const timePassed = performance.now() - this.startTime;
const progress = Math.min(timePassed / this.duration, 1);
this.onProgress(progress);
if (progress < 1) {
// 아직 더 그려야 할 프레임이 있어요
this.frameId = requestAnimationFrame(() => this.onFrame());
}
}
onProgress(progress) {
this.node.style.opacity = progress;
}
stop() {
cancelAnimationFrame(this.frameId);
this.startTime = null;
this.frameId = null;
this.duration = 0;
}
}
label, button { display: block; margin-bottom: 20px; }
html, body { min-height: 300px; }
Effect는 duration의 최신 값을 읽어야 하지만, duration의 변경에 "반응"하는 것을 원하지 않아요. duration을 사용해서 애니메이션을 시작하지만, 애니메이션 시작은 반응형이 아니에요. 비반응형 코드 라인을 Effect Event로 추출하고, Effect에서 그 함수를 호출하세요.
import { useState, useEffect, useRef } from 'react';
import { FadeInAnimation } from './animation.js';
import { useEffectEvent } from 'react';
function Welcome({ duration }) {
const ref = useRef(null);
const onAppear = useEffectEvent(animation => {
animation.start(duration);
});
useEffect(() => {
const animation = new FadeInAnimation(ref.current);
onAppear(animation);
return () => {
animation.stop();
};
}, []);
return (
<h1
ref={ref}
style={{
opacity: 0,
color: 'white',
padding: 50,
textAlign: 'center',
fontSize: 50,
backgroundImage: 'radial-gradient(circle, rgba(63,94,251,1) 0%, rgba(252,70,107,1) 100%)'
}}
>
Welcome
</h1>
);
}
export default function App() {
const [duration, setDuration] = useState(1000);
const [show, setShow] = useState(false);
return (
<>
<label>
<input
type="range"
min="100"
max="3000"
value={duration}
onChange={e => setDuration(Number(e.target.value))}
/>
<br />
Fade in duration: {duration} ms
</label>
<button onClick={() => setShow(!show)}>
{show ? 'Remove' : 'Show'}
</button>
<hr />
{show && <Welcome duration={duration} />}
</>
);
}
export class FadeInAnimation {
constructor(node) {
this.node = node;
}
start(duration) {
this.duration = duration;
this.onProgress(0);
this.startTime = performance.now();
this.frameId = requestAnimationFrame(() => this.onFrame());
}
onFrame() {
const timePassed = performance.now() - this.startTime;
const progress = Math.min(timePassed / this.duration, 1);
this.onProgress(progress);
if (progress < 1) {
// 아직 더 그려야 할 프레임이 있어요
this.frameId = requestAnimationFrame(() => this.onFrame());
}
}
onProgress(progress) {
this.node.style.opacity = progress;
}
stop() {
cancelAnimationFrame(this.frameId);
this.startTime = null;
this.frameId = null;
this.duration = 0;
}
}
label, button { display: block; margin-bottom: 20px; }
html, body { min-height: 300px; }
onAppear 같은 Effect Event는 반응형이 아니므로, 애니메이션을 다시 트리거하지 않고 안에서 duration을 읽을 수 있어요.
이 예시에서, "Toggle theme"를 누를 때마다, 채팅이 다시 연결돼요. 왜 이런 일이 일어나나요? Server URL을 편집하거나 다른 채팅방을 선택할 때만 채팅이 다시 연결되도록 실수를 고치세요.
chat.js를 외부 서드파티 라이브러리로 취급하세요: API를 확인하기 위해 참조할 수 있지만, 편집하지는 마세요.
이것을 고치는 방법은 여러 가지가 있지만, 궁극적으로 객체를 의존성으로 갖는 것을 피하고 싶어요.
import { useState } from 'react';
import ChatRoom from './ChatRoom.js';
export default function App() {
const [isDark, setIsDark] = useState(false);
const [roomId, setRoomId] = useState('general');
const [serverUrl, setServerUrl] = useState('https://localhost:1234');
const options = {
serverUrl: serverUrl,
roomId: roomId
};
return (
<div className={isDark ? 'dark' : 'light'}>
<button onClick={() => setIsDark(!isDark)}>
Toggle theme
</button>
<label>
Server URL:{' '}
<input
value={serverUrl}
onChange={e => setServerUrl(e.target.value)}
/>
</label>
<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>
<hr />
<ChatRoom options={options} />
</div>
);
}
import { useEffect } from 'react';
import { createConnection } from './chat.js';
export default function ChatRoom({ options }) {
useEffect(() => {
const connection = createConnection(options);
connection.connect();
return () => connection.disconnect();
}, [options]);
return <h1>Welcome to the {options.roomId} room!</h1>;
}
export function createConnection({ serverUrl, roomId }) {
// 실제 구현이라면 정말로 서버에 연결할 거예요
if (typeof serverUrl !== 'string') {
throw Error('Expected serverUrl to be a string. Received: ' + serverUrl);
}
if (typeof roomId !== 'string') {
throw Error('Expected roomId to be a string. Received: ' + roomId);
}
return {
connect() {
console.log('✅ Connecting to "' + roomId + '" room at ' + serverUrl + '...');
},
disconnect() {
console.log('❌ Disconnected from "' + roomId + '" room at ' + serverUrl);
}
};
}
label, button { display: block; margin-bottom: 5px; }
.dark { background: #222; color: #eee; }
Effect가 options 객체에 의존하기 때문에 다시 실행돼요. 객체는 의도치 않게 재생성될 수 있으므로, 가능한 한 Effect의 의존성으로 피해야 해요.
가장 침습적이지 않은 수정은 Effect 바로 밖에서 roomId와 serverUrl을 읽고, Effect가 그러한 원시 값들에 의존하게 하는 거예요 (의도치 않게 변경될 수 없어요). Effect 안에서 객체를 생성하고 createConnection에 전달하세요:
import { useState } from 'react';
import ChatRoom from './ChatRoom.js';
export default function App() {
const [isDark, setIsDark] = useState(false);
const [roomId, setRoomId] = useState('general');
const [serverUrl, setServerUrl] = useState('https://localhost:1234');
const options = {
serverUrl: serverUrl,
roomId: roomId
};
return (
<div className={isDark ? 'dark' : 'light'}>
<button onClick={() => setIsDark(!isDark)}>
Toggle theme
</button>
<label>
Server URL:{' '}
<input
value={serverUrl}
onChange={e => setServerUrl(e.target.value)}
/>
</label>
<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>
<hr />
<ChatRoom options={options} />
</div>
);
}
import { useEffect } from 'react';
import { createConnection } from './chat.js';
export default function ChatRoom({ options }) {
const { roomId, serverUrl } = options;
useEffect(() => {
const connection = createConnection({
roomId: roomId,
serverUrl: serverUrl
});
connection.connect();
return () => connection.disconnect();
}, [roomId, serverUrl]);
return <h1>Welcome to the {options.roomId} room!</h1>;
}
export function createConnection({ serverUrl, roomId }) {
// 실제 구현이라면 정말로 서버에 연결할 거예요
if (typeof serverUrl !== 'string') {
throw Error('Expected serverUrl to be a string. Received: ' + serverUrl);
}
if (typeof roomId !== 'string') {
throw Error('Expected roomId to be a string. Received: ' + roomId);
}
return {
connect() {
console.log('✅ Connecting to "' + roomId + '" room at ' + serverUrl + '...');
},
disconnect() {
console.log('❌ Disconnected from "' + roomId + '" room at ' + serverUrl);
}
};
}
label, button { display: block; margin-bottom: 5px; }
.dark { background: #222; color: #eee; }
객체 options prop을 더 구체적인 roomId와 serverUrl props로 교체하는 것이 더 좋을 거예요:
import { useState } from 'react';
import ChatRoom from './ChatRoom.js';
export default function App() {
const [isDark, setIsDark] = useState(false);
const [roomId, setRoomId] = useState('general');
const [serverUrl, setServerUrl] = useState('https://localhost:1234');
return (
<div className={isDark ? 'dark' : 'light'}>
<button onClick={() => setIsDark(!isDark)}>
Toggle theme
</button>
<label>
Server URL:{' '}
<input
value={serverUrl}
onChange={e => setServerUrl(e.target.value)}
/>
</label>
<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>
<hr />
<ChatRoom
roomId={roomId}
serverUrl={serverUrl}
/>
</div>
);
}
import { useState, useEffect } from 'react';
import { createConnection } from './chat.js';
export default function ChatRoom({ roomId, serverUrl }) {
useEffect(() => {
const connection = createConnection({
roomId: roomId,
serverUrl: serverUrl
});
connection.connect();
return () => connection.disconnect();
}, [roomId, serverUrl]);
return <h1>Welcome to the {roomId} room!</h1>;
}
export function createConnection({ serverUrl, roomId }) {
// 실제 구현이라면 정말로 서버에 연결할 거예요
if (typeof serverUrl !== 'string') {
throw Error('Expected serverUrl to be a string. Received: ' + serverUrl);
}
if (typeof roomId !== 'string') {
throw Error('Expected roomId to be a string. Received: ' + roomId);
}
return {
connect() {
console.log('✅ Connecting to "' + roomId + '" room at ' + serverUrl + '...');
},
disconnect() {
console.log('❌ Disconnected from "' + roomId + '" room at ' + serverUrl);
}
};
}
label, button { display: block; margin-bottom: 5px; }
.dark { background: #222; color: #eee; }
가능하면 원시 props를 고수하면 나중에 컴포넌트를 최적화하기 더 쉬워져요.
이 예시는 암호화가 있거나 없이 채팅에 연결해요. 체크박스를 토글하고 암호화가 켜지고 꺼질 때 콘솔에서 다른 메시지를 확인해봐요. 방을 변경해봐요. 그런 다음 테마를 토글해봐요. 채팅방에 연결되어 있으면, 몇 초마다 새 메시지를 받을 거예요. 선택한 테마와 색상이 일치하는지 확인해봐요.
이 예시에서, 테마를 변경하려고 할 때마다 채팅이 다시 연결돼요. 이것을 고치세요. 수정 후, 테마를 변경해도 채팅이 다시 연결되지 않아야 하지만, 암호화 설정을 토글하거나 방을 변경하면 다시 연결되어야 해요.
chat.js의 코드는 변경하지 마세요. 그 외에는, 같은 동작을 유지하는 한 모든 코드를 변경할 수 있어요. 예를 들어, 어떤 props가 전달되는지 변경하는 것이 도움이 될 수 있어요.
두 개의 함수를 전달하고 있어요: onMessage와 createConnection. 둘 다 App이 리렌더링될 때마다 처음부터 생성돼요. 매번 새 값으로 간주되므로, Effect를 다시 트리거하는 이유예요.
이 함수들 중 하나는 이벤트 핸들러예요. 이벤트 핸들러 함수의 새 값에 "반응"하지 않고 Effect에서 이벤트 핸들러를 호출하는 방법을 알고 있나요? 편리할 거예요!
다른 함수는 일부 state를 가져온 API 메서드에 전달하기 위해서만 존재해요. 이 함수가 정말 필요한가요? 전달되는 필수 정보는 무엇인가요? App.js에서 ChatRoom.js로 일부 import를 이동해야 할 수도 있어요.
{
"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 } from 'react';
import ChatRoom from './ChatRoom.js';
import {
createEncryptedConnection,
createUnencryptedConnection,
} from './chat.js';
import { showNotification } from './notifications.js';
export default function App() {
const [isDark, setIsDark] = useState(false);
const [roomId, setRoomId] = useState('general');
const [isEncrypted, setIsEncrypted] = useState(false);
return (
<>
<label>
<input
type="checkbox"
checked={isDark}
onChange={e => setIsDark(e.target.checked)}
/>
Use dark theme
</label>
<label>
<input
type="checkbox"
checked={isEncrypted}
onChange={e => setIsEncrypted(e.target.checked)}
/>
Enable encryption
</label>
<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>
<hr />
<ChatRoom
roomId={roomId}
onMessage={msg => {
showNotification('New message: ' + msg, isDark ? 'dark' : 'light');
}}
createConnection={() => {
const options = {
serverUrl: 'https://localhost:1234',
roomId: roomId
};
if (isEncrypted) {
return createEncryptedConnection(options);
} else {
return createUnencryptedConnection(options);
}
}}
/>
</>
);
}
import { useState, useEffect } from 'react';
import { useEffectEvent } from 'react';
export default function ChatRoom({ roomId, createConnection, onMessage }) {
useEffect(() => {
const connection = createConnection();
connection.on('message', (msg) => onMessage(msg));
connection.connect();
return () => connection.disconnect();
}, [createConnection, onMessage]);
return <h1>Welcome to the {roomId} room!</h1>;
}
// chat.js
export function createEncryptedConnection({ serverUrl, roomId }) {
// 실제 구현이라면 정말로 서버에 연결할 거예요
if (typeof serverUrl !== 'string') {
throw Error('Expected serverUrl to be a string. Received: ' + serverUrl);
}
if (typeof roomId !== 'string') {
throw Error('Expected roomId to be a string. Received: ' + roomId);
}
let intervalId;
let messageCallback;
return {
connect() {
console.log('✅ 🔐 Connecting to "' + roomId + '" room... (encrypted)');
clearInterval(intervalId);
intervalId = setInterval(() => {
if (messageCallback) {
if (Math.random() > 0.5) {
messageCallback('hey')
} else {
messageCallback('lol');
}
}
}, 3000);
},
disconnect() {
clearInterval(intervalId);
messageCallback = null;
console.log('❌ 🔐 Disconnected from "' + roomId + '" room (encrypted)');
},
on(event, callback) {
if (messageCallback) {
throw Error('Cannot add the handler twice.');
}
if (event !== 'message') {
throw Error('Only "message" event is supported.');
}
messageCallback = callback;
},
};
}
export function createUnencryptedConnection({ serverUrl, roomId }) {
// 실제 구현이라면 정말로 서버에 연결할 거예요
if (typeof serverUrl !== 'string') {
throw Error('Expected serverUrl to be a string. Received: ' + serverUrl);
}
if (typeof roomId !== 'string') {
throw Error('Expected roomId to be a string. Received: ' + roomId);
}
let intervalId;
let messageCallback;
return {
connect() {
console.log('✅ Connecting to "' + roomId + '" room (unencrypted)...');
clearInterval(intervalId);
intervalId = setInterval(() => {
if (messageCallback) {
if (Math.random() > 0.5) {
messageCallback('hey')
} else {
messageCallback('lol');
}
}
}, 3000);
},
disconnect() {
clearInterval(intervalId);
messageCallback = null;
console.log('❌ Disconnected from "' + roomId + '" room (unencrypted)');
},
on(event, callback) {
if (messageCallback) {
throw Error('Cannot add the handler twice.');
}
if (event !== 'message') {
throw Error('Only "message" event is supported.');
}
messageCallback = callback;
},
};
}
// 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, button { display: block; margin-bottom: 5px; }
이것을 해결하는 올바른 방법은 여러 개가 있지만, 여기 하나의 가능한 해결책이 있어요.
원래 예시에서, 테마를 토글하면 다른 onMessage와 createConnection 함수가 생성되어 전달됐어요. Effect가 이 함수들에 의존했기 때문에, 테마를 토글할 때마다 채팅이 다시 연결됐어요.
onMessage의 문제를 고치려면, Effect Event로 래핑해야 했어요:
export default function ChatRoom({ roomId, createConnection, onMessage }) {
const onReceiveMessage = useEffectEvent(onMessage);
useEffect(() => {
const connection = createConnection();
connection.on('message', (msg) => onReceiveMessage(msg));
// ...
onMessage prop과 달리, onReceiveMessage Effect Event는 반응형이 아니에요. 이것이 Effect의 의존성일 필요가 없는 이유예요. 결과적으로, onMessage의 변경이 채팅을 다시 연결하게 만들지 않을 거예요.
createConnection은 반응형이어야 하므로 같은 방법을 사용할 수 없어요. 사용자가 암호화된 연결과 암호화되지 않은 연결을 전환하거나 현재 방을 전환하면 Effect가 다시 트리거되기를 원해요. 하지만 createConnection은 함수이므로, 읽는 정보가 실제로 변경되었는지 확인할 수 없어요. 이를 해결하려면, App 컴포넌트에서 createConnection을 전달하는 대신, 원시 roomId와 isEncrypted 값을 전달하세요:
<ChatRoom
roomId={roomId}
isEncrypted={isEncrypted}
onMessage={msg => {
showNotification('New message: ' + msg, isDark ? 'dark' : 'light');
}}
/>
이제 App에서 전달하는 대신 createConnection 함수를 Effect 안으로 이동할 수 있어요:
import {
createEncryptedConnection,
createUnencryptedConnection,
} from './chat.js';
export default function ChatRoom({ roomId, isEncrypted, onMessage }) {
const onReceiveMessage = useEffectEvent(onMessage);
useEffect(() => {
function createConnection() {
const options = {
serverUrl: 'https://localhost:1234',
roomId: roomId
};
if (isEncrypted) {
return createEncryptedConnection(options);
} else {
return createUnencryptedConnection(options);
}
}
// ...
이 두 가지 변경 후, Effect는 더 이상 어떤 함수 값에도 의존하지 않아요:
export default function ChatRoom({ roomId, isEncrypted, onMessage }) { // 반응형 값들
const onReceiveMessage = useEffectEvent(onMessage); // 반응형 아니에요
useEffect(() => {
function createConnection() {
const options = {
serverUrl: 'https://localhost:1234',
roomId: roomId // 반응형 값 읽기
};
if (isEncrypted) { // 반응형 값 읽기
return createEncryptedConnection(options);
} else {
return createUnencryptedConnection(options);
}
}
const connection = createConnection();
connection.on('message', (msg) => onReceiveMessage(msg));
connection.connect();
return () => connection.disconnect();
}, [roomId, isEncrypted]); // ✅ 모든 의존성이 선언되었어요
결과적으로, 의미 있는 것(roomId 또는 isEncrypted)이 변경될 때만 채팅이 다시 연결돼요:
{
"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 } from 'react';
import ChatRoom from './ChatRoom.js';
import { showNotification } from './notifications.js';
export default function App() {
const [isDark, setIsDark] = useState(false);
const [roomId, setRoomId] = useState('general');
const [isEncrypted, setIsEncrypted] = useState(false);
return (
<>
<label>
<input
type="checkbox"
checked={isDark}
onChange={e => setIsDark(e.target.checked)}
/>
Use dark theme
</label>
<label>
<input
type="checkbox"
checked={isEncrypted}
onChange={e => setIsEncrypted(e.target.checked)}
/>
Enable encryption
</label>
<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>
<hr />
<ChatRoom
roomId={roomId}
isEncrypted={isEncrypted}
onMessage={msg => {
showNotification('New message: ' + msg, isDark ? 'dark' : 'light');
}}
/>
</>
);
}
import { useState, useEffect } from 'react';
import { useEffectEvent } from 'react';
import {
createEncryptedConnection,
createUnencryptedConnection,
} from './chat.js';
export default function ChatRoom({ roomId, isEncrypted, onMessage }) {
const onReceiveMessage = useEffectEvent(onMessage);
useEffect(() => {
function createConnection() {
const options = {
serverUrl: 'https://localhost:1234',
roomId: roomId
};
if (isEncrypted) {
return createEncryptedConnection(options);
} else {
return createUnencryptedConnection(options);
}
}
const connection = createConnection();
connection.on('message', (msg) => onReceiveMessage(msg));
connection.connect();
return () => connection.disconnect();
}, [roomId, isEncrypted]);
return <h1>Welcome to the {roomId} room!</h1>;
}
export function createEncryptedConnection({ serverUrl, roomId }) {
// 실제 구현이라면 정말로 서버에 연결할 거예요
if (typeof serverUrl !== 'string') {
throw Error('Expected serverUrl to be a string. Received: ' + serverUrl);
}
if (typeof roomId !== 'string') {
throw Error('Expected roomId to be a string. Received: ' + roomId);
}
let intervalId;
let messageCallback;
return {
connect() {
console.log('✅ 🔐 Connecting to "' + roomId + '" room... (encrypted)');
clearInterval(intervalId);
intervalId = setInterval(() => {
if (messageCallback) {
if (Math.random() > 0.5) {
messageCallback('hey')
} else {
messageCallback('lol');
}
}
}, 3000);
},
disconnect() {
clearInterval(intervalId);
messageCallback = null;
console.log('❌ 🔐 Disconnected from "' + roomId + '" room (encrypted)');
},
on(event, callback) {
if (messageCallback) {
throw Error('Cannot add the handler twice.');
}
if (event !== 'message') {
throw Error('Only "message" event is supported.');
}
messageCallback = callback;
},
};
}
export function createUnencryptedConnection({ serverUrl, roomId }) {
// 실제 구현이라면 정말로 서버에 연결할 거예요
if (typeof serverUrl !== 'string') {
throw Error('Expected serverUrl to be a string. Received: ' + serverUrl);
}
if (typeof roomId !== 'string') {
throw Error('Expected roomId to be a string. Received: ' + roomId);
}
let intervalId;
let messageCallback;
return {
connect() {
console.log('✅ Connecting to "' + roomId + '" room (unencrypted)...');
clearInterval(intervalId);
intervalId = setInterval(() => {
if (messageCallback) {
if (Math.random() > 0.5) {
messageCallback('hey')
} else {
messageCallback('lol');
}
}
}, 3000);
},
disconnect() {
clearInterval(intervalId);
messageCallback = null;
console.log('❌ Disconnected from "' + roomId + '" room (unencrypted)');
},
on(event, callback) {
if (messageCallback) {
throw Error('Cannot add the handler twice.');
}
if (event !== 'message') {
throw Error('Only "message" event is supported.');
}
messageCallback = callback;
},
};
}
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, button { display: block; margin-bottom: 5px; }