useEffect를 리액트에서 가장 많이 쓰이는 hook 중 하나이다. (아마 useState 다음으로 많이?)
하지만 useEffect를 덕지덕지 사용하다보면, 버그에도 취약해지고, 리렌더링 횟수에 따라 성능도 약화되고, 디버깅하기도 어려워 진다. 🤪
useEffect를 꼭 필요한 경우에만 쓸 수 있도록 공식문서를 참고하여 정리해 보았다.
If there is no external system involved (for example, if you want to update a component’s state when some props or state change), you shouldn’t need an Effect.
외부 시스템(External System)과 동기화할 때 useEffect가 필요하다.
컴포넌트의 상태값을 가공할 때
이벤트를 핸들링할 때
잘 와닿지 않는데, 예제를 보면서 무슨말인지 감을 잡아보도록 하자.
firstName, lastName의 state로 fullName의 state를 업데이트하는 로직을 useEffect로 구현
function Form() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
// 🔴 Avoid: redundant state and unnecessary Effect...
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(firstName + ' ' + lastName);
}, [firstName, lastName]);
// ...
}
😔 만약 firstName이 바뀌면 => 리렌더링 => useEffect => 리렌더링으로 2번의 렌더가 발생한다.
function Form() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
// ✅ Good: calculated during rendering
const fullName = firstName + ' ' + lastName;
// ...
}
👍 firstName이 바뀌면 => 리렌더링과 동시에 fullName 평가
간단한 커멘트를 작성할 수 있는 ProfilePage 컴포넌트이다.
userId가 바뀌면, comment를 빈 문자열로 초기화 해야한다. useEffect를 사용하면 다음과 같이 할 수 있다.
export default function ProfilePage({ userId }) {
const [comment, setComment] = useState('');
// 🔴 Avoid: Resetting state on prop change in an Effect
useEffect(() => {
setComment('');
}, [userId]);
// ...
}
🤔 앞서 봤던 예제처럼 불필요하게 리렌더링을 더 할 뿐만 아니라, ProfilePage가 다른 children 컴포넌트를 가지고 있다면 모두 일일이 초기화 해야 한다.
즉, 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('');
// ...
}
key prop을 사용하면 훨씬 쉽게 컴포넌트를 초기화할 수 있다. 😄
key는 본래 map을 사용할 때 컴포넌트를 구별하기 위해 전달해 주는 prop이다.
이를 역이용하여, key를 바꿔주면, React는 완전히 다른 컴포넌트로 인식하게 되어, 처음부터 다시 렌더링하게 된다.
items가 바뀌었을 때 reverse는 가만히 두고, selection의 state만 변경하고 싶은 경우
function List({ items }) {
const [isReverse, setIsReverse] = useState(false);
const [selection, setSelection] = useState(null);
// 🔴 Avoid: Adjusting state on prop change in an Effect
useEffect(() => {
setSelection(null);
}, [items]);
// ...
}
items가 변경되면
즉, 여태 봐왔던 예제들 처럼 stale state로 불필요한 렌더를 하고 있다. 😤
그런데 이번에는 조금 다른 방식으로 문제를 해결할 수 있다.
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);
}
// ...
}
이러면 앞서 봤던 것과 같이 렌더링하는 과정은 똑같지 않냐고 되물을 수 있다.
items가 바뀌었으니, setPrevItem(null)
이 호출됬을 거고, 렌더한 이후에 바로 새로운 state로 렌더하게 되지 않냐고.
하지만 useEffect를 사용햇을 때와는 분명히 다르다.
setState가 호출된다면 react는 새로 렌더링할 JSX 버리고, 새로운 state로 렌더링을 시도한다.
그러면 children에 대한 평가를 하지않아도 되기 때문이다. 자세한 내용은 공식문서를 참고하시길..
물론 이 테크닉이 BEST는 아니다. 웬만한 경우에는 이런식으로 로직을 짤 이유가 없다.
function List({ items }) {
const [isReverse, setIsReverse] = useState(false);
const [selectedId, setSelectedId] = useState(null);
// ✅ Best: Calculate everything during rendering
const selection = items.find(item => item.id === selectedId) ?? null;
// ...
}
쇼핑몰 앱에서 상품을 장바구니에 담거나, 바로 구매하는 경우를 구현해보자.
유저는 두 가지 별개의 버튼을 클릭할 것이고,
우리는 handleBuyClick과 handleCheckoutClick으로 product의 상태를 감지하여 모달을 띄워줄 것이다.
function ProductPage({ product, addToCart }) {
// 🔴 Avoid: Event-specific logic inside an Effect
useEffect(() => {
if (product.isInCart) {
showNotification(`Added ${product.name} to the shopping cart!`);
}
}, [product]);
function handleBuyClick() {
addToCart(product);
}
function handleCheckoutClick() {
addToCart(product);
navigateTo('/checkout');
}
// ...
}
서론에서 useEffect를 사용하면 안되는 경우 중 하나가 event를 다룰 때였다.
ProductPage컴포넌트에서 우리가 원하는 것은, 유저가 버튼을 클릭했을 때 모달이 띄워지는 것이지,
product라는 state가 바뀌었기 때문에 모달이 띄워지는 것이 아니다.
useEffect를 사용하면, 버그에도 취약할 수 있다.
유저가 페이지를 새로고침한다면 useEffect가 그대로 실행되면서, 버튼을 클릭하지도 않았는데 모달이 띄워질 것이다.
React 공식문서에서는 다음과 같이 useEffect를 사용하는 기준을 다음과 같이 설명하고 있다.
When you’re not sure whether some code should be in an Effect or in an event handler, ask yourself *why* this code needs to run. Use Effects only for code that should run *because* the component was displayed to the user.
사용자에게 해당 컴포넌트가 보여졌다는 이유만으로도 호출되어야 하는 코드는 useEffect를 사용하고,
사용자와 인터랙션이 있을 때는 useEffect를 사용하지 않는 편이 좋다.
따라서, 이벤트 핸들러에서 즉각적으로 모달을 띄우는 코드를 호출하는 것으로 수정할 수 있다.
function ProductPage({ product, addToCart }) {
// ✅ Good: Event-specific logic is called from event handlers
function buyProduct() {
addToCart(product);
showNotification(`Added ${product.name} to the shopping cart!`);
}
function handleBuyClick() {
buyProduct();
}
function handleCheckoutClick() {
buyProduct();
navigateTo('/checkout');
}
// ...
}
App을 새로고침하거나 처음 실행하는 경우, 유저에 대한 인증정보를 얻어오는 경우가 많다.
function App() {
// 🔴 Avoid: Effects with logic that should only ever run once
useEffect(() => {
loadDataFromLocalStorage();
checkAuthToken();
}, []);
// ...
}
react 18버전 이후로는 development 환경에서 useEffect가 의도적으로 두 번 호출된다.
컴포넌트를 한번 마운트시키고 마는 것이 아니라 마운트->언마운트->마운트 하는 과정이 추가되었기 때문이다.
인증을 확인하는 코드가 한번만 호출되어야만 하고, 두 번 이상 호출되면 에러를 발생시킬 수도 있다.
이럴 때는 top-level variable을 가지고 전체 App이 로드되었는지 확인하면 된다.
let didInit = false;
function App() {
useEffect(() => {
if (!didInit) {
didInit = true;
// ✅ Only runs once per app load
loadDataFromLocalStorage();
checkAuthToken();
}
}, []);
// ...
}
혹은 top-level의 코드는 컴포넌트 내부와 달리 1번만 실행되므로 다음과 같이 작성할 수도 있다.
if (typeof window !== 'undefined') { // Check if we're running in the browser.
// ✅ Only runs once per app load
checkAuthToken();
loadDataFromLocalStorage();
}
function App() {
// ...
}
Form 컴포넌트에서 두 가지 useEffect의 유즈케이스를 비교해보자.
function Form() {
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
// ✅ Good: This logic should run because the component was displayed
useEffect(() => {
post('/analytics/event', { eventName: 'visit_form' });
}, []);
// 🔴 Avoid: Event-specific logic inside an Effect
const [jsonToSubmit, setJsonToSubmit] = useState(null);
useEffect(() => {
if (jsonToSubmit !== null) {
post('/api/register', jsonToSubmit);
}
}, [jsonToSubmit]);
function handleSubmit(e) {
e.preventDefault();
setJsonToSubmit({ firstName, lastName });
}
// ...
}
function Form() {
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
// ✅ Good: This logic runs because the component was displayed
useEffect(() => {
post('/analytics/event', { eventName: 'visit_form' });
}, []);
function handleSubmit(e) {
e.preventDefault();
// ✅ Good: Event-specific logic is in the event handler
post('/api/register', { firstName, lastName });
}
// ...
}
page와 query(검색어)에 따라서 검색 결과를 fetching하여 보여주는 컴포넌트이다.
function SearchResults({ query }) {
const [results, setResults] = useState([]);
const [page, setPage] = useState(1);
useEffect(() => {
// 🔴 Avoid: Fetching without cleanup logic
fetchResults(query, page).then(json => {
setResults(json);
});
}, [query, page]);
function handleNextPageClick() {
setPage(page + 1);
}
// ...
}
코드를 수정한다면 어떻게 해야할까? 앞서 본 예제들 처럼 이벤트핸들러를 이용하여 처리하는 것이 좋을까?
이 컴포넌트는 유저에게 보여지는 것만으로도 데이터를 fetching 해와야 한다. 따라서 useEffect를 사용하는 것이 맞다.
하지만 버그가 있다 😟
유저에 의해서 query가 빠르게 타이핑 되는 상황을 가정해보자.
fetch가 여러번 일어나는데, 유저에 의해 입력된 마지막 query에 대한 결과가 가장 마지막에 도착한다고 보장할 수 없다.
이를 race condition이라고 한다. race condition을 예방하기 위해, 다음과 같이 clean-up 함수를 작성해야 한다.
function SearchResults({ query }) {
const [results, setResults] = useState([]);
const [page, setPage] = useState(1);
useEffect(() => {
let ignore = false;
fetchResults(query, page).then(json => {
if (!ignore) {
setResults(json);
}
});
return () => {
ignore = true;
};
}, [query, page]);
function handleNextPageClick() {
setPage(page + 1);
}
// ...
}
물론 React 공식문서에서는 이처럼 useEffect를 사용하는 것보다, pre-built되어 제공되는 프레임워크를 사용하기를 권장하기를 권하고 있다...!
잘 봤습니다. 감사합니다.