본 게시글은 useEffect 완벽 가이드를 정리 & 요약하는 글입니다.
💡 🤔 질문:
useEffect
로componentDidMount
동작을 흉내내려면 어떻게 하지?
useEffect(fn, [])
를 사용해서 비슷하게 흉내낼 수 있음.componentDidMount
와는 달리 prop, state를 잡아두기 때문에 초기 prop, state를 확인할 수 있음componentDidMount
및 다른 라이프사이클 모델과 다르다.💡 🤔 질문:
useEffect
안에서 데이터 페칭은 어떻게 할까? 두번째 인자로 오는 배열([]
) 은 뭘까?
useReducer
, useCallback
) 사용하는게 권장됨💡 🤔 질문: 의존성 배열에 함수를 명시해도 될까?
💡 🤔 질문: 왜 가끔씩 데이터 페칭이 무한루프에 빠지는걸까?
💡 🤔 질문: 왜 가끔씩 이펙트 안에서 이전 state나 prop 값을 참조할까?
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}
count
값을 “보는” 것임function Counter() {
const [count, setCount] = useState(0);
function handleAlertClick() {
setTimeout(() => {
alert('You clicked on: ' + count);
}, 3000);
}
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
<button onClick={handleAlertClick}>
Show alert
</button>
</div>
);
}
위 코드는 3초뒤에 count 값과 alert를 띄워주는 코드이다.
그렇다면 왜 이런 결과가 나왔을까?
count
값은 매번 별개의 함수 호출마다 존재하는 상수값이다.count
값은 상수이자 독립적인 값(특정 랜더링 시의 상태)으로 존재한다.handleAlertClick
을 리턴한다. 그리고 각각의 버전은 고유의 count
를 “기억” 한다// 3이 나오는 이유
// 처음 랜더링 시
function Counter() {
// ...
function handleAlertClick() {
setTimeout(() => {
alert('You clicked on: ' + 0);
}, 3000);
}
// ...
<button onClick={handleAlertClick} /> // 0이 안에 들어있음
// ...
}
// 클릭하면 함수가 다시 호출된다
function Counter() {
// ...
function handleAlertClick() {
setTimeout(() => {
alert('You clicked on: ' + 1);
}, 3000);
}
// ...
<button onClick={handleAlertClick} /> // 1이 안에 들어있음
// ...
}
// 또 한번 클릭하면, 다시 함수가 호출된다
function Counter() {
// ...
function handleAlertClick() {
setTimeout(() => {
alert('You clicked on: ' + 2);
}, 3000);
}
// ...
<button onClick={handleAlertClick} /> // 2가 안에 들어있음
// ...
}
즉, 이벤트 핸들러가 특정 랜더링에 “속해 있으며”, 얼럿 표시 버튼을 클릭할 때 그 랜더링 시점의
counter
state를 유지한 채로 사용하는 것
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
document.title = `You clicked ${count} times`;
});
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}
그렇다면 위 예제에서 effect가 어떻게 최신의 count
상태를 읽을 수 있을까?
count
는 특정 컴포넌트 랜더링에 포함되는 상수임. 이펙트에도 똑같은 개념 적용아래와 같은 단계로 count 값이 변경되어 화면에 보여진다
// 최초 랜더링 시
function Counter() {
// ...
useEffect(
// 첫 번째 랜더링의 이펙트 함수
() => {
document.title = `You clicked ${0} times`;
}
);
// ...
}
// 클릭하면 함수가 다시 호출된다
function Counter() {
// ...
useEffect(
// 두 번째 랜더링의 이펙트 함수
() => {
document.title = `You clicked ${1} times`;
}
);
// ...
}
// 또 한번 클릭하면, 다시 함수가 호출된다
function Counter() {
// ...
useEffect(
// 세 번째 랜더링의 이펙트 함수
() => {
document.title = `You clicked ${2} times`;
}
);
// ..
}
- 리액트: state가
0
일 때의 UI를 보여줘.- 컴포넌트
- 여기 랜더링 결과물로
<p>You clicked 0 times</p>
가 있어.- 그리고 모든 처리가 끝나고 이 이펙트를 실행하는 것을 잊지 마:
() => { document.title = 'You clicked 0 times' }
.- 리액트: 좋아. UI를 업데이트 하겠어. 이봐 브라우저, 나 DOM에 뭘 좀 추가하려고 해.
- 브라우저: 좋아, 화면에 그려줄게.
- 리액트: 좋아 이제 컴포넌트 네가 준 이펙트를 실행할거야.
() => { document.title = 'You clicked 0 times' }
를 실행하는 중.
- 컴포넌트: 이봐 리액트, 내 상태를
1
로 변경해줘.- 리액트: 상태가
1
일때의 UI를 줘.- 컴포넌트
- 여기 랜더링 결과물로
<p>You clicked 1 times</p>
가 있어.- 그리고 모든 처리가 끝나고 이 이펙트를 실행하는 것을 잊지 마:
() => { document.title = 'You clicked 1 times' }
.- 리액트: 좋아. UI를 업데이트 하겠어. 이봐 브라우저, 나 DOM에 뭘 좀 추가하려고 해.
- 브라우저: 좋아, 화면에 그려줄게.
- 리액트: 좋아 이제 컴포넌트 네가 준 이펙트를 실행할거야.
() => { document.title = 'You clicked 1 times' }
를 실행하는 중.
그렇다면 과연 아래 코드를 여러번 실행하면 어떤 결과가 나올까?
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
setTimeout(() => {
console.log(`You clicked ${count} times`);
}, 3000);
});
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}
그렇다면 클래스 컴포넌트로 만들면 어떻게 동작할까?
componentDidUpdate() {
setTimeout(() => {
console.log(`You clicked ${this.state.count} times`);
}, 3000);
}
this.state.count
값은 특정 랜더링 시점의 값이 아니라 언제나 최신의 값을 가리키기 때문. 그래서 매번 5가 찍혀있는 로그를 보게 된다.useEffect(() => {
ChatAPI.subscribeToFriendStatus(props.id, handleStatusChange);
return () => {
ChatAPI.unsubscribeFromFriendStatus(props.id, handleStatusChange);
};
});
과연 클린업 단계는 아래와 같이 일어날까?
{id: 10}
을 다루는 이펙트를 클린업한다.{id: 20}
을 가지고 UI를 랜더링한다.{id: 20}
으로 이펙트를 실행한다.{id: 20}
을 가지고 UI를 랜더링한다.{id: 20}
이 반영된 UI를 볼 수 있다.{id: 10}
에 대한 이펙트를 클린업한다.{id: 20}
에 대한 이펙트를 실행한다.어떻게 위와 같은 단계가 일어날까?
💡 컴포넌트가 랜더링 안에 있는 모든 함수는 (이벤트 핸들러, 이펙트, 타임아웃이나 그 안에서 호출되는 API 등) 랜더가 호출될 때 정의된 props와 state 값을 잡아두기 때문에!
즉, 이펙트의 클린업은 “최신” prop을 읽지 않는다. 클린업이 정의된 시점의 랜더링에 있던 값을 읽는 것
// 첫 번째 랜더링, props는 {id: 10}
function Example() {
// ...
useEffect(
// 첫 번째 랜더링의 이펙트
() => {
ChatAPI.subscribeToFriendStatus(10, handleStatusChange);
// 첫 번째 랜더링의 클린업
return () => {
ChatAPI.unsubscribeFromFriendStatus(10, handleStatusChange);
};
}
);
// ...
}
// 다음 랜더링, props는 {id: 20}
function Example() {
// ...
useEffect(
// 두 번째 랜더링의 이펙트
() => {
ChatAPI.subscribeToFriendStatus(20, handleStatusChange);
// 두 번째 랜더링의 클린업
return () => {
ChatAPI.unsubscribeFromFriendStatus(20, handleStatusChange);
};
}
);
// ...
}
useEffect
는 리액트 트리 바깥에 있는 것들을 props와 state에 따라 동기화 할 수 있게한다.
function Greeting({ name }) {
useEffect(() => {
document.title = 'Hello, ' + name;
});
return (
<h1 className="Greeting">
Hello, {name}
</h1>
);
}
단, 모든 이펙트를 매번 랜더링마다 실행하는 것은 효율이 떨어질 수 있음. 이는 아래와 같이 해결할 수 있음
리액트의 리렌더링 예시)
아래의 컴포넌트를
<h1 className="Greeting">
Hello, Dan
</h1>
이렇게 바꾼다면
<h1 className="Greeting">
Hello, Yuzhi
</h1>
리액트는 두 객체를 비교함
const oldProps = {className: 'Greeting', children: 'Hello, Dan'};
const newProps = {className: 'Greeting', children: 'Hello, Yuzhi'};
각각의 prop을 짚어보고 children
이 바뀌어서 DOM 업데이트가 필요하다고 파악했지만 className
은 그렇지 않음. 그래서 그저 아래의 코드만 호출됨
domNode.innerText = 'Hello, Yuzhi';
// domNode.className 은 건드릴 필요가 없다
그렇다면 이펙트에도 위와 같은 방법을 적용할 수 있을까?
위에서 말한 객체 비교 방식과는 다르게, 이펙트끼리 비교는 불가능함
let oldEffect = () => { document.title = 'Hello, Dan'; };
let newEffect = () => { document.title = 'Hello, Dan'; };
// 리액트가 이 배열을 같은 배열이라고 인식할 수 있을까?
그래서 특정한 이펙트가 불필요하게 다시 실행되는 것을 방지하고 싶다면 의존성 배열을(“deps” 라고 알려짐) useEffect
의 인자로 전달할 수 있음!
useEffect(() => {
document.title = 'Hello, ' + name;
}, [name]); // 우리의 의존성
name
외의 값은 쓰지 않는다고 약속할게.” 라고 말하는 것과 같음따라서, 현재와 이전 이펙트 발동 시 이 값들이 같다면 동기화할 것은 없으니 리액트는 이펙트를 스킵할 수 있음
const oldEffect = () => { document.title = 'Hello, Dan'; };
const oldDeps = ['Dan'];
const newEffect = () => { document.title = 'Hello, Dan'; };
const newDeps = ['Dan'];
// 리액트는 함수 안을 살펴볼 수 없지만, deps를 비교할 수 있다.
// 모든 deps가 같으므로, 새 이펙트를 실행할 필요가 없다.
deps를 지정한다면, 컴포넌트에 있는 모든 값 중 그 이펙트에 사용될 값은 반드시 거기 있어야 함!
function SearchResults() {
async function fetchData() {
// ...
}
useEffect(() => {
fetchData();
}, []); // 이게 맞을까요? 항상 그렇진 않지요. 그리고 더 나은 방식으로 코드를 작성하는 방법이 있습니다.
// ...
}
또 다른 문제점 예시:
count를 1초마다 1씩 올리는 에펙트 코드를 짜고 싶다고 하자.
const count = // ...
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
}, []);
1) 첫 번째 방법은 컴포넌트 안에 있으면서 이펙트에서 사용되는 모든 값이 의존성 배열 안에 포함되도록 고치는 것
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
}, [count]);
count
를 deps에 추가함으로서 의존성 배열을 올바르게 만듦.count
값은 이펙트를 다시 실행하고 매번 다음 인터벌에서 setCount(count + 1)
count
값을 사용 원하는대로 동작하는 것 처럼 보이겠지만, count
값이 바뀔 때마다 인터벌은 해제되고 다시 설정됨.
2) 두 번째 전략은 이펙트의 코드를 바꿔서 우리가 원하던 것 보다 자주 바뀌는 값을 요구하지 않도록 만드는 것
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + 1);
}, 1000);
return () => clearInterval(id);
}, []);
count
를 제거하도록 만들었음count
값을 읽어 들이지 않음위 두 방법을 통해 *의존성을 제거하지 않고도 실제로 문제를 해결했음!*
step
입력값에 따라 count
값을 더함)function Counter() {
const [count, setCount] = useState(0);
const [step, setStep] = useState(1);
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + step);
}, 1000);
return () => clearInterval(id);
}, [step]);
return (
<>
<h1>{count}</h1>
<input value={step} onChange={e => setStep(Number(e.target.value))} />
</>
);
}
코드 설명:
하지만,
step
이 바뀐다고 인터벌 시계가 초기화되지 않는 것을 원한다면?
이펙트의 의존성 배열에서
step
을 제거하려면?
정답: 어떤 상태 변수가 다른 상태 변수의 현재 값에 연관되도록 설정하려고 한다면, 두 상태 변수 모두 useReducer
로 교체할 수 있음
const [state, dispatch] = useReducer(reducer, initialState);
const { count, step } = state;
useEffect(() => {
const id = setInterval(() => {
dispatch({ type: 'tick' }); // setCount(c => c + step) 대신에
}, 1000);
return () => clearInterval(id);
}, [dispatch]);
dispatch
함수가 항상 같다는 것을 보장함step
상태로부터 분리되어 있게됨const initialState = {
count: 0,
step: 1,
};
function reducer(state, action) {
const { count, step } = state;
if (action.type === 'tick') {
return { count: count + step, step };
} else if (action.type === 'step') {
return { count, step: action.step };
} else {
throw new Error();
function SearchResults() {
const [data, setData] = useState({ hits: [] });
async function fetchData() {
const result = await axios(
'https://hn.algolia.com/api/v1/search?query=react',
);
setData(result.data);
}
useEffect(() => {
fetchData();
}, []); // 이게 괜찮을까?
// 문제점 예시
function SearchResults() {
const [query, setQuery] = useState('react');
// 이 함수가 길다고 상상해 봅시다
function getFetchUrl() {
return 'https://hn.algolia.com/api/v1/search?query=' + query;
}
// 이 함수가 길다고 상상해 봅시다
async function fetchData() {
const result = await axios(getFetchUrl());
setData(result.data);
}
useEffect(() => {
fetchData();
}, []);
// ...
}
해결 방안: useEffect 안에서만 사용하는 함수는 이펙트 안에 넣자
function SearchResults() {
// ...
useEffect(() => {
// 아까의 함수들을 안으로 옮겼어요!
function getFetchUrl() {
return 'https://hn.algolia.com/api/v1/search?query=react';
}
async function fetchData() {
const result = await axios(getFetchUrl());
setData(result.data);
}
fetchData();
}, []); // ✅ Deps는 OK
// ...
}
만약 query 라는 새로운 state를 사용한다고 하면, 해당 상태만 의존성 배열에 추가해주면 됨
function SearchResults() {
const [query, setQuery] = useState('react');
useEffect(() => {
function getFetchUrl() {
return 'https://hn.algolia.com/api/v1/search?query=' + query;
}
async function fetchData() {
const result = await axios(getFetchUrl());
setData(result.data);
}
fetchData();
}, [query]); // ✅ Deps는 OK
// ...
}
eslint-plugin-react-hooks
플러그인의 exhaustive-deps
린트 룰 덕분에 에디터에서 빠져있는 의존성 분석이 가능함예시: 두 이펙트가 getFetchUrl 함수를 호출
function SearchResults() {
function getFetchUrl(query) {
return 'https://hn.algolia.com/api/v1/search?query=' + query;
}
useEffect(() => {
const url = getFetchUrl('react');
// ... 데이터를 불러와서 무언가를 한다 ...
}, []); // 🔴 빠진 dep: getFetchUrl
useEffect(() => {
const url = getFetchUrl('redux');
// ... 데이터를 불러와서 무언가를 한다 ...
}, []); // 🔴 빠진 dep: getFetchUrl
// ...
}
그렇다면 아래처럼 의존성 배열에 함수를 추가한다면?
function SearchResults() {
// 🔴 매번 랜더링마다 모든 이펙트를 다시 실행한다
function getFetchUrl(query) {
return 'https://hn.algolia.com/api/v1/search?query=' + query;
}
useEffect(() => {
const url = getFetchUrl('react');
// ... 데이터를 불러와서 무언가를 한다 ...
}, [getFetchUrl]); // 🚧 Deps는 맞지만 너무 자주 바뀐다
useEffect(() => {
const url = getFetchUrl('redux');
// ... 데이터를 불러와서 무언가를 한다 ...
}, [getFetchUrl]); // 🚧 Deps는 맞지만 너무 자주 바뀐다
// ...
}
1) 함수를 컴포넌트 스코프 외부에 작성한다
2) useCallback 훅을 사용한다.
먼저, 함수를 컴포넌트 스코프 외부로 끌어올리면 이펙트 안에서 자유롭게 사용할 수 있음
// ✅ 데이터 흐름에 영향을 받지 않는다
function getFetchUrl(query) {
return 'https://hn.algolia.com/api/v1/search?query=' + query;
}
function SearchResults() {
useEffect(() => {
const url = getFetchUrl('react');
// ... 데이터를 불러와서 무언가를 한다 ...
}, []); // ✅ Deps는 OK
useEffect(() => {
const url = getFetchUrl('redux');
// ... 데이터를 불러와서 무언가를 한다 ...
}, []); // ✅ Deps는 OK
// ...
}
(중요) 두번째로는 useCallback 훅으로 함수를 감쌀 수 있음
function SearchResults() {
const [query, setQuery] = useState('react');
// ✅ query가 바뀔 때까지 항등성을 유지한다
const getFetchUrl = useCallback(() => {
return 'https://hn.algolia.com/api/v1/search?query=' + query;
}, [query]); // ✅ 콜백 deps는 OK
useEffect(() => {
const url = getFetchUrl('react');
// ... 데이터를 불러와서 무언가를 한다 ...
}, [getFetchUrl]); // ✅ 이펙트의 deps는 OK
useEffect(() => {
const url = getFetchUrl('redux');
// ... 데이터를 불러와서 무언가를 한다 ...
}, [getFetchUrl]); // ✅ 이펙트의 deps는 OK
// ...
}
class Parent extends Component {
state = {
query: 'react'
};
fetchData = () => {
const url = 'https://hn.algolia.com/api/v1/search?query=' + this.state.query;
// ... 데이터를 불러와서 무언가를 한다 ...
};
render() {
return <Child fetchData={this.fetchData} />;
}
}
class Child extends Component {
state = {
data: null
};
componentDidMount() {
this.props.fetchData();
}
render() {
// ...
}
}
useEffect는 componentDidMount와 componentDidUpdate가 섞여 있다고 알려져 있다. 하지만 신기하게도 위 로직은 componentDidUpdate에서는 동작하지 않는다
class Child extends Component {
state = {
data: null
};
componentDidMount() {
this.props.fetchData();
}
componentDidUpdate(prevProps) {
// 🔴 이 조건문은 절대 참이 될 수 없다
if (this.props.fetchData !== prevProps.fetchData) {
this.props.fetchData();
}
}
render() {
// ...
}
}
this.props.fetchData
는 prevProps.fetchData
와 같기 때문에 절대 다시 데이터를 페칭하지 않는다Child
컴포넌트가 query
를 직접 사용하지 않음에도 불구하고 query
가 바뀔 때 다시 데이터를 불러오는 로직은 해결할 수 있음
class Parent extends Component {
state = {
query: 'react'
};
fetchData = () => {
const url = 'https://hn.algolia.com/api/v1/search?query=' + this.state.query;
// ... 데이터를 불러와서 무언가를 한다 ...
};
render() {
return <Child fetchData={this.fetchData} query={this.state.query} />;
}
}
class Child extends Component {
state = {
data: null
};
componentDidMount() {
this.props.fetchData();
}
componentDidUpdate(prevProps) {
if (this.props.query !== prevProps.query) {
this.props.fetchData();
}
}
render() {
// ...
}
}
this
변수에 묶여 있기 때문에 함수의 일관성을 담보할 수 없게 됨. 그러므로 우리가 함수만 필요할 때도 “차이” 를 비교하기 위해 온갖 다른 데이터를 전달해야 함useCallback
을 사용하면, 함수는 명백하게 데이터 흐름에 포함됨비슷하게, useMemo
또한 복잡한 객체에 대해 같은 방식의 해결책을 제공함
function ColorPicker() {
// color가 진짜로 바뀌지 않는 한
// Child의 얕은 props 비교를 깨트리지 않는다
const [color, setColor] = useState('pink');
const style = useMemo(() => ({ color }), [color]);
return <Child style={style} />;
}
아래는 클래스로 데이터를 불러오는 전통적인 예제임
class Article extends Component {
state = {
article: null
};
componentDidMount() {
this.fetchData(this.props.id);
}
async fetchData(id) {
const article = await API.fetchArticle(id);
this.setState({ article });
}
// ...
}
class Article extends Component {
state = {
article: null
};
componentDidMount() {
this.fetchData(this.props.id);
}
componentDidUpdate(prevProps) {
if (prevProps.id !== this.props.id) {
this.fetchData(this.props.id);
}
}
async fetchData(id) {
const article = await API.fetchArticle(id);
this.setState({ article });
}
// ...
}
{id: 10}
으로 데이터를 요청하고 {id: 20}
으로 바꾸었다면, {id: 20}
의 요청이 먼저 시작된다. 그래서 먼저 시작된 요청이 더 늦게 끝나서 잘못된 상태를 덮어씌울 수 있다async
/ await
이 섞여있는 코드에 흔히 나타난다.위 경쟁상태를 해결하기 위해서는:
function Article({ id }) {
const [article, setArticle] = useState(null);
useEffect(() => {
let didCancel = false;
async function fetchData() {
const article = await API.fetchArticle(id);
if (!didCancel) {
setArticle(article);
}
}
fetchData();
return () => {
didCancel = true;
};
}, [id]);
// ...
}