주의❗️ : 이 글은 'dante Yoon'님의 [useEffect 잘못쓰고 계신겁니다.] 글과 React 공식 문서를 참조하여 개인적으로 정리한 글입니다. 잘못된 부분이 있다면 댓글로 알려주시면 감사하겠습니다🙇🏻♂️
useEffect는 컴포넌트의 상태 값이 변화할 때마다 특정 동작을 수행시키고 싶을 때 사용하는 Hook이다. deps에 어떤 값이 들어가는지에 따라 컴포넌트 생성 후 상태 값의 변화에 맞춰 수행하거나 딱 한 번만 수행할 수 있게 할 수 있다.
cleanup 함수가 뭔가요?
React의 useEffect Hook은 class 생명주기 메서드에서 componentDidMount
, componentDidUpdate
, componentWillUnmount
세 가지 시점에 특정 side effect를 실행시키기 위해 조합된 훅이다(React useEffect 공식문서 내용이 완벽하니 꼭 한 번씩 읽어볼 것).
이 때, Component의 unmount 이전 / update 직전에 어떠한 작업을 수행하고 싶다면 Clean-up 함수
를 반환해 주어야 한다.
Clean-up 함수를 사용하게 되면 수행 순서는 re-render -> 이전 effect clean-up -> effect
이다.
친구가 온라인인지 아닌지 표시하는 FriendStatus
컴포넌트 예시를 생각해봅시다. class는 this.props
로부터 friend.id
를 읽어내고 컴포넌트가 마운트된 이후에 친구의 상태를 구독하며 컴포넌트가 마운트를 해제할 때에 구독을 해지합니다.
componentDidMount() {
ChatAPI.subscribeToFriendStatus(
this.props.friend.id,
this.handleStatusChange
);
}
componentWillUnmount() {
ChatAPI.unsubscribeFromFriendStatus(
this.props.friend.id,
this.handleStatusChange
);
}
그런데 컴포넌트가 화면에 표시되어있는 동안 friend
prop이 변한다면 무슨 일이 일어날까요? 컴포넌트는 다른 친구의 온라인 상태를 계속 표시할 것입니다. 버그인 거죠. 또한 마운트 해제가 일어날 동안에는 구독 해지 호출이 다른 친구 ID를 사용하여 메모리 누수나 충돌이 발생할 수도 있습니다.
클래스 컴포넌트에서는 이런 경우들을 다루기 위해 componentDidUpdate
를 사용합니다.
componentDidMount() {
ChatAPI.subscribeToFriendStatus(
this.props.friend.id,
this.handleStatusChange
);
}
componentDidUpdate(prevProps) {
// 이전 friend.id에서 구독을 해지합니다.
ChatAPI.unsubscribeFromFriendStatus(
prevProps.friend.id,
this.handleStatusChange
);
// 다음 friend.id를 구독합니다.
ChatAPI.subscribeToFriendStatus(
this.props.friend.id,
this.handleStatusChange
);
}
componentWillUnmount() {
ChatAPI.unsubscribeFromFriendStatus(
this.props.friend.id,
this.handleStatusChange
);
}
React 애플리케이션의 흔한 버그 중의 하나가 componentDidUpdate
를 제대로 다루지 않는 것입니다.
이번에는 Hook을 사용하는 컴포넌트를 생각해봅시다.
function FriendStatus(props) {
// ...
useEffect(() => {
// ...
ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
return () => {
ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
};
});
이 경우에는 버그에 시달리지 않습니다.(달리 바꾼 것도 없는데 말이죠.)
useEffect
가 기본적으로 업데이트를 다루기 때문에 더는 업데이트를 위한 특별한 코드가 필요 없습니다. 다음의 effect를 적용하기 전에 이전의 effect는 정리(clean-up)합니다.
위 예시처럼 컴포넌트가 마운트 해제되는 순간 뿐만 아니라 리렌더링되는 모든 순간에도 cleanup함수가 사용되며, cleanup 함수를 잘 활용해야 사용자가 리렌더링이 두 번 일어나는지 느끼지 못하게 된다.
function TodoList({ todos, filter }) {
const [newTodo, setNewTodo] = useState("");
// 🔴 Avoid: redundant state and unnecessary Effect
const [visibleTodos, setVisibleTodos] = useState([]);
useEffect(() => {
setVisibleTodos(getFilteredTodos(todos, filter));
}, [todos, filter]);
// ...
}
위 코드에서 todos, filter 둘 중 하나의 데이터만 변경되더라도 ui가 업데이트되어야 하기 때문에 visibleTodos state는 변경되어야 한다. 그래서 useEffect를 사용하는 것은 문제가 없어보인다.
그러나 이는 불필요한 리렌더링을 발생시킨다. 상태 변경이 일어나면 리엑트는 돔에 변경된 state를 commit하고 그 이후에 ui를 업데이트 한다. 위 경우에는
todos(or filter) 데이터가 변경
→ 리렌더링, getFilteredTodos 변경
→ 리렌더링, setVisibleTodos 변경
→ 리렌더링
총 3번의 리렌더링이 발생하게 되며 불필요한 렌더링이 총 2(3-1)x2번 일어나는 것이다.
import { useMemo, useState } from "react";
function TodoList({ todos, filter }) {
const [newTodo, setNewTodo] = useState("");
// ✅ Does not re-run getFilteredTodos() unless todos or filter change
const visibleTodos = useMemo(
() => getFilteredTodos(todos, filter),
[todos, filter]
);
// ...
}
위 코드는 ui 업데이트를 위해 setVisibleTodos
를 호출해주지 않아도 컴포넌트가 리렌더링 될 때마다 ui가 의존하고 있는 visibleTodos가 업데이트 되기 때문에 ui는 최신 데이터 반영을 보장할 수 있다.
export default function ProfilePage({ userId }) {
const [comment, setComment] = useState("");
// 🔴 Avoid: Resetting state on prop change in an Effect
useEffect(() => {
setComment("");
}, [userId]);
// ...
}
관리자 페이지에서 유저들에 대한 코멘트를 작성한다고 가정, 유저 1에 대한 정보를 작성하다 유저 2로 이동할 경우 위 처럼 comment state를 명시적으로 useEffect 내부에서 리셋해주는 방식을 떠올릴 수 있다. 그러나 이 방식은 마찬가지로 userId가 변경될 때 한 번, setComment가 실행된 이후 한 번 총 두 번의 리렌더링이 일어나는 비효율적인 방법이다.
아래와 같이 컴포넌트 내부 useEffect 사용이 아닌 페이지단에서 컴포넌트 자체를 리셋하는 방식으로 접근한다면 더 효율적인 리엑트 사용이 가능할 것이다.
export default function ProfilePage({ userId }) {
return <Profile userId={userId} key={userId} />;
}
function Profile({ userId }) {
// ✅ This and any other state below will reset on key change automatically
const [comment, setComment] = useState("");
// ...
}
function List({ items }) {
const [isReverse, setIsReverse] = useState(false);
const [selection, setSelection] = useState(null);
useEffect(() => {
setSelection(null);
}, [items]);
// ...
}
위 코드의 경우 아래와 같이 바꿀 수 있다.
function List({ items }) {
const [isReverse, setIsReverse] = useState(false);
const [selection, setSelection] = useState(null);
// Better: Adjust the state while rendering
const [prevItems, setPrevItems] = useState(items);
if (items !== prevItems) {
setPrevItems(items);
setSelection(null);
}
// ...
}
useEffect 대신 useState와 조건문을 사용하여 state를 set시켜주는 방법이다. 두 방법의 차이로는 우선 useEffect의 태생적 실행 순서가 있다. useEffect는 컴포넌트의 렌더링이 끝난 뒤 effect가 실행된다. 아래 방법은 컴포넌트의 렌더링 과정 중에 update가 이루어진다. useEffect로 인한 불필요한 리렌더링으로 낭비되는 메모리보다 훨씬 적은 메모리 낭비를 꾀할 수 있다.
useEffect(() => {
let ignore = false;
async function startFetching() {
const json = await fetchTodos(userId);
if (!ignore) {
setTodos(json);
}
}
startFetching();
return () => {
ignore = true;
};
}, [userId]);
위 코드와 같이 clean-up 함수를 통해 data fetching 이후 수행될 effect의 불필요한 반복을 토글할 수 있는 2진 변수를 활용하는 방법이 있다.
data fetching 횟수가 적지 않을 경우, 또는 데이터의 캐싱이 필요할 경우에는 react-query
와 같은 data fetch 최적화 라이브러리를 활용하는 것이 좋다.