useEffect는 컴포넌트를 외부 시스템과 동기화할 수 있게 해주는 React Hook이에요.
useEffect(setup, dependencies?)
useEffect(setup, dependencies?)컴포넌트의 최상위 레벨에서 useEffect를 호출해서 Effect를 선언하세요:
import { useState, useEffect } from 'react';
import { createConnection } from './chat.js';
function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://localhost:1234');
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
connection.disconnect();
};
}, [serverUrl, roomId]);
// ...
}
setup: Effect의 로직이 담긴 함수예요. setup 함수는 선택적으로 cleanup 함수를 반환할 수도 있어요. 컴포넌트가 커밋될 때, React는 setup 함수를 실행해요. 의존성이 변경된 후 매 커밋 이후에, React는 먼저 (제공했다면) 이전 값으로 cleanup 함수를 실행한 다음, 새로운 값으로 setup 함수를 실행해요. 컴포넌트가 DOM에서 제거된 후에, React는 cleanup 함수를 실행해요.dependencies: setup 코드 내부에서 참조되는 모든 반응형 값의 목록이에요. 반응형 값에는 props, state, 그리고 컴포넌트 본문 내부에 직접 선언된 모든 변수와 함수가 포함돼요. 린터가 React용으로 설정되어 있다면, 모든 반응형 값이 의존성으로 올바르게 지정되었는지 검증할 거예요. 의존성 목록은 항목 수가 일정해야 하고 [dep1, dep2, dep3]처럼 인라인으로 작성되어야 해요. React는 Object.is 비교를 사용해서 각 의존성을 이전 값과 비교해요. 이 인자를 생략하면, Effect는 컴포넌트의 매 커밋 후에 다시 실행될 거예요. 의존성 배열을 전달하는 것, 빈 배열, 의존성을 전달하지 않는 것의 차이를 확인해보세요.useEffect는 undefined를 반환해요.
useEffect는 Hook이기 때문에, 컴포넌트의 최상위 레벨이나 자체 Hook에서만 호출할 수 있어요. 반복문이나 조건문 안에서는 호출할 수 없어요. 필요하다면, 새 컴포넌트를 추출해서 state를 그쪽으로 옮기세요.
외부 시스템과 동기화하려는 게 아니라면, Effect가 필요하지 않을 수도 있어요.
Strict Mode가 켜져 있으면, React는 첫 번째 실제 setup 전에 개발 환경에서만 setup+cleanup 사이클을 한 번 더 실행해요. 이것은 cleanup 로직이 setup 로직을 "미러링"하고 setup이 하고 있는 것을 중지하거나 되돌리는지 확인하는 스트레스 테스트예요. 이것이 문제를 일으킨다면, cleanup 함수를 구현하세요.
일부 의존성이 컴포넌트 내부에 정의된 객체나 함수라면, Effect가 필요 이상으로 자주 재실행될 위험이 있어요. 이를 해결하려면, 불필요한 객체 및 함수 의존성을 제거하세요. Effect 외부로 state 업데이트와 비반응형 로직을 추출할 수도 있어요.
Effect가 상호작용(클릭 같은)에 의한 것이 아니라면, React는 일반적으로 브라우저가 Effect를 실행하기 전에 먼저 업데이트된 화면을 그리도록 해요. Effect가 시각적인 작업을 하고 있고(예: 툴팁 위치 지정), 지연이 눈에 띈다면(예: 깜빡임), useEffect를 useLayoutEffect로 교체하세요.
Effect가 상호작용(클릭 같은)에 의한 것이라면, React는 브라우저가 업데이트된 화면을 그리기 전에 Effect를 실행할 수도 있어요. 이렇게 하면 Effect의 결과가 이벤트 시스템에서 관찰될 수 있어요. 보통은 이게 예상대로 작동해요. 하지만 alert() 같이 페인트 후에 작업을 지연시켜야 한다면, setTimeout을 사용할 수 있어요. 더 많은 정보는 reactwg/react-18/128을 참고하세요.
Effect가 상호작용(클릭 같은)에 의한 것이더라도, React는 Effect 내부의 state 업데이트를 처리하기 전에 브라우저가 화면을 다시 그리도록 허용할 수 있어요. 보통은 이게 예상대로 작동해요. 하지만 브라우저가 화면을 다시 그리는 것을 차단해야 한다면, useEffect를 useLayoutEffect로 교체해야 해요.
Effect는 클라이언트에서만 실행돼요. 서버 렌더링 중에는 실행되지 않아요.
일부 컴포넌트는 페이지에 표시되는 동안 네트워크, 브라우저 API, 또는 서드파티 라이브러리에 연결된 상태를 유지해야 해요. 이런 시스템들은 React가 제어하지 않기 때문에 외부 시스템이라고 불러요.
컴포넌트를 외부 시스템에 연결하려면, 컴포넌트의 최상위 레벨에서 useEffect를 호출하세요:
import { useState, useEffect } from 'react';
import { createConnection } from './chat.js';
function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://localhost:1234');
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
connection.disconnect();
};
}, [serverUrl, roomId]);
// ...
}
useEffect에는 두 개의 인자를 전달해야 해요:
React는 필요할 때마다 setup과 cleanup 함수를 호출하는데, 이게 여러 번 일어날 수 있어요:
위의 예제로 이 순서를 설명해볼게요.
위의 ChatRoom 컴포넌트가 페이지에 추가되면, 초기 serverUrl과 roomId로 채팅방에 연결할 거예요. 커밋의 결과로 serverUrl이나 roomId가 변경되면(예: 사용자가 드롭다운에서 다른 채팅방을 선택하면), Effect가 이전 방과의 연결을 끊고, 다음 방에 연결할 거예요. ChatRoom 컴포넌트가 페이지에서 제거되면, Effect가 마지막으로 한 번 연결을 끊을 거예요.
버그를 찾는 데 도움을 주기 위해, 개발 환경에서 React는 setup 전에 setup과 cleanup을 한 번 더 실행해요. 이것은 Effect의 로직이 올바르게 구현되었는지 검증하는 스트레스 테스트예요. 이것이 눈에 보이는 문제를 일으킨다면, cleanup 함수에 일부 로직이 누락된 거예요. cleanup 함수는 setup 함수가 하고 있던 것을 중지하거나 되돌려야 해요. 기본 원칙은 사용자가 setup이 한 번 호출되는 것(프로덕션처럼)과 setup → cleanup → setup 순서(개발처럼)를 구별할 수 없어야 한다는 거예요. 일반적인 해결책을 확인해보세요.
각 Effect를 독립적인 프로세스로 작성하고 한 번에 하나의 setup/cleanup 사이클에 대해 생각하세요. 컴포넌트가 마운트, 업데이트, 또는 언마운트 중인지는 중요하지 않아야 해요. cleanup 로직이 setup 로직을 올바르게 "미러링"하면, Effect는 필요한 만큼 자주 setup과 cleanup을 실행할 수 있는 탄력성을 갖게 돼요.
참고
Effect는 컴포넌트를 외부 시스템과 동기화할 수 있게 해줘요. 여기서 외부 시스템은 다음과 같은 React가 제어하지 않는 모든 코드를 의미해요:
setInterval()과clearInterval()로 관리되는 타이머.window.addEventListener()와window.removeEventListener()를 사용하는 이벤트 구독.animation.start()와animation.reset()같은 API를 가진 서드파티 애니메이션 라이브러리.외부 시스템에 연결하지 않는다면, Effect가 필요하지 않을 수도 있어요.
이 예제에서, ChatRoom 컴포넌트는 Effect를 사용해서 chat.js에 정의된 외부 시스템에 연결된 상태를 유지해요. "Open chat"을 눌러서 ChatRoom 컴포넌트를 나타나게 하세요. 이 샌드박스는 개발 모드로 실행되기 때문에, 여기서 설명한 것처럼 추가로 연결-끊기 사이클이 있어요. 드롭다운과 입력을 사용해서 roomId와 serverUrl을 변경하면서 Effect가 채팅에 어떻게 다시 연결되는지 확인해보세요. "Close chat"을 눌러서 Effect가 마지막으로 연결을 끊는 것을 확인하세요.
import { useState, useEffect } from 'react';
import { createConnection } from './chat.js';
function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://localhost:1234');
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
connection.disconnect();
};
}, [roomId, serverUrl]);
return (
<>
<label>
Server URL:{' '}
<input
value={serverUrl}
onChange={e => setServerUrl(e.target.value)}
/>
</label>
<h1>Welcome to the {roomId} room!</h1>
</>
);
}
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} />}
</>
);
}
// src/chat.js
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);
}
};
}
input { display: block; margin-bottom: 20px; }
button { margin-left: 10px; }
이 예제에서, 외부 시스템은 브라우저 DOM 자체예요. 보통은 JSX로 이벤트 리스너를 지정하지만, 이런 방식으로는 전역 window 객체를 들을 수 없어요. Effect를 사용하면 window 객체에 연결해서 이벤트를 들을 수 있어요. pointermove 이벤트를 듣으면 커서(또는 손가락) 위치를 추적하고 빨간 점을 움직일 수 있어요.
import { useState, useEffect } from 'react';
export default function App() {
const [position, setPosition] = useState({ x: 0, y: 0 });
useEffect(() => {
function handleMove(e) {
setPosition({ x: e.clientX, y: e.clientY });
}
window.addEventListener('pointermove', handleMove);
return () => {
window.removeEventListener('pointermove', handleMove);
};
}, []);
return (
<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 {
min-height: 300px;
}
이 예제에서, 외부 시스템은 animation.js의 애니메이션 라이브러리예요. DOM 노드를 인자로 받아서 애니메이션을 제어하는 start()와 stop() 메서드를 노출하는 FadeInAnimation이라는 JavaScript 클래스를 제공해요. 이 컴포넌트는 ref를 사용해서 기본 DOM 노드에 접근해요. Effect는 ref에서 DOM 노드를 읽고 컴포넌트가 나타날 때 해당 노드에 대한 애니메이션을 자동으로 시작해요.
import { useState, useEffect, useRef } from 'react';
import { FadeInAnimation } from './animation.js';
function Welcome() {
const ref = useRef(null);
useEffect(() => {
const animation = new FadeInAnimation(ref.current);
animation.start(1000);
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 [show, setShow] = useState(false);
return (
<>
<button onClick={() => setShow(!show)}>
{show ? 'Remove' : 'Show'}
</button>
<hr />
{show && <Welcome />}
</>
);
}
// src/animation.js
export class FadeInAnimation {
constructor(node) {
this.node = node;
}
start(duration) {
this.duration = duration;
if (this.duration === 0) {
// Jump to end immediately
this.onProgress(1);
} else {
this.onProgress(0);
// Start animating
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) {
// We still have more frames to paint
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; }
이 예제에서, 외부 시스템은 브라우저 DOM이에요. ModalDialog 컴포넌트는 <dialog> 요소를 렌더링해요. Effect를 사용해서 isOpen prop을 showModal()과 close() 메서드 호출과 동기화해요.
import { useState } from 'react';
import ModalDialog from './ModalDialog.js';
export default function App() {
const [show, setShow] = useState(false);
return (
<>
<button onClick={() => setShow(true)}>
Open dialog
</button>
<ModalDialog isOpen={show}>
Hello there!
<br />
<button onClick={() => {
setShow(false);
}}>Close</button>
</ModalDialog>
</>
);
}
// src/ModalDialog.js
import { useEffect, useRef } from 'react';
export default function ModalDialog({ isOpen, children }) {
const ref = useRef();
useEffect(() => {
if (!isOpen) {
return;
}
const dialog = ref.current;
dialog.showModal();
return () => {
dialog.close();
};
}, [isOpen]);
return <dialog ref={ref}>{children}</dialog>;
}
body {
min-height: 300px;
}
이 예제에서, 외부 시스템은 다시 브라우저 DOM이에요. App 컴포넌트는 긴 목록, 그 다음 Box 컴포넌트, 그리고 또 다른 긴 목록을 표시해요. 목록을 아래로 스크롤해보세요. Box 컴포넌트가 뷰포트에 완전히 보일 때 배경색이 검은색으로 변하는 것을 확인하세요. 이를 구현하기 위해, Box 컴포넌트는 Effect를 사용해서 IntersectionObserver를 관리해요. 이 브라우저 API는 DOM 요소가 뷰포트에 보일 때 알려줘요.
import Box from './Box.js';
export default function App() {
return (
<>
<LongSection />
<Box />
<LongSection />
<Box />
<LongSection />
</>
);
}
function LongSection() {
const items = [];
for (let i = 0; i < 50; i++) {
items.push(<li key={i}>Item #{i} (keep scrolling)</li>);
}
return <ul>{items}</ul>
}
// src/Box.js
import { useRef, useEffect } from 'react';
export default function Box() {
const ref = useRef(null);
useEffect(() => {
const div = ref.current;
const observer = new IntersectionObserver(entries => {
const entry = entries[0];
if (entry.isIntersecting) {
document.body.style.backgroundColor = 'black';
document.body.style.color = 'white';
} else {
document.body.style.backgroundColor = 'white';
document.body.style.color = 'black';
}
}, {
threshold: 1.0
});
observer.observe(div);
return () => {
observer.disconnect();
}
}, []);
return (
<div ref={ref} style={{
margin: 20,
height: 100,
width: 100,
border: '2px solid black',
backgroundColor: 'blue'
}} />
);
}
Effect는 "탈출구"예요: React 밖으로 "나가야" 할 때와 사용 사례에 더 나은 내장 솔루션이 없을 때 사용해요. 수동으로 Effect를 자주 작성해야 한다면, 보통 컴포넌트가 의존하는 공통 동작에 대한 커스텀 Hook을 추출해야 한다는 신호예요.
예를 들어, 이 useChatRoom 커스텀 Hook은 Effect의 로직을 더 선언적인 API 뒤에 "숨겨요":
function useChatRoom({ serverUrl, roomId }) {
useEffect(() => {
const options = {
serverUrl: serverUrl,
roomId: roomId
};
const connection = createConnection(options);
connection.connect();
return () => connection.disconnect();
}, [roomId, serverUrl]);
}
그러면 어떤 컴포넌트에서든 이렇게 사용할 수 있어요:
function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://localhost:1234');
useChatRoom({
roomId: roomId,
serverUrl: serverUrl
});
// ...
React 생태계에는 모든 용도에 맞는 훌륭한 커스텀 Hook도 많이 있어요.
커스텀 Hook으로 Effect를 감싸는 방법에 대해 더 알아보세요.
useChatRoom Hook이 예제는 이전 예제 중 하나와 동일하지만, 로직이 커스텀 Hook으로 추출되었어요.
import { useState } from 'react';
import { useChatRoom } from './useChatRoom.js';
function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://localhost:1234');
useChatRoom({
roomId: roomId,
serverUrl: serverUrl
});
return (
<>
<label>
Server URL:{' '}
<input
value={serverUrl}
onChange={e => setServerUrl(e.target.value)}
/>
</label>
<h1>Welcome to the {roomId} room!</h1>
</>
);
}
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} />}
</>
);
}
// src/useChatRoom.js
import { useEffect } from 'react';
import { createConnection } from './chat.js';
export function useChatRoom({ serverUrl, roomId }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
connection.disconnect();
};
}, [roomId, serverUrl]);
}
// src/chat.js
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);
}
};
}
input { display: block; margin-bottom: 20px; }
button { margin-left: 10px; }
useWindowListener Hook이 예제는 이전 예제 중 하나와 동일하지만, 로직이 커스텀 Hook으로 추출되었어요.
import { useState } from 'react';
import { useWindowListener } from './useWindowListener.js';
export default function App() {
const [position, setPosition] = useState({ x: 0, y: 0 });
useWindowListener('pointermove', (e) => {
setPosition({ x: e.clientX, y: e.clientY });
});
return (
<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,
}} />
);
}
// src/useWindowListener.js
import { useState, useEffect } from 'react';
export function useWindowListener(eventType, listener) {
useEffect(() => {
window.addEventListener(eventType, listener);
return () => {
window.removeEventListener(eventType, listener);
};
}, [eventType, listener]);
}
body {
min-height: 300px;
}
useIntersectionObserver Hook이 예제는 이전 예제 중 하나와 동일하지만, 로직이 부분적으로 커스텀 Hook으로 추출되었어요.
import Box from './Box.js';
export default function App() {
return (
<>
<LongSection />
<Box />
<LongSection />
<Box />
<LongSection />
</>
);
}
function LongSection() {
const items = [];
for (let i = 0; i < 50; i++) {
items.push(<li key={i}>Item #{i} (keep scrolling)</li>);
}
return <ul>{items}</ul>
}
// src/Box.js
import { useRef, useEffect } from 'react';
import { useIntersectionObserver } from './useIntersectionObserver.js';
export default function Box() {
const ref = useRef(null);
const isIntersecting = useIntersectionObserver(ref);
useEffect(() => {
if (isIntersecting) {
document.body.style.backgroundColor = 'black';
document.body.style.color = 'white';
} else {
document.body.style.backgroundColor = 'white';
document.body.style.color = 'black';
}
}, [isIntersecting]);
return (
<div ref={ref} style={{
margin: 20,
height: 100,
width: 100,
border: '2px solid black',
backgroundColor: 'blue'
}} />
);
}
// src/useIntersectionObserver.js
import { useState, useEffect } from 'react';
export function useIntersectionObserver(ref) {
const [isIntersecting, setIsIntersecting] = useState(false);
useEffect(() => {
const div = ref.current;
const observer = new IntersectionObserver(entries => {
const entry = entries[0];
setIsIntersecting(entry.isIntersecting);
}, {
threshold: 1.0
});
observer.observe(div);
return () => {
observer.disconnect();
}
}, [ref]);
return isIntersecting;
}
때때로, 외부 시스템을 컴포넌트의 일부 prop이나 state와 동기화하고 싶을 수 있어요.
예를 들어, React 없이 작성된 서드파티 맵 위젯이나 비디오 플레이어 컴포넌트가 있다면, Effect를 사용해서 그 상태를 React 컴포넌트의 현재 상태와 일치시키는 메서드를 호출할 수 있어요. 이 Effect는 map-widget.js에 정의된 MapWidget 클래스의 인스턴스를 생성해요. Map 컴포넌트의 zoomLevel prop을 변경하면, Effect가 클래스 인스턴스의 setZoom()을 호출해서 동기화를 유지해요:
// package.json (숨김)
{
"dependencies": {
"leaflet": "1.9.1",
"react": "latest",
"react-dom": "latest",
"react-scripts": "latest",
"remarkable": "2.0.1"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test --env=jsdom",
"eject": "react-scripts eject"
}
}
// src/App.js
import { useState } from 'react';
import Map from './Map.js';
export default function App() {
const [zoomLevel, setZoomLevel] = useState(0);
return (
<>
Zoom level: {zoomLevel}x
<button onClick={() => setZoomLevel(zoomLevel + 1)}>+</button>
<button onClick={() => setZoomLevel(zoomLevel - 1)}>-</button>
<hr />
<Map zoomLevel={zoomLevel} />
</>
);
}
// src/Map.js
import { useRef, useEffect } from 'react';
import { MapWidget } from './map-widget.js';
export default function Map({ zoomLevel }) {
const containerRef = useRef(null);
const mapRef = useRef(null);
useEffect(() => {
if (mapRef.current === null) {
mapRef.current = new MapWidget(containerRef.current);
}
const map = mapRef.current;
map.setZoom(zoomLevel);
}, [zoomLevel]);
return (
<div
style={{ width: 200, height: 200 }}
ref={containerRef}
/>
);
}
// src/map-widget.js
import 'leaflet/dist/leaflet.css';
import * as L from 'leaflet';
export class MapWidget {
constructor(domNode) {
this.map = L.map(domNode, {
zoomControl: false,
doubleClickZoom: false,
boxZoom: false,
keyboard: false,
scrollWheelZoom: false,
zoomAnimation: false,
touchZoom: false,
zoomSnap: 0.1
});
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '© OpenStreetMap'
}).addTo(this.map);
this.map.setView([0, 0], 0);
}
setZoom(level) {
this.map.setZoom(level);
}
}
button { margin: 5px; }
이 예제에서는 cleanup 함수가 필요하지 않아요. MapWidget 클래스가 전달받은 DOM 노드만 관리하기 때문이에요. Map React 컴포넌트가 트리에서 제거된 후, DOM 노드와 MapWidget 클래스 인스턴스 모두 브라우저 JavaScript 엔진에 의해 자동으로 가비지 컬렉션될 거예요.
Effect를 사용해서 컴포넌트에 대한 데이터를 가져올 수 있어요. 프레임워크를 사용한다면, 프레임워크의 데이터 가져오기 메커니즘을 사용하는 것이 수동으로 Effect를 작성하는 것보다 훨씬 더 효율적일 거예요.
Effect에서 수동으로 데이터를 가져오고 싶다면, 코드가 이렇게 보일 수 있어요:
import { useState, useEffect } from 'react';
import { fetchBio } from './api.js';
export default function Page() {
const [person, setPerson] = useState('Alice');
const [bio, setBio] = useState(null);
useEffect(() => {
let ignore = false;
setBio(null);
fetchBio(person).then(result => {
if (!ignore) {
setBio(result);
}
});
return () => {
ignore = true;
};
}, [person]);
// ...
ignore 변수가 false로 초기화되고 cleanup 중에 true로 설정되는 것에 주목하세요. 이것은 코드가 "경쟁 조건"으로 고통받지 않도록 보장해요: 네트워크 응답이 보낸 순서와 다르게 도착할 수 있어요.
// src/App.js
import { useState, useEffect } from 'react';
import { fetchBio } from './api.js';
export default function Page() {
const [person, setPerson] = useState('Alice');
const [bio, setBio] = useState(null);
useEffect(() => {
let ignore = false;
setBio(null);
fetchBio(person).then(result => {
if (!ignore) {
setBio(result);
}
});
return () => {
ignore = true;
}
}, [person]);
return (
<>
<select value={person} onChange={e => {
setPerson(e.target.value);
}}>
<option value="Alice">Alice</option>
<option value="Bob">Bob</option>
<option value="Taylor">Taylor</option>
</select>
<hr />
<p><i>{bio ?? 'Loading...'}</i></p>
</>
);
}
// src/api.js (숨김)
export async function fetchBio(person) {
const delay = person === 'Bob' ? 2000 : 200;
return new Promise(resolve => {
setTimeout(() => {
resolve('This is ' + person + ''s bio.');
}, delay);
})
}
async / await 구문을 사용해서 다시 작성할 수도 있지만, 여전히 cleanup 함수를 제공해야 해요:
// src/App.js
import { useState, useEffect } from 'react';
import { fetchBio } from './api.js';
export default function Page() {
const [person, setPerson] = useState('Alice');
const [bio, setBio] = useState(null);
useEffect(() => {
async function startFetching() {
setBio(null);
const result = await fetchBio(person);
if (!ignore) {
setBio(result);
}
}
let ignore = false;
startFetching();
return () => {
ignore = true;
}
}, [person]);
return (
<>
<select value={person} onChange={e => {
setPerson(e.target.value);
}}>
<option value="Alice">Alice</option>
<option value="Bob">Bob</option>
<option value="Taylor">Taylor</option>
</select>
<hr />
<p><i>{bio ?? 'Loading...'}</i></p>
</>
);
}
// src/api.js (숨김)
export async function fetchBio(person) {
const delay = person === 'Bob' ? 2000 : 200;
return new Promise(resolve => {
setTimeout(() => {
resolve('This is ' + person + ''s bio.');
}, delay);
})
}
Effect에서 직접 데이터를 가져오는 것은 반복적이고 나중에 캐싱이나 서버 렌더링 같은 최적화를 추가하기 어렵게 만들어요. 커스텀 Hook을 사용하는 것이 더 쉬워요--자체 제작이든 커뮤니티에서 관리하는 것이든요.
Deep Dive: Effect에서 데이터 가져오기의 좋은 대안은 무엇인가요?Effect 내부에 fetch 호출을 작성하는 것은 데이터를 가져오는 인기 있는 방법이에요, 특히 완전히 클라이언트 측 앱에서요. 하지만 이것은 매우 수동적인 접근 방식이고 상당한 단점이 있어요:
fetch 호출을 작성할 때 상당히 많은 보일러플레이트 코드가 필요해요.이 단점 목록은 React에만 국한된 것이 아니에요. 어떤 라이브러리로든 마운트 시에 데이터를 가져오는 것에 적용돼요. 라우팅처럼, 데이터 가져오기를 잘 하는 것은 간단하지 않아요. 그래서 다음 접근 방식을 권장해요:
이 접근 방식 중 어느 것도 맞지 않는다면 Effect에서 직접 데이터를 계속 가져올 수 있어요.
Effect의 의존성을 "선택"할 수 없다는 점에 주목하세요. Effect의 코드에서 사용되는 모든 반응형 값은 의존성으로 선언되어야 해요. Effect의 의존성 목록은 주변 코드에 의해 결정돼요:
function ChatRoom({ roomId }) { // 이것은 반응형 값이에요
const [serverUrl, setServerUrl] = useState('https://localhost:1234'); // 이것도 반응형 값이에요
useEffect(() => {
const connection = createConnection(serverUrl, roomId); // 이 Effect는 이 반응형 값들을 읽어요
connection.connect();
return () => connection.disconnect();
}, [serverUrl, roomId]); // ✅ 그래서 Effect의 의존성으로 지정해야 해요
// ...
}
serverUrl이나 roomId가 변경되면, Effect는 새 값으로 채팅에 다시 연결할 거예요.
반응형 값에는 props와 컴포넌트 내부에 직접 선언된 모든 변수와 함수가 포함돼요. roomId와 serverUrl이 반응형 값이므로, 의존성에서 제거할 수 없어요. 생략하려고 하고 린터가 React용으로 올바르게 설정되어 있다면, 린터가 이것을 수정해야 할 실수로 플래그를 지정할 거예요:
function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://localhost:1234');
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => connection.disconnect();
}, []); // 🔴 React Hook useEffect has missing dependencies: 'roomId' and 'serverUrl'
// ...
}
의존성을 제거하려면, 린터에게 의존성이 될 필요가 없다는 것을 "증명"해야 해요. 예를 들어, serverUrl을 컴포넌트 밖으로 옮겨서 반응형이 아니고 리렌더링 시에 변경되지 않는다는 것을 증명할 수 있어요:
const serverUrl = 'https://localhost:1234'; // 더 이상 반응형 값이 아니에요
function ChatRoom({ roomId }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => connection.disconnect();
}, [roomId]); // ✅ 모든 의존성이 선언됨
// ...
}
이제 serverUrl이 반응형 값이 아니고(리렌더링 시에 변경될 수 없고) 의존성이 될 필요가 없어요. Effect의 코드가 반응형 값을 사용하지 않는다면, 의존성 목록은 비어 있어야 해요 ([]):
const serverUrl = 'https://localhost:1234'; // 더 이상 반응형 값이 아니에요
const roomId = 'music'; // 더 이상 반응형 값이 아니에요
function ChatRoom() {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => connection.disconnect();
}, []); // ✅ 모든 의존성이 선언됨
// ...
}
빈 의존성이 있는 Effect는 컴포넌트의 props나 state가 변경될 때 다시 실행되지 않아요.
주의
기존 코드베이스가 있다면, 린터를 이렇게 억제하는 Effect가 있을 수 있어요:
useEffect(() => { // ... // 🔴 이렇게 린터를 억제하지 마세요: // eslint-ignore-next-line react-hooks/exhaustive-deps }, []);의존성이 코드와 일치하지 않으면, 버그가 발생할 위험이 매우 높아요. 린터를 억제함으로써, Effect가 의존하는 값에 대해 React에게 "거짓말"하는 거예요. 대신, 불필요하다는 것을 증명하세요.
의존성을 지정하면, Effect는 초기 커밋 후 그리고 의존성이 변경된 커밋 후에 실행돼요.
useEffect(() => {
// ...
}, [a, b]); // a나 b가 다르면 다시 실행돼요
아래 예제에서, serverUrl과 roomId는 반응형 값이므로, 둘 다 의존성으로 지정되어야 해요. 결과적으로, 드롭다운에서 다른 방을 선택하거나 서버 URL 입력을 수정하면 채팅이 다시 연결돼요. 하지만 message는 Effect에서 사용되지 않으므로(의존성이 아니에요), 메시지를 수정해도 채팅에 다시 연결되지 않아요.
import { useState, useEffect } from 'react';
import { createConnection } from './chat.js';
function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://localhost:1234');
const [message, setMessage] = useState('');
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
connection.disconnect();
};
}, [serverUrl, roomId]);
return (
<>
<label>
Server URL:{' '}
<input
value={serverUrl}
onChange={e => setServerUrl(e.target.value)}
/>
</label>
<h1>Welcome to the {roomId} room!</h1>
<label>
Your message:{' '}
<input value={message} onChange={e => setMessage(e.target.value)} />
</label>
</>
);
}
export default function App() {
const [show, setShow] = useState(false);
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>
<button onClick={() => setShow(!show)}>
{show ? 'Close chat' : 'Open chat'}
</button>
</label>
{show && <hr />}
{show && <ChatRoom roomId={roomId}/>}
</>
);
}
// src/chat.js
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);
}
};
}
input { margin-bottom: 10px; }
button { margin-left: 5px; }
Effect가 정말로 반응형 값을 사용하지 않는다면, 초기 커밋 후에만 실행될 거예요.
useEffect(() => {
// ...
}, []); // (개발 환경에서 한 번 제외하고) 다시 실행되지 않아요
빈 의존성이 있더라도, setup과 cleanup은 개발 환경에서 한 번 더 실행돼서 버그를 찾는 데 도움을 줘요.
이 예제에서, serverUrl과 roomId 둘 다 하드코딩되어 있어요. 컴포넌트 밖에 선언되어 있기 때문에, 반응형 값이 아니고 의존성이 아니에요. 의존성 목록이 비어 있어서, Effect는 리렌더링 시에 다시 실행되지 않아요.
import { useState, useEffect } from 'react';
import { createConnection } from './chat.js';
const serverUrl = 'https://localhost:1234';
const roomId = 'music';
function ChatRoom() {
const [message, setMessage] = useState('');
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => connection.disconnect();
}, []);
return (
<>
<h1>Welcome to the {roomId} room!</h1>
<label>
Your message:{' '}
<input value={message} onChange={e => setMessage(e.target.value)} />
</label>
</>
);
}
export default function App() {
const [show, setShow] = useState(false);
return (
<>
<button onClick={() => setShow(!show)}>
{show ? 'Close chat' : 'Open chat'}
</button>
{show && <hr />}
{show && <ChatRoom />}
</>
);
}
// src/chat.js
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는 컴포넌트의 모든 단일 커밋 후에 실행돼요.
useEffect(() => {
// ...
}); // 항상 다시 실행돼요
이 예제에서, Effect는 serverUrl과 roomId를 변경할 때 다시 실행되는데, 이게 합리적이에요. 하지만 message를 변경할 때도 다시 실행되는데, 이것은 아마 바람직하지 않을 거예요. 그래서 보통 의존성 배열을 지정해요.
import { useState, useEffect } from 'react';
import { createConnection } from './chat.js';
function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://localhost:1234');
const [message, setMessage] = useState('');
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
connection.disconnect();
};
}); // 의존성 배열이 전혀 없음
return (
<>
<label>
Server URL:{' '}
<input
value={serverUrl}
onChange={e => setServerUrl(e.target.value)}
/>
</label>
<h1>Welcome to the {roomId} room!</h1>
<label>
Your message:{' '}
<input value={message} onChange={e => setMessage(e.target.value)} />
</label>
</>
);
}
export default function App() {
const [show, setShow] = useState(false);
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>
<button onClick={() => setShow(!show)}>
{show ? 'Close chat' : 'Open chat'}
</button>
</label>
{show && <hr />}
{show && <ChatRoom roomId={roomId}/>}
</>
);
}
// src/chat.js
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);
}
};
}
input { margin-bottom: 10px; }
button { margin-left: 5px; }
Effect에서 이전 state를 기반으로 state를 업데이트하고 싶을 때, 문제가 발생할 수 있어요:
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const intervalId = setInterval(() => {
setCount(count + 1); // 매초 카운터를 증가시키고 싶어요...
}, 1000)
return () => clearInterval(intervalId);
}, [count]); // 🚩 ... 하지만 `count`를 의존성으로 지정하면 항상 인터벌을 재설정해요.
// ...
}
count는 반응형 값이므로, 의존성 목록에 지정되어야 해요. 하지만 그렇게 하면 count가 변경될 때마다 Effect가 cleanup하고 다시 setup돼요. 이것은 이상적이지 않아요.
이를 해결하려면, c => c + 1 state 업데이터를 setCount에 전달하세요:
import { useState, useEffect } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const intervalId = setInterval(() => {
setCount(c => c + 1); // ✅ state 업데이터를 전달하세요
}, 1000);
return () => clearInterval(intervalId);
}, []); // ✅ 이제 count가 의존성이 아니에요
return <h1>{count}</h1>;
}
label {
display: block;
margin-top: 20px;
margin-bottom: 20px;
}
body {
min-height: 150px;
}
이제 count + 1 대신 c => c + 1을 전달하기 때문에, Effect가 더 이상 count에 의존할 필요가 없어요. 이 수정의 결과로, count가 변경될 때마다 인터벌을 cleanup하고 다시 setup할 필요가 없어요.
Effect가 렌더링 중에 생성된 객체나 함수에 의존한다면, 너무 자주 실행될 수 있어요. 예를 들어, 이 Effect는 options 객체가 매 렌더링마다 다르기 때문에 매 커밋 후에 다시 연결돼요:
const serverUrl = 'https://localhost:1234';
function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');
const options = { // 🚩 이 객체는 매 리렌더링마다 처음부터 생성돼요
serverUrl: serverUrl,
roomId: roomId
};
useEffect(() => {
const connection = createConnection(options); // Effect 내부에서 사용돼요
connection.connect();
return () => connection.disconnect();
}, [options]); // 🚩 결과적으로, 이 의존성들은 커밋마다 항상 달라요
// ...
렌더링 중에 생성된 객체를 의존성으로 사용하지 마세요. 대신, Effect 내부에서 객체를 생성하세요:
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} />
</>
);
}
// src/chat.js
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);
}
};
}
input { display: block; margin-bottom: 20px; }
button { margin-left: 10px; }
이제 Effect 내부에서 options 객체를 생성하므로, Effect 자체는 roomId 문자열에만 의존해요.
이 수정으로, 입력에 타이핑해도 채팅이 다시 연결되지 않아요. 다시 생성되는 객체와 달리, roomId 같은 문자열은 다른 값으로 설정하지 않는 한 변경되지 않아요. 의존성 제거에 대해 더 읽어보세요.
Effect가 렌더링 중에 생성된 객체나 함수에 의존한다면, 너무 자주 실행될 수 있어요. 예를 들어, 이 Effect는 createOptions 함수가 매 렌더링마다 다르기 때문에 매 커밋 후에 다시 연결돼요:
function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');
function createOptions() { // 🚩 이 함수는 매 리렌더링마다 처음부터 생성돼요
return {
serverUrl: serverUrl,
roomId: roomId
};
}
useEffect(() => {
const options = createOptions(); // Effect 내부에서 사용돼요
const connection = createConnection();
connection.connect();
return () => connection.disconnect();
}, [createOptions]); // 🚩 결과적으로, 이 의존성들은 커밋마다 항상 달라요
// ...
그 자체로, 매 리렌더링마다 처음부터 함수를 생성하는 것은 문제가 아니에요. 그걸 최적화할 필요는 없어요. 하지만 Effect의 의존성으로 사용하면, 매 커밋 후에 Effect가 다시 실행되게 될 거예요.
렌더링 중에 생성된 함수를 의존성으로 사용하지 마세요. 대신, Effect 내부에서 선언하세요:
import { useState, useEffect } from 'react';
import { createConnection } from './chat.js';
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]);
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} />
</>
);
}
// src/chat.js
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);
}
};
}
input { display: block; margin-bottom: 20px; }
button { margin-left: 10px; }
이제 Effect 내부에서 createOptions 함수를 정의하므로, Effect 자체는 roomId 문자열에만 의존해요. 이 수정으로, 입력에 타이핑해도 채팅이 다시 연결되지 않아요. 다시 생성되는 함수와 달리, roomId 같은 문자열은 다른 값으로 설정하지 않는 한 변경되지 않아요. 의존성 제거에 대해 더 읽어보세요.
기본적으로, Effect에서 반응형 값을 읽을 때, 의존성으로 추가해야 해요. 이것은 Effect가 그 값의 모든 변경에 "반응"하도록 보장해요. 대부분의 의존성에 대해, 그게 원하는 동작이에요.
하지만, 때때로 그것들에 "반응"하지 않고 Effect에서 최신 props와 state를 읽고 싶을 수 있어요. 예를 들어, 매 페이지 방문마다 쇼핑 카트의 항목 수를 로그하고 싶다고 상상해보세요:
function Page({ url, shoppingCart }) {
useEffect(() => {
logVisit(url, shoppingCart.length);
}, [url, shoppingCart]); // ✅ 모든 의존성이 선언됨
// ...
}
url이 변경될 때마다 새 페이지 방문을 로그하고 싶지만, shoppingCart만 변경될 때는 아니라면 어떻게 할까요? 반응성 규칙을 깨지 않고 의존성에서 shoppingCart를 제외할 수 없어요. 하지만 Effect 내부에서 호출되더라도 코드가 변경에 "반응"하기를 원하지 않는다고 표현할 수 있어요. useEffectEvent Hook으로 Effect Event를 선언하고, shoppingCart를 읽는 코드를 그 안으로 옮기세요:
function Page({ url, shoppingCart }) {
const onVisit = useEffectEvent(visitedUrl => {
logVisit(visitedUrl, shoppingCart.length)
});
useEffect(() => {
onVisit(url);
}, [url]); // ✅ 모든 의존성이 선언됨
// ...
}
Effect Event는 반응형이 아니고 항상 Effect의 의존성에서 생략되어야 해요. 이것이 비반응형 코드(일부 props와 state의 최신 값을 읽을 수 있는)를 그 안에 넣을 수 있게 해줘요. onVisit 내부에서 shoppingCart를 읽음으로써, shoppingCart가 Effect를 다시 실행하지 않도록 보장해요.
Effect Event가 어떻게 반응형 코드와 비반응형 코드를 분리할 수 있게 하는지 더 읽어보세요.
앱이 서버 렌더링을 사용한다면 (직접 또는 프레임워크를 통해), 컴포넌트는 두 가지 다른 환경에서 렌더링될 거예요. 서버에서는 초기 HTML을 생성하기 위해 렌더링할 거예요. 클라이언트에서는, React가 렌더링 코드를 다시 실행해서 이벤트 핸들러를 HTML에 첨부할 수 있도록 할 거예요. 그래서 하이드레이션이 작동하려면, 초기 렌더링 출력이 클라이언트와 서버에서 동일해야 해요.
드물게, 클라이언트에서 다른 콘텐츠를 표시해야 할 수도 있어요. 예를 들어, 앱이 localStorage에서 일부 데이터를 읽는다면, 서버에서는 그걸 할 수 없어요. 이렇게 구현할 수 있어요:
function MyComponent() {
const [didMount, setDidMount] = useState(false);
useEffect(() => {
setDidMount(true);
}, []);
if (didMount) {
// ... 클라이언트 전용 JSX를 반환...
} else {
// ... 초기 JSX를 반환...
}
}
앱이 로딩되는 동안, 사용자는 초기 렌더링 출력을 볼 거예요. 그 다음, 로드되고 하이드레이션되면, Effect가 실행되고 didMount를 true로 설정해서 리렌더링을 트리거할 거예요. 이것은 클라이언트 전용 렌더링 출력으로 전환될 거예요. Effect는 서버에서 실행되지 않기 때문에, 초기 서버 렌더링 중에 didMount가 false였던 거예요.
이 패턴은 드물게 사용하세요. 느린 연결을 가진 사용자는 상당히 오랜 시간--잠재적으로 몇 초 동안--초기 콘텐츠를 볼 거라는 점을 기억하세요. 그래서 컴포넌트의 외관에 불협화음을 일으키는 변경을 하고 싶지 않을 거예요. 많은 경우, CSS로 조건부로 다른 것을 보여줌으로써 이 필요성을 피할 수 있어요.
Strict Mode가 켜져 있으면, 개발 환경에서 React는 실제 setup 전에 setup과 cleanup을 한 번 더 실행해요.
이것은 Effect의 로직이 올바르게 구현되었는지 검증하는 스트레스 테스트예요. 이것이 눈에 보이는 문제를 일으킨다면, cleanup 함수에 일부 로직이 누락된 거예요. cleanup 함수는 setup 함수가 하고 있던 것을 중지하거나 되돌려야 해요. 기본 원칙은 사용자가 setup이 한 번 호출되는 것(프로덕션처럼)과 setup → cleanup → setup 순서(개발처럼)를 구별할 수 없어야 한다는 거예요.
이것이 어떻게 버그를 찾는 데 도움이 되는지와 로직을 수정하는 방법에 대해 더 읽어보세요.
먼저, 의존성 배열을 지정하는 것을 잊지 않았는지 확인하세요:
useEffect(() => {
// ...
}); // 🚩 의존성 배열 없음: 매 커밋 후에 다시 실행돼요!
의존성 배열을 지정했는데도 Effect가 여전히 루프로 다시 실행된다면, 의존성 중 하나가 매 리렌더링마다 다르기 때문이에요.
콘솔에 의존성을 수동으로 로그해서 이 문제를 디버그할 수 있어요:
useEffect(() => {
// ..
}, [serverUrl, roomId]);
console.log([serverUrl, roomId]);
그러면 콘솔에서 다른 리렌더링의 배열을 마우스 오른쪽 버튼으로 클릭하고 둘 다 "Store as a global variable"을 선택할 수 있어요. 첫 번째가 temp1로 저장되고 두 번째가 temp2로 저장되었다고 가정하면, 브라우저 콘솔을 사용해서 두 배열의 각 의존성이 동일한지 확인할 수 있어요:
Object.is(temp1[0], temp2[0]); // 배열 간에 첫 번째 의존성이 동일한가요?
Object.is(temp1[1], temp2[1]); // 배열 간에 두 번째 의존성이 동일한가요?
Object.is(temp1[2], temp2[2]); // ... 모든 의존성에 대해...
매 리렌더링마다 다른 의존성을 찾으면, 보통 다음 방법 중 하나로 수정할 수 있어요:
최후의 수단으로(이 방법들이 도움이 되지 않았다면), 생성을 useMemo나 useCallback(함수의 경우)으로 감싸세요.
Effect가 무한 사이클로 실행된다면, 다음 두 가지가 사실이어야 해요:
문제 해결을 시작하기 전에, Effect가 일부 외부 시스템(DOM, 네트워크, 서드파티 위젯 등)에 연결하고 있는지 자문해보세요. Effect가 state를 설정해야 하는 이유는 무엇인가요? 그 외부 시스템과 동기화되나요? 아니면 그것으로 애플리케이션의 데이터 흐름을 관리하려고 하나요?
외부 시스템이 없다면, Effect를 완전히 제거하는 것이 로직을 단순화할지 고려해보세요.
정말로 일부 외부 시스템과 동기화하고 있다면, Effect가 왜, 어떤 조건에서 state를 업데이트해야 하는지 생각해보세요. 컴포넌트의 시각적 출력에 영향을 미치는 것이 변경되었나요? 렌더링에서 사용되지 않는 일부 데이터를 추적해야 한다면, ref(리렌더링을 트리거하지 않는)가 더 적절할 수 있어요. Effect가 필요 이상으로 state를 업데이트(그리고 리렌더링을 트리거)하지 않는지 확인하세요.
마지막으로, Effect가 올바른 시점에 state를 업데이트하지만 여전히 루프가 있다면, 그 state 업데이트가 Effect의 의존성 중 하나를 변경하게 하기 때문이에요. 의존성 변경을 디버그하는 방법을 읽어보세요.
cleanup 함수는 언마운트 시뿐만 아니라, 변경된 의존성이 있는 매 리렌더링 전에도 실행돼요. 추가로, 개발 환경에서는 React가 컴포넌트 마운트 직후에 setup+cleanup을 한 번 더 실행해요.
대응하는 setup 코드 없이 cleanup 코드가 있다면, 보통 코드 냄새예요:
useEffect(() => {
// 🔴 피하세요: 대응하는 setup 로직 없는 cleanup 로직
return () => {
doSomething();
};
}, []);
cleanup 로직은 setup 로직에 "대칭"이어야 하고, setup이 한 것을 중지하거나 되돌려야 해요:
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
connection.disconnect();
};
}, [serverUrl, roomId]);
Effect 생명주기가 컴포넌트의 생명주기와 어떻게 다른지 알아보세요.
Effect가 브라우저가 화면을 그리는 것을 차단해야 한다면, useEffect를 useLayoutEffect로 교체하세요. 대다수의 Effect에는 이게 필요하지 않아요. Effect를 브라우저 페인트 전에 실행하는 것이 중요한 경우에만 필요해요: 예를 들어, 사용자가 보기 전에 툴팁을 측정하고 위치시키는 경우요.