아래 두 자료를 공부하고 정리한 내용입니다!
Effect가 필요하지 않을 수도 있습니다 – React
The library for web and native user interfaces
ko.react.dev
Goodbye, useEffect - David Khourshid
useEffect 사용의 문제점
1) 코드의 맥락을 따라가기 어려움
2) 실행 속도가 불필요하게 느려질 수 있음
3) 에러 발생 가능성 증가 (복잡한 의존성 배열 등)
4) Strict 모드에서 이중으로 실행되는 문제
useEffect의 대안
1) 이벤트 핸들러 사용
- 폼 제출과 같은 간단한 이벤트는 이벤트 핸들러 내에서 직접 처리
2) 외부 라이브러리 사용
- 데이터 패칭 및 상태 관리를 위해 React Query, Remix, Next.js 등을 활용
Effect가 필요없는 일반적인 상황
렌더링을 위해 데이터를 변환하는 경우
- 리스트를 표시하기 전에 필터링을 해야하는 경우, 리스트가 변경될 때 state변수를 업데이트하는 Effect를 작성하고 싶을 수 있음
- 하지만 이는 비효율적!!
- state를 업데이트하면 React는 먼저 컴포넌트 함수를 호출해 화면에 표시될 내용을 계산하고, 변경사항을 DOM에 반영하여 화면을 업데이트함
- 그 후 React가 Effect를 실행
- 즉, 만약 Effect도 state를 즉시 업데이트한다면 변경된 state를 반영하여 렌더링하는 과정을 두번씩 하게되는 것
- 불필요한 렌더링 패스를 피하려면, 컴포넌트의 최상위 레벨에서 모든 데이터를 변환하자
-> 내 sortOption 변경시 sortData해주는 useEffect 이 경우 맞는지 다시 한 번 살펴보기
사용자 이벤트를 처리하는 경우
- 사용자가 제품을 구매할 때 /api/buy POST 요청을 전송하고 알림을 표시하는 기능을 만들고 싶음
- 구매 버튼 클릭 이벤트 핸들러에서는 정확히 어떤 일이 일어났는지 알 수 있음
- Effect가 실행될 때까지 사용자가 무엇을 했는지(ex. 어떤 버튼을 클릭했는지) 알 수 없음
- 따라서 Effect가 아닌 해당하는 이벤트 핸들러에서 사용자 이벤트를 처리해야 함
<button onClick={clickHandler}></button>
const [isClick, setIsClick] = useState(false);
// 이렇게 하지 마라
const clickHandler = () => {
setIsClick(true);
};
useEffect(() => {
if(isClick) {
// API POST 요청 전송
setIsClick(false);
}
}, [isClick]);
// 이렇게 해라
const clickHandler = () => {
// API POST 요청 전송
};
props 또는 state에 따라 state 업데이트하기
예시 기능
✔︎ firstName, lastName 두개의 state 변수가 있다고 가정할 때 두 변수를 연결해서 fullName을 계산
✔︎ firstName, lastName이 업데이트 될 때마다 fullName이 업데이트됨
😈 피해야할 방법
- fullName state를 추가하고 Effect에서 의존성배열에 firstName, lastName을 담아 업데이트하기
const Form() => {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(firstName + ' ' + lastName);
}, [firstName, lastName]);
};
- setFirstName or setLastName 으로 인한 리렌더링 총 1회
- 이후 useEffect가 실행되며 setFullName으로 리렌더링 총 2회
🥳 개선된 방법
const Form() => {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
const fullName = firstName + ' ' + lastName;
};
- setFirstName 혹은 setLastName이 실행되면서 Form이 리렌더링 될 것이고 그 과정에서 fullName이 계산되어 업데이트 됨!!
- 리렌더링 총 1회
💡 고려할 부분
✔︎ 기존 props나 state에서 계산할 수 있다면 state에 넣지 않고 렌더링 중에 계산해야 함
- 연속적인 업데이트를 피할 수 있다
- 더 간단해진다
- 에러가 덜 발생한다
비용이 많이 드는 계산 캐싱하기
예시 기능
✔︎ props로 받은 todo를 filter에 따라 필터링하여 visibleTodos를 계산
😈 피해야할 방법
- visibleTodos라는 state를 만들고, useEffect로 todo or filter가 변경될 때를 감지하여 visibleTodos를 업데이트하기
const TodoList({todos, filter }) {
const [newTodo, setNewTodo] = useState('');
const [visibleTodos, setVisibleTodos] = useState([]);
useEffect(() => {
setVisibleTodos(getFilteredTodos(todos, filter));
}, [todos, filter]);
};
- 위의 예시와 마찬가지로 visibleTodos는 todos, filter로 계산 가능하므로 state로 만들 필요가 없음!
🥳 개선된 방법 1
const TodoList({todos, filter }) {
const [newTodo, setNewTodo] = useState('');
const visibleTodos = getFilteredTodos(todos, filter);
};
- getFilteredTodos()와 todos가 처리가 느려질만큼 복잡하고, 많은 양이 아니라면 좋은 방법!
- 하지만 이 방법은 newTodo가 새로운 visibleTodos를 받는 것과는 상관없더라도 다시 계산하게 됨
🥳 개선된 방법 2
const TodoList({todos, filter }) {
const [newTodo, setNewTodo] = useState('');
const visibleTodos = useMemo(() => {
return getFilteredTodos(todos, filter);
}, [todos, filter]);
};
- useMemo를 이용해 값비싼 계산을 메모이제이션 하는 방식
- 의존성배열에 포함된 todos, filter가 변경되지 않는다면 getFilteredTodos를 실행하지 않는다!
- 따라서 렌더링 시에 계산됨 + todos, filter가 바꼈을 때에만 계산됨 두 조건을 모두 만족시킬 수 있음
💡 고려할 부분 - 값비싼 계산이란?
- 수천개의 객체를 만들거나, 수천번 반복하는 경우가 아니라면 비용이 많이 들지 않는다!
- 확인하고 싶은 경우 console.time -> console.timeEnd로 실행 시간을 측정할 수 있음
console.time('filter array');
const visibleTodos = getFilteredTodos(todos, filter);
console.timeEnd('filter array');
- 기록된 시간이 1ms 이상으로 합산되는 경우 메모이제이션을 활용하는 것이 좋음
- 사용자의 컴퓨터가 더 성능이 좋지 않을 것을 고려해 테스트하고 싶다면 CPU 스로틀링을 사용할 수 있음
Effect 없이 컴포넌트 state를 초기화하고 조정하는 방법
props 변경 시 모든 state 초기화
예시 기능
✔︎ 특정 유저의 프로필 페이지를 보여주는 컴포넌트
✔︎ userId를 props로 받고, state를 이용해 comment를 입력받음
✔︎ 다른 유저의 페이지로 이동시 comment를 빈 변수로 초기화시키려 함
😈 피해야할 방법
export default const ProfilePage({userId}) {
const [comment, setComment] = useState('');
useEffect(() => {
setComment('')
}, [userId]);
};
- 동일한 컴포넌트가 같은 위치에 렌더링되면서 state를 보존함
- ProfilePage와 그 자식 컴포넌트들이 모두 기존 값으로 렌더링 되었다가 Comment를 초기화하여 다시 렌더링함
- 댓글 UI가 중첩된 경우 중첩된 댓글 state도 비워야 함
🥳 개선된 방법 1
- 명시적인 key를 전달하여 각 사용자의 프로필이 개념적으로 다른 프로필임을 React에 알리기
export default const ProfilePage = ({userId}) => {
return (
<Profile
userId = {userId}
key = {userId}
/>
);
};
const Profile = (userId) => {
const [comment, setComment] = useState('');
};
- userId를 key로 전달해 두 Profile 컴포넌트가 state를 공유해선 안되는 두개의 다른 컴포넌트로 취급하도록 요청
- key가 변경될 때마다 React는 DOM 을 다시 생성하고, 모든 state를 재설정(초기화)하게 됨
props 변경 시 일부 state 조정
예시 기능
✔︎ items를 props로 받아 selection state에 선택된 item을 보관
✔︎ items prop이 변경될 때마다 selection을 null로 재설정
😈 피해야할 방법
const List = ({items}) => {
const [isReverse, setIsReverse] = useState(false);
const [selection, setSelection] = useState(null);
useEffect(() => {
setSelection(null);
}, [items]);
};
- 마찬가지로 selection을 null로 만들기 위해 두번의 리렌더링을 거쳐야 함
🥳 개선된 방법 1
const List = ({items}) => {
const [isReverse, setIsReverse] = useState(false);
const [selection, setSelection] = useState(null);
const [prevItems, setPrevItems] = useState(items);
if(items !== prevItems) {
setPrevItems(itmes);
setSelection(null);
}
};
- List가 렌더링될 때 state를 조정할 수 있음
setSelection이 두번 일어나는 건 마찬가지인데 왜 useEffect썼을 때랑 차이가 생기는 걸까???
렌더링 확인을 위해 간단히 테스트해봄 (strict mode 제거)
import { useEffect, useState } from "react";
function App() {
const [items, setItems] = useState(0);
return (
<>
<button
type="button"
onClick={() => {
setItems(items + 1);
}}
>
setItme!
</button>
<List items={items} />
</>
);
}
function List({ items }) {
console.log("List 렌더링");
const [selection, setSelection] = useState(0);
// 개선안 사용하는 경우
const [prevItems, setPrevItems] = useState(items);
if (items !== prevItems) {
console.log("prevItem과 비교");
setPrevItems(items);
setSelection(selection + 1);
}
// useEffect 사용하는 경우
useEffect(() => {
console.log("useEffect 실행");
setSelection(selection + 1);
}, [items]);
return (
<>
<div>selection {selection}</div>
<ChildComponent selection={selection} />
</>
);
}
function ChildComponent({ selection }) {
console.log("ChildComponent 렌더링");
return <div>ChildComponent {selection}</div>;
}
export default App;
변경사항이 있는 모든 하위 컴포넌트까지 렌더링 후 useEffect 실행 -> 또다시 필요한 렌더링 반복
return 문이 종료된 후 바로 다시 List 리렌더링 -> 그 후 ChildComponent까지 렌더링
💡 고려할 부분
- 이 방법은 useEffect보다는 효율적이지만 대부분의 경우에서는 필요하지 않다!
- key를 이용해 모든 state를 초기화하거나 렌더링 중에 state를 계산할 수 있는지 먼저 확인하는 것이 필요함
🥳 개선된 방법 2
const List = ({items}) => {
const [isReverse, setIsReverse] = useState(false);
const [selectedId, setSelectedId] = useState(null);
const selection = items.find(item => item.id === selectedId) ?? null;
};
- 선택을 할 때 선택된 아이템의 id를 state로 저장해두고, item에서 해당하는 아이템이 있으면 유지, 없는 경우 null로 바꾸는 방법
- 복잡하지 않고 렌더링 중에 모든 계산이 완료되므로 가장 권장됨
이벤트 핸들러 간에 로직을 공유하는 방법
사용자의 입력에 의한 경우
예시 기능
✔︎ 제품을 구매할 수 있는 두개의 버튼(구매, 결제)가 있는 제품 페이지
✔︎ 사용자가 제품을 장바구니에 넣을 때마다 알림을 표시
😈 피해야할 방법
function ProductPage({ product, addToCart }) {
useEffect(() => {
if (product.isInCart) {
showNotification(`Added ${product.name} to the shopping cart!`);
}
}, [product]);
function handleBuyClick() {
addToCart(product);
}
function handleCheckoutClick() {
addToCart(product);
navigateTo('/checkout');
}
// ...
}
- product page에서 product가 변할 때마다 showNotification을 호출하는 방식
- 만약 이 페이지를 새로고침하는 경우 장바구니 속 목록이 유지되는 한 계속해서 알림이 표시
🥳 개선된 방법
function ProductPage({ product, addToCart }) {
function buyProduct() {
addToCart(product);
showNotification(`Added ${product.name} to the shopping cart!`);
}
function handleBuyClick() {
buyProduct();
}
function handleCheckoutClick() {
buyProduct();
navigateTo('/checkout');
}
// ...
}
- 두 이벤트 핸들러에 showNotification과 공통 로직을 공유할 수 있도록 함수를 만들어주는 것이 맞는 방법
💡 고려할 부분
- 실행하고자 하는 로직 (현재는 알림을 띄우는 것)이 사용자의 입력 등에 의한 것인지, 컴포넌트가 사용자에게 표시되었기 때문에 실행되어야 하는 코드인지 고려해야 함!
컴포넌트가 사용자에게 표시되었기 때문에 실행될 동작
예시 기능
✔︎ 마운트 될 때 analytics 이벤트 전송 (POST)
✔︎ 폼을 작성하고 Submit 버튼을 누르는 경우 /api/register 엔드포인트로 POST 요청 전송
😈 피해야할 방법
function Form() {
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
// 컴포넌트가 마운트 되었을 때 실행돼야 함
useEffect(() => {
post('/analytics/event', { eventName: 'visit_form' });
}, []);
const [jsonToSubmit, setJsonToSubmit] = useState(null);
// 사용자가 submit 버튼을 눌렀을 때 실행할 동작
useEffect(() => {
if (jsonToSubmit !== null) {
post('/api/register', jsonToSubmit);
}
}, [jsonToSubmit]);
function handleSubmit(e) {
e.preventDefault();
setJsonToSubmit({ firstName, lastName });
}
// ...
}
- 컴포넌트가 마운트 되었을 때 실행해야할 동작에서는 useEffect를 활용한 것이 좋은 선택!
- 하지만 사용자 입력에 의해 실행할 동작에서는 useEffect를 활용하지 않는 것이 좋음
- 따라서 아래 동작은 이벤트 핸들러에서 처리되어야 함!!
🥳 개선된 방법
function Form() {
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
useEffect(() => {
post('/analytics/event', { eventName: 'visit_form' });
}, []);
function handleSubmit(e) {
e.preventDefault();
post('/api/register', { firstName, lastName });
}
// ...
}
💡 고려할 부분
✔︎ 이벤트 핸들러에 들어갈 로직: 상호작용에 의해 발생하는 경우
✔︎ Effect에 들어갈 로직: 사용자가 화면에서 컴포넌트를 보는 것이 원인인 경우
부모 컴포넌트에 변경 사항을 알리는 방법
자식 컴포넌트의 state 변경을 부모 컴포넌트에 알리기
예시 기능
✔︎ true or false로 변환될 state isOn이 있는 Toggle 컴포넌트
✔︎ isOn이 변경될 때마다 부모 컴포넌트의 onChange를 호출
😈 피해야할 방법
function Toggle({ onChange }) {
const [isOn, setIsOn] = useState(false);
useEffect(() => {
onChange(isOn);
}, [isOn, onChange])
function handleClick() {
setIsOn(!isOn);
}
function handleDragEnd(e) {
if (isCloserToRightEdge(e)) {
setIsOn(true);
} else {
setIsOn(false);
}
}
// ...
}
- setIsOn이 실행되면서 Toggle 컴포넌트 리렌더링
- useEffect 가 호출되면서 onChagne 호출하면서 부모 컴포넌트 리렌더링
🥳 개선된 방법 1
function Toggle({ onChange }) {
const [isOn, setIsOn] = useState(false);
function updateToggle(nextIsOn) {
setIsOn(nextIsOn);
onChange(nextIsOn);
}
function handleClick() {
updateToggle(!isOn);
}
function handleDragEnd(e) {
if (isCloserToRightEdge(e)) {
updateToggle(true);
} else {
updateToggle(false);
}
}
// ...
}
- 두 컴포넌트의 업데이트를 한번에 처리함
- 변화한 부분이 일괄처리되어 한번만 리렌더링
🥳 개선된 방법 2
function Toggle({ isOn, onChange }) {
function handleClick() {
onChange(!isOn);
}
function handleDragEnd(e) {
if (isCloserToRightEdge(e)) {
onChange(true);
} else {
onChange(false);
}
}
// ...
}
- 부모 컴포넌트에서 onChange, isOn을 관리하도록 변경한 방법
부모에게 데이터 전달하기
예시 기능
✔︎ 자식 컴포넌트에서 API fetch 기능을 수행 -> 부모 컴포넌트의 state에 저장해야 하는 경우
😈 피해야할 방법
function Parent() {
const [data, setData] = useState(null);
// ...
return <Child onFetched={setData} />;
}
function Child({ onFetched }) {
const data = useSomeAPI();
useEffect(() => {
if (data) {
onFetched(data);
}
}, [onFetched, data]);
// ...
}
- 자식에게 내려준 setData를 이용해 fetch한 데이터를 부모 컴포넌트의 state에 전달
- 자식 컴포넌트가 Effect를 활용해 부모 컴포넌트로 state를 업데이트하는 경우 데이터 흐름을 추적하기 어려움
- 자식과 부모 모두 동일한 데이터가 필요한 경우이므로, 부모에서 데이터를 fetch 후 자식에게 내려주는 방식이 더 권장됨!!
🥳 개선된 방법
function Parent() {
const data = useSomeAPI();
// ...
return <Child data={data} />;
}
function Child({ data }) {
// ...
}
- 부모 컴포넌트에서 fetch 후 자식에게 props로 전달
외부 저장소 구독하기
예시 기능
✔︎ React state가 아닌 외부 저장소의 데이터를 구독하는 경우
✔︎ 외부에서 데이터가 변경되더라도 받아올 수 있도록 해야 함
😈 피해야할 방법 - Effect 활용
function useOnlineStatus() {
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();
// ...
}
- 첫 마운트 시 navigator.onLine의 온/오프라인 상태에따라 updateState로 isOnline을 을 셋팅할 수 있는 이벤트 등록
- 언마운트 될 때 이벤트도 해제됨
- 일반적으로 위와 같이 Effect를 사용하지만, React에는 외부 저장소를 구독하기 위한 Hook이 있음!
🥳 개선된 방법 - useSyncExternalStore
function subscribe(callback) {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
}
function useOnlineStatus() {
return useSyncExternalStore(
subscribe, // 동일한 함수를 전달하는 한 React는 다시 구독하지 않습니다.
() => navigator.onLine, // 클라이언트에서 값을 얻는 방법
() => true // 서버에서 값을 얻는 방법
);
}
function ChatIndicator() {
const isOnline = useOnlineStatus();
// ...
}
- useSyncExternalStore 이용해 온/오프라인 상태에 따라 navigator.onLine에서 데이터를 받아오거나 상태를 알리는 callback을 이벤트를 등록함
- navigator.onLine의 상태가 변경되면 callback이 호출되며 컴포넌트는 리렌더링 됨
위 예제의 callback은?
subscribe의 매개변수로 전달된, 이벤트로 등록된 callback의 존재는 무엇일까?
이는 React가 내부적으로 관리하는 함수로 외부 데이터 소스의 변경을 React에게 알리는 역할을 한다.
- useSyncExternalSotre 훅을 실행할 때 콜백함수를 자동으로 생성
- 이 콜백이 호출될 때 React는 컴포넌트의 리렌더링을 트리거
- 메달 트래커 예제에는 적합하지 않은 것 같음
이유: 외부에서 계속 값이 변경되고, 그걸 받아와서 사용하는 것이 아니라 마운트시 한번 불러오고 사용자 조작으로 값이 변경되기 때문
데이터 fetch
- fetch를 위해 Effect를 사용하는 것은 일반적이지만 주의할 사항들이 있음
😈 피해야할 방법
function SearchResults({ query }) {
const [results, setResults] = useState([]);
const [page, setPage] = useState(1);
useEffect(() => {
fetchResults(query, page).then(json => {
setResults(json);
});
}, [query, page]);
function handleNextPageClick() {
setPage(page + 1);
}
// ...
}
- 데이터 fetch는 주된 이유가 입력 이벤트가 아니기에 이를 이벤트 핸들러로 옮길 필요는 없음
- 하지만 위 예제처럼 클릭 이벤트가 아닌 input의 onChange에 의한 경우라면 "h", "he", "hel", ... , "hello"에 대한 fetch가 각각 진행되지만 최종적으로 어떤 데이터가 도착할지는 알 수 없다!!
- 이를 경쟁조건 이라고 하는데, useEffect에서 fetch를 위해선 위 예시 코드에서 끝나는 것이 아니라 경쟁을 정리하는 함수를 추가해야 함
🥳 개선된 방법 - 정리 함수 추가
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 내부의 return () => {} 이 정리함수!
- useEffect의 콜백함수가 호출되는 다음 시점에 이전 Effect의 정리함수가 실행됨
- 빠르게 입력을 했을 경우 마지막 입력에 대한 응답보다 이전 응답이 늦게 fetch 되더라도 이미 정리함수로 ignore 는 true로 바꼈으므로 setResult로 진입하기 위한 조건을 만족시키지 못해 응답이 무시된다!!!!
정리 함수
리액트의 정리함수 호출 시점
1) 컴포넌트가 언마운트 될 때
2) 의존성 배열의 값이 변경되어 Effect가 다시 실행되기 직전
실행 순서
1) 이전 효과의 정리 함수 실행
2) 새로운 효과 실행
'React' 카테고리의 다른 글
[React + Supabase] 설치, 셋팅, 간단한 사용법 (0) | 2024.08.20 |
---|---|
[React] react-router-dom (0) | 2024.08.20 |
[React] useSyncExternalStore (0) | 2024.08.18 |
[React] memoization (0) | 2024.08.17 |
[React] useContext (0) | 2024.08.16 |