Sua Blog
React 18 + Strict Mode에서 useEffect사용시 최소 두번이상 랜더링(또는 data fetching)이 일어날 수 있다는 블로그 포스팅을 보게 되었다.
React Docs-You Might Not Need an Effect 을 참고하여 useEffect를 사용하면서 발생할 수 있는 문제와 해결 방법을 정리했다.
React 18 + Strict Mode
React 18은 페이지 이동 후 다시 돌아왔을 떄 앱이 망가지는 부분이 없는지 확인하기 위해 개발모드(process.env.NODE_ENV === development)에서 한 컴포넌트를 두번 렌더링한다.
그렇다고 StrictMode를 사용하지 않으면 두번씩 렌더링이 일어나지는 않지만 production 환경에서 일어날 수 있는 오류를 잡아주지 못하므로 항상 StrictMode에서 개발하는 것이 좋다.
그럼 어떻게 하면 Effect를 한번만 동작할 수 있을까? 라고 생각할 수 있는데 이는 잘못된 접근 방법이다. useEffect가 두번씩 호출 되어도 어떻게 유저가 리렌더링을 느끼지 못하게 할까? 가 올바른 접근 방법이다.
해결 방법
외부 플러그인을 사용하는 경우
cleanup 함수를 꼭 작성하자.
useEffect(() => {
const dialog = dialogRef.current;
dialog.showModal();
return () => dialog.close();
}, []);
props, state 변경에 따라 또다른 state를 업데이트 해야 할 때
useEffect, useState를 사용하지 않는다.
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]);
// ✅ Good: calculated during rendering
const fullName = firstName + ' ' + lastName;
// ...
}
props, state 변경에 따라 데이터를 변경해야하는 경우
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]);
// ...
// ✅ This is fine if getFilteredTodos() is not slow.
const visibleTodos = getFilteredTodos(todos, filter);
// ...
}
위의 경우도 불필요한 리렌더링을 발생시키기 때문에 setVisibleTodos를 호출하지 않아도 된다.
하지만 속도는 느릴 수 있다.
이 경우에 다시 계산하지 않아도 되는경우까지 계산될 수 있기 때문에 useMemo를 사용하여 memoize 할 수 있다.
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]
);
// ...
}
이렇게 하면 내부 함수가 변경되거나 변경 되지 않는 한 재실행되지 않는다.
useMemo로 래핑한 함수는 렌더링 중에 실행되므로 순수 계산에만 작동한다.
props 변경에 따라 상태가 리셋 되어야 하는 경우
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]);
// ...
}
위 처럼 useEffect를 실행하면 item이 변경 될 때 전체 프로세스를 다시 시작하고 하위 구성 요소를 다시 렌더링하도록 한다.
Effect를 삭제하고 상태를 직접 변경하는게 낫다.
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);
}
// ...
}
이전 상태를 저장하고 직접 비교하면 렌더링 중에 직접 호츨되고 업데이트 됐을 때 반환된 JSX를 버리고 즉시 렌더링을 다시 시도한다.
이 패턴이 Effect보다 효울적이지만 props나 다른 상태를 기반으로 상태를 변경하면 데이터 흐름을 이해하고 디버그하기 더 어려워 질 수 있다.
이 경우
- 키를 사용하여 모든 상태를 재설정 하거나
- 렌더링하는 동안 모든 것을 계산할 수 있는지 확인 하는게 더 좋은 방식이다.
예를 들어, 선택한 항목을 저장(및 재설정)하는 대신 선택한 항목 ID를 저장할 수 있다.
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;
// ...
}
이렇게하면 상태를 변경할 필요가 없어진다.
선택한 ID의 항목이 목록에 있으면 선택된 상태로 유지된다. 일치 하지 않은 경우 일치하는 항목이 없기 때문에 렌더링 중에 계산 된다.
로직이 useEffect에 있어야하는지 이벤트 핸들러에 있어야하는지
예를들어 제품 구매 페이지에서 구매, 결제 버튼이 있고 사용자가 장바구니에 제품을 넣을 때 마다 알림을 표시하려고 할 때, 두 버튼에 클릭 핸들러에 대한 호츨을 추가하는 것이 반복적으로 느껴져 useEffect를 사용하려고 할 수 있다.
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는 불필요하다. 또한 버그를 일으킬 가능성이 높다.
장바구니에 상품을 한 번 추가하고 페이지를 새로고침하면 알림이 다시 나타난다. 제품의 페이지를 새로 고칠때마다 계속 표시된다. 이는 이미 페이지를 로드할 때 useEffect를 호출하기 때문이다.
로직이 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');
}
// ...
}
코드가 상태를 변경했을 때 작동하는 것이 의도인지, 유저의 이벤트에 따라 UI를 변경하는 것이 주 목적인지 생각해볼 때, 버그가 생길 수 있는 useEffect보다 이벤트 핸들러 내부에서 호출하는 것이 바람직하다.
이벤트 핸들러 내부에서 처리할 수 있는 로직은 이벤트 핸들러 내에서 처리하자.
애플리케이션을 초기화 하는 경우
로직이 컴포넌트가 마운트 될때 한번만 실행되어야하는 경우가 있다
function App() {
// 🔴 Avoid: Effects with logic that should only ever run once
useEffect(() => {
loadDataFromLocalStorage();
checkAuthToken();
}, []);
// ...
}
하지만 이 경우도 개발 중에 두 번 실행되고 이로 인해 문제가 발생할 수 있다.
프로덕션 환경에서 실제로 다시 마운트되지 않을 수도 있지만 모든 구성 요소에서 동일한 제약 조건을 따르면 코드를 재사용할 수 있다.
만약 일부 코드가 마운트당 한 번이 아니라 앱 로드당 한 번 실행되어야 하는 경우 최상위 변수를 추가해 이미 실행되었는지 여부를 추적하고 항상 재실행을 건너뛸 수 있다.
let didInit = false;
function App() {
useEffect(() => {
if (!didInit) {
didInit = true;
// ✅ Only runs once per app load
loadDataFromLocalStorage();
checkAuthToken();
}
}, []);
// ...
}
앱이 렌더링되기 전에 실행할 수도 있다.
if (typeof window !== 'undefined') {
// Check if we're running in the browser.
// ✅ Only runs once per app load
checkAuthToken();
loadDataFromLocalStorage();
}
function App() {
// ...
}
최상위 요소 코드는 렌더링 되지 않더라도 한 번 실행된다.
속도 저하를 일으킬 수도 있기 때문에 이 패턴을 남용해서는 안된다. App.js와 같은 진입점 모듈에서 앱 전체 초기화를 하는 경우 사용하면 좋을 것 같다.
자식 컴포넌트에서 부모에게 데이터를 전달하는 경우
function Parent() {
const [data, setData] = useState(null);
// ...
return <Child onFetched={setData} />;
}
function Child({ onFetched }) {
const data = useSomeAPI();
// 🔴 Avoid: Passing data to the parent in an Effect
useEffect(() => {
if (data) {
onFetched(data);
}
}, [onFetched, data]);
// ...
}
React에서 데이터 흐름은 상위 요소에서 하위 요소로 흐른다.
위의 코드처럼 자식 컴포넌트에서 부모 컴포넌트로 상태를 업데이트 하면 데이터 흐름을 추적하기 어려워진다. 상위 요소와 하위 요소에서 모두 동일한 데이터가 필요하면 부모 요소에서 데이터를 fetching하여 자식에게 전달해야한다.
function Parent() {
const data = useSomeAPI();
// ...
// ✅ Good: Passing data down to the child
return <Child data={data} />;
}
function Child({ data }) {
// ...
}
위 코드가 더 간단하고 데이터의 흐름을 예측 가능하게 유지한다.
데이터를 가지고 올 때
많은 앱에서 데이터를 가지고 올 때 useEffect를 사용하고 이 방법은 매우 일반적이다.
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);
}
// ...
}
위에 내용에서 이벤트 핸들러에서 로직을 처리해야한다고 했지만 이 경우 데이터를 받는 경우가 다양할 수 있다. 검색 입력의 경우 url에서 미리 채워질 수도 있고 타이핑 이벤트에 의해서도 데이터를 받아올 수 있다. 데이터가 어디서 왔는지, 무슨 동작으로 왔는지 보다 네트워크의 데이터와 동기화된 데이터라는 점이 더 중요하다.
만약에 유저가 아주 빨리 타이핑을 하는 경우 데이터 fetching이 시작되지만 응답이 어떤 순서로 올지 순서를 보장할 수 없다.
이런 경우를 race condition이라고 하는데 순서를 보장하려면 이전에 온 response를 무시하는 기능이 필요하다.
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);
}
// ...
}
이렇게 하면 useEffect가 데이터를 가져올 때 마지막으로 요청된 응답을 제외한 이전 데이터가 무시된다.
아니면 custom hooks를 만들어 데이터를 가져오는 것도 좋은 방법이 된다.
function SearchResults({ query }) {
const [page, setPage] = useState(1);
const params = new URLSearchParams({ query, page });
const results = useData(`/api/search?${params}`);
function handleNextPageClick() {
setPage(page + 1);
}
// ...
}
function useData(url) {
const [data, setData] = useState(null);
useEffect(() => {
let ignore = false;
fetch(url)
.then((response) => response.json())
.then((json) => {
if (!ignore) {
setData(json);
}
});
return () => {
ignore = true;
};
}, [url]);
return data;
}
useEffect 남발한 경험
이전 프로젝트에서 useEffect를 남발해 작성한 회원가입 관련 로직이 있었다.
[수정 전]
React.useEffect(() => {
window.addEventListener('keypress', onKeypress);
window.addEventListener('keydown', onKeydown);
return () => {
window.removeEventListener('keypress', onKeypress);
window.removeEventListener('keydown', onKeydown);
};
}, []);
React.useEffect(() => {
if (signUp && !address) {
alert('network disconnected!');
setModal((p) => ({ ...p, signUp: false }));
}
}, [signUp, address]);
React.useEffect(() => {
if (step > 4 && verify && check) {
setDisable(false);
} else {
setDisable(true);
}
}, [step, verify, check]);
React.useEffect(() => {
if (step === 2 && isMobile) {
inputFocus();
}
if (step === 2 && code.length === 6) {
verifyCode({
variables: {
email: email + email2,
wallet: address,
code,
},
});
}
if (step > 7) {
activeUser({
variables: {
email: email + email2,
wallet: address,
},
});
}
}, [code, step]);
const handleSubmit = () => {
if (!disable) {
setStep(isUser ? 7 : 6);
}
};
위 내용을 공부하며 꼭 필요한 useEffect만 남기고 코드를 수정하였다.
렌더링 중 계산 될 수 있는 상태는 변수에 담아 사용하고 이벤트 핸들러에서 호출되어야 하는 로직은 이벤트 핸들러로 이동하였다.
[수정 후]
const disable = !(step > 4 && verify && check);
React.useEffect(() => {
window.addEventListener('keypress', onKeypress);
window.addEventListener('keydown', onKeydown);
return () => {
window.removeEventListener('keypress', onKeypress);
window.removeEventListener('keydown', onKeydown);
};
}, []);
React.useEffect(() => {
if (step === 2) {
if (isMobile) inputFocus();
if (code.length === 6)
verifyCode({
variables: {
email: email + email2,
wallet: address,
code,
},
});
}
}, [code, step]);
const handleSubmit = () => {
if (!disable) {
setStep(isUser ? 7 : 6);
if (isUser)
activeUser({
variables: {
email: email + email2,
wallet: address,
},
});
}
};
정리
- 렌더링하는 동안 계산할 수 있으면 useEffect가 필요하지 않다.
- 계산이 많이 필요하면
useMemo를 사용하여 캐시할 수 있다. - props 변경에 따른 상태 변경은 렌더링 중에 설정한다.
- 구성 요소가 변경되고 실행되어야 하는 코드는 useEffect에 남기고 나머지는 이벤트 핸들러에 있어야한다.
- 마운트 기준이 아닌 앱 로드당 한 번 실행되어야 하는 경우 최상위 변수를 이용하거나 렌더링 되기전에 실행한다.
- useEffect를 사용해 데이터를 fetching 할 수 있지만 데이터 응답 순서를 보장 할 수 없으므로 정리 기능을 추가해야한다.