[아티클럽] 리액트 useEffect에 대해서 그리고 4가지 팁
2021년 05월 02일, 15:01
관심 있는 내용에 대해서 아티클을 읽고 정리하기 위해 아티클럽을 결성, 시작한다.
리액트의 생명주기
useEffect의 팁을 알아가기 전에 리액트의 생명 주기부터 간단하게 살펴보자.
아래의 그림을 보자.(출처, 리액트 생명주기)
생성 | 업데이트 | 삭제 |
---|---|---|
constructor | render | componentWillUnmount |
render | componentDidUpdate | |
componentDidMount |
useEffect?
- 리액트에게 컴포넌트가 렌더링 이후에 어떤 일을 수행하게 하는 훅
- DOM 업데이트를 수행한 이후에 불러와서 실행
- componentDidMount, componentDidUpdate, componentWillUnmount 3가지가 합쳐진 것이라고 생각해도 무방
useEffect를 이용한 간단한 Counter
간단한 카운터를 생각해보자, count state를 두고 setTimeout으로 count를 증가시킨다.
전체 코드를 보면,
import React, { useEffect, useState } from "react";
function Main() {
const [count, setCount] = useState(0);
useEffect(() => {
const time = setTimeout(() => {
setCount(count + 1);
}, 1000);
return () => {
clearTimeout(time);
};
}, [count]);
return <div>{count}</div>;
}
useEffect에 대해서 설명하고 있기 때문에 useEffect 부분만 따로 빼서 확인해보자.
useEffect(() => {
const time = setTimeout(() => {
setCount(count + 1);
}, 1000);
return () => {
clearTimeout(time);
};
}, [count]);
해당 코드를 실행시켜보면, 당연하게 1초에 count가 1씩 증가하는 것을 볼 수 있다. 이 과정을 한번 생명주기 메서드에 맞춰 생각해보면,
- count의 초기값을 0으로 설정
- return된 값을 render
- DOM에 업데이트 -> 0이 찍힘
- component가 mount되고 난 후 setCount를 실행 -> componentDidMount
const time = setTimeout(() => { setCount(count + 1) }, 1000);
- state가 바뀌었으므로 render 다시 실행
- DOM에 1이 찍힘
- useEffect는 componentDidUpdate도 같이 포함되어 있기 때문에 다시 useEffect의 작업이 실행
- return 값 즉 clean-up(componentWillUnmount)이 실행되어 이 전 setTimeout을 없앰.
위와 같은 순서를 볼 때, useEffect는 3가지의 생명주기 메서드를 합친 것이라 생각해도 된다.
참고
사실 useEffect의 return 값은 componentWillUnmount와 정확하게 맞진 않다.
위의 코드에서 deps 배열에 count값을 주고 리렌더링이 될 때마다 useEffect의 리턴값의 함수가 실행된다.
마운트가 해제될 때 실행되야하는데 count의 값이 바뀔때마다 리렌더링 된다는 것이다.
공식 문서에 따르면, componentDidMount, componentWillUnmount만 사용하면 버그가 생길 수 있다는 것이다.
componentDidMount에서 사용한 props와 componentWillUnmount에서 사용한 props가 다를 수 있다는 점을 예제로 들어 설명한다.(클래스형 컴포넌트)
그래서 우리는 componentDidUpdate에서 바뀐 것이 있는지 확인해야한다. 이 과정을 합쳐놓은 것이 useEffect이기 때문에 return 값의 함수가 dep에 따라,
실행되는 것이다.(자세한 내용은 공식문서를 읽는 것을 추천). 그래서 사실 useEffect의 return 값은 componentDidUpdate, componentWillUnmount 두 가지 경우에 실행된다.
useEffect를 사용하는데 있어서 네 가지의 팁
useEffect에 대해서 조금은 이해한 것 같으므로, 어떻게 사용하는 것이 좋은지를 생각해봐야한다.
-
단일 목적의 useEffect를 사용하자. useEffect의 목적 자체가 side effect를 수행하는 것인데, 여러 가지의 side effect들이 섞여 있다면 헷갈릴 것이다.
그리고, 여러가지 목적의 side effect들을 섞어 수행한다면, dependency 배열에 따른 componentDidUpdate가 무분별하게 실행될 가능성이 있을 것이다.나쁜 예
function Main() { const [countA, setCountA] = useState(0); const [countB, setCountB] = useState(0); useEffect(() => { const timeoutA = setTimeout(() => setCountA(countA + 1), 1000); const timeoutB = setTimeout(() => setCountB(countB + 1), 2000); return () => { clearTimeout(timeoutA); clearTimeout(timeoutB); }; }, [countA, countB]); return ( <div> <p>A : {countA}</p> <p>B : {countB}</p> </div>); }
위의 코드는 잘 작동하지 않는다. useEffect에서 deps 배열에 따라서 countA, countB가 바뀔 때마다 componentDidUpdate가 실행되고 clean-up함수가 실행 될 것이다.
그러면 countA가 바뀌는 경우 clean-up 함수가 실행되고 아직 실행되지 않은 countB의 timeout이 지워질 수 있기 때문이다.
이런 버그들을 생성할 수 있으므로 단일 목적으로 useEffect를 사용하자.좋은 예
function Main() { const [countA, setCountA] = useState(0); const [countB, setCountB] = useState(0); useEffect(() => { const timeoutA = setTimeout(() => setCountA(countA + 1), 1000); return () => { clearTimeout(timeoutA); }; }, [countA]); useEffect(() => { const timeoutB = setTimeout(() => setCountB(countB + 1), 2000); return () => { clearTimeout(timeoutB); }; }, [countB]); return ( <div> <p>A : {countA}</p> <p>B : {countB}</p> </div> ); }
-
커스텀 훅을 사용하자 1번에 이어져서 보면, Main안에 여러 개의 useEffect가 있으면 코드의 길이는 길어지고 그러면 가독성이 떨어지게되고 유지보수도 하기 힘들게 된다.
단일 목적에 따라 나눈 김에 커스텀 훅을 사용해서 Main 컴포넌트 안에서는 useEffect에 대한 내부적인 구현을 없애보자.function Main() { const [countA, setCountA] = useCounterA(); const [countB, setCountB] = useCounterB(); return ( <div> <p>A : {countA}</p> <p>B : {countB}</p> </div> ); } function useCounterA() { const [countA, setCountA] = useState(0); useEffect(() => { const timeoutA = setTimeout(() => setCountA(countA + 1), 1000); return () => { clearTimeout(timeoutA); }; }, [countA]); return [countA, setCountA]; } function useCounterB() { const [countB, setCountB] = useState(0); useEffect(() => { const timeoutB = setTimeout(() => setCountB(countB + 1), 2000); return () => { clearTimeout(timeoutB); }; }, [countB]); return [countB, setCountB]; }
-
조건부 useEffect의 옳은 방법 특정한 이유로 카운터 횟수를 제한해보자.
function Counter() { const [count, setCount] = useState(0); useEffect(() => { let time; if (count < 5) { time = setTimeout(() => { setCount(count + 1); }, 1000); } return () => { clearTimeout(time); }; }, [count]); return <div>{count}</div>; }
위의 방법은 좋지 않다. 만약 다른 곳에서 count가 5 이상의 값으로 변경된다고 하면 이 useEffect는 clean-up 함수에서 clearTimeout을 실행할 것이다.
그래서 이렇게 실행하지 않게 하기 위해서 아래와 깉이 고치면 좀 더 이상적인 방법일 것 같다.function Counter() { const [count, setCount] = useState(0); useEffect(() => { if (count >= 5) return; const time = setTimeout(() => { setCount(count + 1); }, 1000); return () => clearTimeout(time); }, [count]); return <div>{count}</div>; }
-
useEffect안에서 사용되는 변수들은 dep 배열에 추가한다. useEffect에서 사용되는 변수들은 꼭 추가해주는 것이 좋지만, 몇몇 상황에서는 의문이 들 때가 있다.
가령, setTimeout으로 1초씩 증가하는 카운터를 useEffect에서 dep에 둔다면, 1초에 한번씩 useEffect가 실행된다고 볼 수 있다.
setInterval로 한다면 한번만 실행해도 될 것을 말이다. 그렇다면 한 번 실행하게 하기 위해 빈 배열을 주어야하고, 이러면 counter에서는 버그가 생긴다.
counter 뿐만 아니라 다른 구현상황에서도 버그가 생길 수도 있다. 그래서 공식문서에서는 다음과 같은 방법들을 소개한다.- setState에 함수 컴포넌트 업데이트 폼 사용
- 함수 업데이트 폼을 사용한다면, count 변수에 의존하지 않을 수 있으므로 dep에서 제거해도 된다.
- useReducer를 이용한 dispatch 함수를 dep에 추가
- state의 업데이트 로직을 effect 외부(reducer)에서 실행하고, useEffect는 dispatch 함수를 사용한다.
- useRef를 사용하기
- setState에 함수 컴포넌트 업데이트 폼 사용