개발을 하면서 useEffect를 남용하는 일이 많은 것 같아 useEffect를 어떻게 효과적으로, side Effect가 발생하지 않도록 사용 할 수 있을까? 에 대한 고민이 생겼다.
먼저 공식문서 내용부터 정리해보고 가자.
Effect는 React 패러다임에서 벗어날 수 있는 탈출구이다. Effect를 사용하면 React의 “외부로 나가서” 컴포넌트를 React가 아닌 위젯, 네트워크 또는 브라우저 DOM과 같은 외부 시스템과 동기화할 수 있다.
외부 시스템이 관여하지 않는 경우(예: 일부 props나 state가 변경될 때 컴포넌트의 state를 업데이트하려는 경우)에는 Effect가 필요하지 않다.
기본적으로 Effect는 렌더링 이후 commit 단계에서 실행된다. => React의 모든 화면 업데이트는 세단계로 이루어진다.
렌더링 트리거
컴포넌트 리렌더링
커밋
- 컴포넌트의 초기 렌더링
- state가 업데이트 된 경우, prop이 변경된 경우
이 두가지 경우가 일어나는 경우를 렌더링 트리거 라고 한다.
초기렌더링시.. createRoot~ 호출 후 render 메소드로 화면을 그리는 작업도 트리거이다.
트리거가 일어난 컴포넌트를 호출하여 화면에 표시할 내용을 파악하는 작업.
초기 렌더링에서 React는 루트 컴포넌트를 호출합니다.
이후 렌더링에서 React는 state 업데이트가 일어나 렌더링을 트리거한 컴포넌트를 호출합니다
재귀적 단계: 업데이트된 컴포넌트가 다른 컴포넌트를 반환하면 React는 다음으로 해당 컴포넌트를 렌더링하고 해당 컴포넌트도 컴포넌트를 반환하면 반환된 컴포넌트를 다음에 렌더링하는 방식. 중첩된 컴포넌트가 더 이상 없고 React가 화면에 표시되어야 하는 내용을 정확히 알 때까지 이 단계는 계속된다.
이때 => 가상돔을 비교해 변경된 속성을 계산한다.
초기 렌더링의 경우 React는 appendChild() DOM API를 사용하여 생성한 모든 DOM 노드를 화면에 표시함.
리렌더링의 경우 React는 필요한 최소한의 작업(렌더링하는 동안 계산된 것!)을 적용하여 DOM이 최신 렌더링 출력과 일치하도록 한다.
React는 렌더링 단계에서 기록한 모든 useEffect를 모아서 실행한다.
useEffect 훅이 실행되어 의존성 배열에 지정된 값들을 이전 렌더링과 비교한다.
의존성 배열에 지정된 값 중 하나라도 이전 렌더링과 다르다면, useEffect의 콜백 함수가 실행된다.
4.컴포넌트가 다음으로 렌더링될 때, 다시 의존성 배열을 비교하고 변경 여부에 따라 useEffect의 콜백 함수를 실행할지 결정한다.
BAD
function Form() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
// 🔴 Avoid: redundant state and unnecessary Effect
// 🔴 이러지 마세요: 중복 state 및 불필요한 Effect
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(firstName + ' ' + lastName);
}, [firstName, lastName]);
// ...
}
GOOD
function Form() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
// ✅ Good: calculated during rendering
// ✅ 좋습니다: 렌더링 과정 중에 계산
const fullName = firstName + ' ' + lastName;
// ...
}
필터링 결과를 계산하고 싶을 때
state가 리렌더링이 되기 때문에 => 불필요하게 다시 effect를 할 필요가 없다.
BAD
function TodoList({ todos, filter }) {
const [newTodo, setNewTodo] = useState('');
// 🔴 Avoid: redundant state and unnecessary Effect
// 🔴 이러지 마세요: 중복 state 및 불필요한 Effect
const [visibleTodos, setVisibleTodos] = useState([]);
useEffect(() => {
setVisibleTodos(getFilteredTodos(todos, filter));
}, [todos, filter]);
// ...
}
GOOD
function TodoList({ todos, filter }) {
const [newTodo, setNewTodo] = useState('');
// ✅ This is fine if getFilteredTodos() is not slow.
// ✅ getFilteredTodos()가 느리지 않다면 괜찮습니다.
const visibleTodos = getFilteredTodos(todos, filter);
// ...
}
prop이 변경되면 모든 state 재설정하기
ProfilePage컴포넌트는 userId값이 변하면 => 기본 state값을 초기화 시켜줘야 한다.
이럴때 흔히 아래와 같이 작성하는 경우가 많다.
이것은 ProfilePage컴포넌트가 먼저 오래된 값으로 렌더링한 다음 새로운 값으로 다시 렌더링하기 때문에 비효율적이다.
export default function ProfilePage({ userId }) {
const [comment, setComment] = useState('');
// 🔴 Avoid: Resetting state on prop change in an Effect
// 🔴 이러지 마세요: prop 변경시 Effect에서 state 재설정 수행
useEffect(() => {
setComment('');
}, [userId]);
// ...
}
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
// ✅ key가 변하면 이 컴포넌트 및 모든 자식 컴포넌트의 state가 자동으로 재설정됨
const [comment, setComment] = useState('');
// ...
}
BAD
props가 변경될때 => 어차피 컴포넌트 렌더링은 일어난다. => 이때 렌더링중에 해당 prop에 따라 지역 state를 변경하는 것이 더 효과적이다.
items 변함 => 컴포넌트 리렌더링이 일어남, 이때 변경사항이 바로 반영되는게 아니라, 가상돔과 비교해 이전값과 변한값만 반영한 후 Dom을 업데이트 => useEffect코드 뭉쳐서 실행함. => 다시 selection이 업데이트 되었으니 해당 state사용하는 컴포넌트 리렌더링...
function List({ items }) {
const [isReverse, setIsReverse] = useState(false);
const [selection, setSelection] = useState(null);
// 🔴 Avoid: Adjusting state on prop change in an Effect
// 🔴 이러지 마세요: prop 변경시 Effect에서 state 조정
useEffect(() => {
setSelection(null);
}, [items]);
// ...
}
이게 되네? prop이 변경됨 => 해당 prop을 사용하는 state도 해당 prop으로 다시 리렌더링 되는게 아닌가?
아니다. React는 이전 렌더링의 정보를 저장한다.
items가 변경된다면 => 컴포넌트 렌더링이 일어난다. 이때, 처음에는 오래된 selection 값으로 렌더링되고, React는 Dom을 업데이트하고 Effect를 실행한다. 즉, 초기에 렌더링이 될때 가상돔을 비교하는 작업에서 오래된 값이 들어있기 때문에 해당로직이 실행가능한 것.
- 1.items 변경
- 컴포넌트 리렌더링 => 이때 prevItems은 아직 update되지 않았음(이전값을 들고있다는 의미)
- 아래 if문 로직 수행 => setSelection호출
- 해당 변경사항 Dom update 이후 commit
function List({ items }) {
const [isReverse, setIsReverse] = useState(false);
const [selection, setSelection] = useState(null);
// Better: Adjust the state while rendering
// 더 나음: 렌더링 중에 state 조정
const [prevItems, setPrevItems] = useState(items);
if (items !== prevItems) {
setPrevItems(items);
setSelection(null);
}
// ...
}
단점 : 해당 로직을 이해하기가 좀 힘들다.
렌더링 중에 모든 로직을 수행하도록 코드를 짜는것이 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;
// ...
}
알림로직이 두 핸들러간에 공통일때 => 이것을 useEffect에 넣고 싶을 수도 있다.(중복코드 작성하기 싫어서)
하지만 이 effect는 불필요하고, sideEffect를 만들 가능성이 있다.
function ProductPage({ product, addToCart }) {
// 🔴 Avoid: Event-specific logic inside an Effect
// 🔴 이러지 마세요: Effect 내부에 특정 이벤트에 대한 로직 존재
useEffect(() => {
if (product.isInCart) {
showNotification(`Added ${product.name} to the shopping cart!`);
}
}, [product]);
function handleBuyClick() {
addToCart(product);
}
function handleCheckoutClick() {
addToCart(product);
navigateTo('/checkout');
}
// ...
}
어떤 코드가 Effect에 있어야 하는지 이벤트 핸들러에 있어야 하는지 확실치 않은 경우, 이 코드가 실행되어야 하는 이유를 자문해 보자.
컴포넌트가 사용자에게 표시되었기 때문에 실행되어야 하는 코드에만 Effect를 사용하는게 좋다.
사용자가 버튼을 눌렀기 때문에 => 알림이 표시되는 상황이므로, 공유로직을 공통함수로 빼는게 맞다.
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');
}
// ...
}
GOOD
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 });
}
// ...
}
state를 바탕으로 또 다른 state를 조정하고 싶을때 => Effect를 사용하고 싶을 수도 있다.
state 자체는 비동기적으로 동작하기 때문에 ... => useEffect를 해결해서 연쇄적으로 로직을 작성하고 싶을 때
Remind
- React는 setState가 연속으로 호출된다면 Batch Update를 통해 16ms 마다 이를 한 번에 렌더링한다. 즉, useState는 동기적이 아닌 비동기적으로 작동하는 것이다. 이렇게 동작하는 이유는, 불필요한 랜더링을 방지하여 성능을 향상하기 위함도 있고, 만일 useState가 동기적으로 동작 시 디버깅에 큰 어려움이 야기될 수 있기 때문이다.
BAD
function Game() {
const [card, setCard] = useState(null);
const [goldCardCount, setGoldCardCount] = useState(0);
const [round, setRound] = useState(1);
const [isGameOver, setIsGameOver] = useState(false);
// 🔴 Avoid: Chains of Effects that adjust the state solely to trigger each other
// 🔴 이러지 마세요: 오직 서로를 촉발하기 위해서만 state를 조정하는 Effect 체인
useEffect(() => {
if (card !== null && card.gold) {
setGoldCardCount(c => c + 1);
}
}, [card]); // 2
useEffect(() => {
if (goldCardCount > 3) {
setRound(r => r + 1)
setGoldCardCount(0);
}
}, [goldCardCount]); //3
useEffect(() => {
if (round > 5) {
setIsGameOver(true);
}
}, [round]); //4
useEffect(() => {
alert('Good game!');
}, [isGameOver]); //5
function handlePlaceCard(nextCard) {
if (isGameOver) {
throw Error('Game already ended.');
} else {
setCard(nextCard); //1
}
}
// ...
function Game() {
const [card, setCard] = useState(null);
const [goldCardCount, setGoldCardCount] = useState(0);
const [round, setRound] = useState(1);
// ✅ Calculate what you can during rendering
// ✅ 가능한 것을 렌더링 중에 계산
const isGameOver = round > 5;
function handlePlaceCard(nextCard) {
if (isGameOver) {
throw Error('Game already ended.');
}
// ✅ Calculate all the next state in the event handler
// ✅ 이벤트 핸들러에서 다음 state를 모두 계산
setCard(nextCard);
if (nextCard.gold) {
if (goldCardCount <= 3) {
setGoldCardCount(goldCardCount + 1);
} else {
setGoldCardCount(0);
setRound(round + 1);
if (round === 5) {
alert('Good game!');
}
}
}
}
// ...
애플리케이션이 mount될때만 실행되어야 하는 로직이 존재한다고 가정
STRICT모드에서 => Development환경일때 컴포넌트를 명시적으로 다시 마운트함. PROD 환경에서는 아님
function App() {
// 🔴 Avoid: Effects with logic that should only ever run once
// 🔴 이러지 마세요: 한 번만 실행되어야 하는 로직이 포함된 Effect
useEffect(() => {
loadDataFromLocalStorage();
checkAuthToken();
}, []);
// ...
}
어떻게 Effect가 다시 마운트된 후에도 작동하도록 고칠 것인가?
let didInit = false;
function App() {
useEffect(() => {
if (!didInit) {
didInit = true;
// ✅ 앱 로드당 한 번만 실행
loadDataFromLocalStorage();
checkAuthToken();
}
}, []);
// ...
}
-2. clean up 구현
useEffect(() => {
const dialog = dialogRef.current;
dialog.showModal();
return () => dialog.close();
}, []);
function useOnlineStatus() {
// 이상적이지 않습니다: Effect에서 저장소를 수동으로 구독
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
function updateState() {
setIsOnline(navigator.onLine);
}
updateState();
window.addEventListener('online', updateState);
window.addEventListener('offline', updateState);
return () => {
window.removeEventListener('online', updateState);
window.removeEventListener('offline', updateState);
};
}, []);
return isOnline;
}
function ChatIndicator() {
const isOnline = useOnlineStatus();
// ...
}
ref) https://ko.react.dev/reference/react/useSyncExternalStore
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?)
외부 store 구독 (state, prop, context가 아닌경우)
브라우저 API 구독
custom Hook으로 로직 추출하기
서버 렌더링 지원 추가 할 시에 사용한다.
subscribe : callback, store를 구독하고 구독을 취소하는 함수를 반환해야 합니다.
getSnapshot : store 데이터의 스냅샷을 반환하는 함수
function subscribe(callback) {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
}
function useOnlineStatus() {
// ✅ 좋습니다: 내장 Hook으로 외부 스토어 구독하기
return useSyncExternalStore(
subscribe, // 동일한 함수를 전달하는 한 React는 다시 구독하지 않습니다.
() => navigator.onLine, // 클라이언트에서 값을 얻는 방법
() => true // 서버에서 값을 얻는 방법
);
}
function ChatIndicator() {
const isOnline = useOnlineStatus();
// ...
}
- useEffect 사용을 최소화하는것이 좋다. useEffect 없이 로직을 실행 할 수있는지 먼저 생각해보자
- 상호작용에 대한 처리는 이벤트 핸들러로, 초기화에 대한 처리는 key로!
- 각각의 이벤트 핸들러에 대한 함수 작성은 순수함수를 통해 작성해야 재사용성이 높아진다.
- 컴포넌트에 따른 작용이라면 useEffect로 작성
- useEffect안에 들어가는 로직은 순수함수이여야 한다.(deps에 대해 동일한 결과를 반환하도록)
렌더링 중에 무언가를 계산할 수 있다면 Effect가 필요하지 않습니다.
비용이 많이 드는 계산을 캐시하려면 useEffect 대신 useMemo를 추가하세요.
전체 컴포넌트 트리의 state를 초기화하려면 다른 key를 전달하세요.
prop 변경에 대한 응답으로 특정 state bit를 초기화하려면 렌더링 중에 설정하세요.
컴포넌트가 표시되어 실행되는 코드는 Effect에 있어야 하고 나머지는 이벤트에 있어야 합니다.
여러 컴포넌트의 state를 업데이트해야 하는 경우 단일 이벤트 중에 수행하는 것이 좋습니다.
다른 컴포넌트의 state 변수를 동기화하려고 할 때마다 state 끌어올리기를 고려하세요.
Effect로 데이터를 가져올 수 있지만 경쟁 조건을 피하기 위해 정리를 구현해야 합니다.
ref)
https://ko.react.dev/learn/you-might-not-need-an-effect
https://ko.react.dev/learn/render-and-commit
https://chamdom.blog/how-useeffect-is-executed/