Escape Hatches : Effect가 필요하지 않을 수도 있다
이펙트는 React 패러다임에서 벗어날 수 있는 탈출구입니다.
이펙트를 사용하면 React를 "외부로" 나가서 컴포넌트를 React가 아닌 위젯, 네트워크 또는 브라우저 DOM과 같은 외부 시스템과 동기화할 수 있습니다. 외부 시스템이 관여하지 않는 경우(예: 일부 props나 state가 변경될 때, 컴포넌트의 state를 업데이트하려는 경우)에는 Effect가 필요하지 않습니다. 불필요한 이펙트를 제거하면 코드를 더 쉽게 따라갈 수 있고, 실행 속도가 빨라지며, 오류 발생률이 줄어듭니다.
학습 내용
- 컴포넌트에서 불필요한 Effects를 제거하는 이유와 방법
- Effects 없이 값비싼 연산을 캐시하는 방법
- Effects 없이 컴포넌트 상태를 리셋하고 조정하는 방법
- 이벤트 핸들러 간에 로직을 공유하는 방법
- 이벤트 핸들러로 옮겨야 하는 로직
- 부모 컴포넌트에 변경 사항을 알리는 방법
불필요한 Effects를 제거하는 방법
Effects가 필요하지 않은 일반적인 두가지 경우가 있습니다:
렌더링을 위해 데이터를 변환하는 데 Effects가 필요하지 않습니다.
예를 들어 목록을 표시하기 전에 필터링하고 싶다고 가정해 보겠습니다. 목록이 변경될 때 상태 변수를 업데이트하는 Effects를 작성하고 싶을 수 있습니다. 하지만 이는 비효율적입니다. 컴포넌트의 state를 업데이트할 때 React는 먼저 컴포넌트 함수를 호출해 화면에 표시될 내용을 계산합니다. 그런 다음 React는 이러한 변경 사항을 DOM에 "커밋"하여 화면을 업데이트합니다. 그런 다음 React가 이펙트를 실행합니다. 만약 이펙트도 즉시 상태를 업데이트한다면 전체 프로세스가 처음부터 다시 시작됩니다! 불필요한 렌더 과정을 피하려면 컴포넌트의 최상위 수준에서 모든 데이터를 변환하세요. 그러면 props나 state가 변경될 때마다 해당 코드가 자동으로 다시 실행됩니다.
사용자 이벤트를 처리하는 데 Effects는 필요하지 않습니다.
예를 들어 사용자가 제품을 구매할 때 /api/buy POST 요청을 전송하고 알림을 표시하고 싶다고 가정해 보겠습니다. 구매 버튼 클릭 이벤트 핸들러에서 정확히 어떤 일이 일어났는지 알 수 있습니다. Effect가 실행될 때까지는 사용자가 무엇을 했는지(예: 어떤 버튼을 클릭했는지) 알 수 없습니다. 그렇기 때문에 일반적으로 해당 이벤트 핸들러에서 사용자 이벤트를 처리합니다.
외부 시스템과 동기화하려면 Effect가 필요합니다.
예를 들어 jQuery 위젯을 React 상태와 동기화하는 Effect를 작성할 수 있습니다. 예를 들어 검색 결과를 현재 검색 쿼리와 동기화할 수 있습니다. 최신 프레임워크는 컴포넌트에 직접 Effect를 작성하는 것보다 더 효율적인 내장 데이터 불러오기 메커니즘을 제공한다는 점을 명심하세요.
확실히 이해하는데 도움이 되도록, 몇 가지 일반적인 구체적인 예를 살펴보겠습니다!
Props 또는 State에 따른 상태 업데이트
첫 번째 이름과 마지막 이름이라는 두 개의 상태 변수가 있는 컴포넌트가 있다고 가정해 보겠습니다. 두 변수를 연결하여 전체 이름을 계산하고 싶습니다. 또한, 첫 번째 이름이나 마지막 이름이 변경될 때마다 전체 이름이 업데이트되기를 원합니다. 가장 먼저 생각나는 것은 fullName 상태 변수를 추가하고 Effect에서 업데이트하는 것일 수 있습니다:
function Form() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
// 🔴 피하기: 중복 상태 및 불필요한 효과
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(firstName + ' ' + lastName);
}, [firstName, lastName]);
// ...
}
이는 필요 이상으로 복잡하고 비효율적입니다.
전체 렌더링 과정에서 fullName에 대한 stale값을(*업데이트 되지않은 value) 사용한 다음, 업데이트된 값으로 즉시 다시 렌더링합니다. state 변수와 Effect를 모두 제거하세요:
function Form() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
// ✅ 좋음: 렌더링 중에 계산됨
const fullName = firstName + ' ' + lastName;
// ...
}
기존의 props나 state에서 계산할 수 있는 것이 있으면 state에 넣지 마세요. 대신 렌더링 중에 계산하세요. 이렇게 하면 코드가 더 빨라지고(추가적인 "계단식" 업데이트를 피할 수 있으며), 더 간단해지고(일부 코드를 제거할 수 있으며), 오류가 덜 발생합니다(서로 다른 상태 변수가 서로 동기화되지 않아 발생하는 버그를 피할 수 있습니다). 이 접근 방식이 생소하게 느껴진다면 [빠르게 시작하기 > 리액트로 사고하기] 에 무엇이 state에 들어가야 하는지에 대한 몇 가지 지침이 나와 있습니다.
비용이 많이 드는 계산 캐싱
이 컴포넌트는 prop으로 받은 할일을 'filter' prop에 따라 필터링하여 가시적인 할일을 계산합니다.
결과를 상태 변수에 저장하고 Effect에서 업데이트하고 싶을 수 있습니다:
function TodoList({ todos, filter }) {
const [newTodo, setNewTodo] = useState('');
// 🔴 피하기: 중복 상태 및 불필요한 효과
const [visibleTodos, setVisibleTodos] = useState([]);
useEffect(() => {
setVisibleTodos(getFilteredTodos(todos, filter));
}, [todos, filter]);
// ...
}
앞의 예시에서와 마찬가지로 이것은 불필요하고 비효율적입니다. 먼저 state와 Effect를 제거합니다:
function TodoList({ todos, filter }) {
const [newTodo, setNewTodo] = useState('');
// ✅ getFilteredTodos()가 느리지 않다면 괜찮습니다.
const visibleTodos = getFilteredTodos(todos, filter);
// ...
}
대부분의 경우, 이 코드는 괜찮습니다! 하지만 getFilteredTodos()가 느리거나 할 일이 많을 수도 있습니다.
이 경우 새로운 할일과 같이 관련 없는 상태 변수가 변경된 경우 getFilteredTodos()를 다시 계산하고 싶지 않을 수 있습니다.
useMemo Hook으로 감싸서 값비싼 계산을 캐시(또는 "메모화")할 수 있습니다:
import { useMemo, useState } from 'react';
function TodoList({ todos, filter }) {
const [newTodo, setNewTodo] = useState('');
const visibleTodos = useMemo(() => {
// ✅ todos 또는 filter가 변경되지 않는 한 다시 실행되지 않습니다.
return getFilteredTodos(todos, filter);
}, [todos, filter]);
// ...
}
또는 한 줄로 작성할 수도 있습니다:
import { useMemo, useState } from 'react';
function TodoList({ todos, filter }) {
const [newTodo, setNewTodo] = useState('');
// ✅ todos나 filter가 변경되지 않는 한 getFilteredTodos()를 다시 실행하지 않습니다.
const visibleTodos = useMemo(() => getFilteredTodos(todos, filter), [todos, filter]);
// ...
}
이렇게 하면 todos나 filter가 변경되지 않는 한 내부 함수가 다시 실행되지 않기를 원한다는 것을 React에 알립니다.
React는 초기 렌더링 중에 getFilteredTodos()의 반환값을 기억합니다. 다음 렌더링 중에 todos나 filter가 다른지 확인합니다. 지난번과 동일하다면, useMemo는 마지막으로 저장한 결과를 반환합니다. 하지만 다르다면 React는 래핑된 함수를 다시 호출합니다(그리고 그 결과를 다시 저장합니다.)
useMemo로 래핑한 함수는 렌더링 중에 실행되므로 순수한 계산에만 작동합니다.
DEEP DIVE - 계산이 비싼지(오래 걸리는지) 어떻게 알 수 있나요?
일반적으로 수천 개의 개체를 만들거나 반복하는 경우가 아니라면 비용이 많이 들지 않을 것입니다.
좀 더 확신을 얻고 싶다면 콘솔 로그를 추가하여 코드에 소요된 시간을 측정할 수 있습니다:
console.time('filter array');
const visibleTodos = getFilteredTodos(todos, filter);
console.timeEnd('filter array');
측정하려는 인터랙션을 수행합니다(예: input에 타이핑). 그러면 필터 배열과 같은 로그가 표시됩니다: 0.15ms와 같은 로그가 콘솔에 표시됩니다. 전체적으로 기록된 시간이 상당한 양(예: 1ms 이상)으로 합산되면 해당 계산을 memo해 두는 것이 좋습니다. 그런 다음 실험으로 해당 계산을 useMemo로 감싸서 해당 상호작용에 대해 총 로깅 시간이 감소했는지 여부를 확인할 수 있습니다:
console.time('filter array');
const visibleTodos = useMemo(() => {
return getFilteredTodos(todos, filter); // todos와 filter가 변경되지 않은 경우 건너뛰기
}, [todos, filter]);
console.timeEnd('filter array');
useMemo는 첫 번째 렌더링을 더 빠르게 만들지 않습니다. 업데이트 시 불필요한 작업을 건너뛰는 데만 도움이 됩니다.
당신의 컴퓨터가 사용자 컴퓨터보다 빠를 수 있으므로 인위적인 속도 저하로 성능을 테스트하는 것이 좋습니다. 예를 들어 Chrome은 이를 위해 CPU 스로틀링(https://developer.chrome.com/blog/new-in-devtools-61/#throttling) 옵션을 제공합니다.
또한 개발 중에 성능을 측정하는 것은 언제나 정확한 결과를 제공하지는 않는다는 점에 유의하세요. (예를 들어 strict mode를 켜면 각 컴포넌트가 한 번이 아닌 두 번 렌더링되는 것을 볼 수 있습니다.) 가장 정확한 타이밍을 얻으려면 프로덕션용 앱을 빌드하고 사용자가 사용하는 것과 같은 기기에서 테스트하세요.
props가 변경되면 모든 state 재설정
이 ProfilePage 컴포넌트는 userId prop을 받습니다. 페이지에는 댓글 입력이 포함되어 있으며, 댓글 state를 사용하여 해당 값을 보관합니다. 어느 날, 한 프로필에서 다른 프로필로 이동할 때 댓글 상태가 재설정되지 않는문제를 발견합니다. 그 결과 실수로 잘못된 사용자의 프로필에 댓글을 게시하기 쉽습니다. 이 문제를 해결하기위해 userId 변경될 때마다 comment 상태 변수를 지우려고 합니다:
export default function ProfilePage({ userId }) {
const [comment, setComment] = useState('');
// 🔴 피하기: Effect에서 prop 변경 시 상태 초기화
useEffect(() => {
setComment('');
}, [userId]);
// ...
}
이는 비효율적인데, ProfilePage와 그 자식들이 먼저 statle 값(업데이트 안된값)으로 렌더링한 다음 다시 렌더링하기 때문입니다. 또한 ProfilePage 내부에 어떤 상태를 가진 모든 컴포넌트에서 이 작업을 수행해야 하므로 복잡합니다. 예를 들어 댓글 UI가 중첩되어 있다면 중첩된 댓글 상태도 지워야 할 것입니다.
대신, 명시적인 키를 제공하여 각 사용자의 프로필이 개념적으로 다른 프로필임을 React에 알릴 수 있습니다. 컴포넌트를 둘로 분할하고 외부 컴포넌트에서 내부 컴포넌트로 키 속성을 전달하세요:
export default function ProfilePage({ userId }) {
return (
<Profile
userId={userId}
key={userId}
/>
);
}
function Profile({ userId }) {
// ✅ 이 밑에 선언된 모든 상태는 키 변경 시 자동으로 재설정됩니다.
const [comment, setComment] = useState('');
// ...
}
일반적으로 React는 동일한 컴포넌트가 같은 위치에 렌더링될 때 상태를 보존합니다. Profile 컴포넌트에 userId를 키로 전달하면, React가 userId가 다른 두 개의 Profile 컴포넌트를 상태를 공유해서는 안 되는 두 개의 다른 컴포넌트로 취급하도록 요청하는 것입니다. userId로 설정한 키가 변경될 때마다 React는 DOM을 다시 생성하고 Profile 컴포넌트와 그 모든 자식들의 상태를 재설정합니다. 그 결과, 프로필 사이를 이동할 때(변경할때) 코멘트 필드가 자동으로 지워집니다.
prop이 변경될 때 일부 상태 조정하기
때로는 prop 변경 시 상태의 일부를 재설정하거나 조정하고 싶지만 전부를 재설정하고 싶지는 않을 수 있습니다.
이 List 컴포넌트는 items를 prop으로 받고 selection 상태 변수에 선택된 아이템을 유지합니다. items prop이 다른 배열을 받을 때마다 선택 항목을 null로 재설정하고 싶습니다:
function List({ items }) {
const [isReverse, setIsReverse] = useState(false);
const [selection, setSelection] = useState(null);
// 🔴 피하기: Effect에서 prop 변경 시 state 조정
useEffect(() => {
setSelection(null);
}, [items]);
// ...
}
이것 역시 이상적이지 않습니다. 항목이 변경될 때마다 목록과 그 하위 컴포넌트는 처음에는 오래된 선택 값으로 렌더링됩니다. 그런 다음 React는 DOM을 업데이트하고 Effects를 실행합니다. 마지막으로, setSelection(null) 호출은 List와 그 하위 컴포넌트를 다시 렌더링하여 이 전체 프로세스를 다시 시작하게 됩니다.
Effect를 삭제하는 것으로 시작하세요. 대신 렌더링 중에 직접 상태를 조정하세요:
function List({ items }) {
const [isReverse, setIsReverse] = useState(false);
const [selection, setSelection] = useState(null);
// 이게 낫다: 렌더링 중 state 조정
const [prevItems, setPrevItems] = useState(items);
if (items !== prevItems) {
setPrevItems(items);
setSelection(null);
}
// ...
이렇게 이전 렌더링의 정보를 저장하는 것은 이해하기 어려울 수 있지만 Effects에서 동일한 상태를 업데이트하는 것보다 낫습니다. 위 예시에서는 렌더링 도중 setSelection이 직접 호출됩니다. React는 반환문과 함께 종료된 직후에 List를 다시 렌더링합니다. 이때까지 React는 아직 List 자식들을 렌더링하거나 DOM을 업데이트하지 않았기 때문에 오래된 선택 값의 렌더링을 건너뛸 수 있습니다.
렌더링 도중 컴포넌트를 업데이트하면 React는 반환된 JSX를 버리고 즉시 렌더링을 다시 시도합니다.
매우 느린 계단식 재시도를 피하기 위해 React는 렌더링 중에 동일한 컴포넌트의 상태만 업데이트할 수 있도록 합니다. 렌더링 도중 다른 컴포넌트의 상태를 업데이트하면 오류가 발생합니다. 루프를 피하려면 items !== prevItems와 같은 조건이 필요합니다. 이런 식으로 상태를 조정할 수 있지만, 컴포넌트를 예측할 수 있도록 유지하려면 이벤트 핸들러나 Effect에 DOM 변경이나 타임아웃 설정과 같은 다른 부작용이 남아있어야 합니다.
이 패턴이 Effect보다 더 효율적이지만 대부분의 컴포넌트에는 필요하지 않습니다. 어떻게 하든 prop이나 다른 state에 따라 상태를 조정하면 데이터 흐름을 이해하고 디버깅하기가 더 어려워집니다. key를 사용하여 모든 상태를 초기화하거나 렌더링 중에 모든 상태를 계산할 수 있는지 항상 확인하세요. 예를 들어 선택한 항목을 저장(및 재설정)하는 대신 선택한 item ID를 저장할 수 있습니다:
function List({ items }) {
const [isReverse, setIsReverse] = useState(false);
const [selectedId, setSelectedId] = useState(null);
// ✅ 가장 좋음: 렌더링 중에 모든 것을 계산
const selection = items.find(item => item.id === selectedId) ?? null;
// ...
}
이제 상태를 "조정"할 필요가 전혀 없습니다. selected Id를 가진 항목이 목록에 있으면 선택된 상태로 유지됩니다. 그렇지 않은 경우 일치하는 항목을 찾을 수 없으므로 렌더링 중에 계산된 선택 항목은 null이 됩니다. 이 동작은 약간 다르지만 이제 항목에 대한 대부분의 변경 사항이 선택 내용을 유지하므로 더 나은 방법이라고 할 수 있습니다. 그러나 selectedId가 있는 항목이 존재하지 않을 수 있으므로 아래의 모든 로직에서 selection을 사용해야 합니다.
이벤트 핸들러 간 로직 공유
해당 제품을 구매할 수 있는 두 개의 버튼(구매 및 결제)이 있는 제품 페이지가 있다고 가정해 보겠습니다. 사용자가 제품을 장바구니에 넣을 때마다 알림을 표시하고 싶습니다. 두 버튼의 클릭 핸들러에서 모두 showNotification()을 호출하는 것은 반복적으로 느껴지므로 이 로직을 Effect에 배치하고 싶을 수 있습니다:
function ProductPage({ product, addToCart }) {
// 🔴 피하기: 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는 불필요합니다. 또한 버그를 유발할 가능성이 높습니다. 예를 들어 페이지가 새로 고쳐질 때마다 앱이 장바구니를 "기억"한다고 가정해 보겠습니다. 카트에 제품을 한 번 추가하고 페이지를 새로 고치면 알림이 다시 표시됩니다. 해당 제품 페이지를 새로 고칠 때마다 알림이 계속 표시됩니다. 이는 페이지 로드 시 product.isInCart가 이미 true이기 때문에 위의 Effect는 showNotification()을 호출하기 때문입니다.
어떤 코드가 Effect에 있어야 하는지 이벤트 핸들러에 있어야 하는지 확실하지 않은 경우 이 코드가 실행되어야 하는 이유를 자문해 보세요. 컴포넌트가 사용자에게 표시되었기 때문에 실행되어야 하는 코드에만 이펙트를 사용하세요. 이 예제에서는 페이지가 표시되었기 때문이 아니라 사용자가 버튼을 눌렀기 때문에 알림이 표시되어야 합니다! Effect를 삭제하고 공유 로직을 두 이벤트 핸들러에서 호출되는 함수에 넣으세요:
function ProductPage({ product, addToCart }) {
// ✅ 좋습니다: 이벤트 핸들러에서 이벤트별 로직이 호출됩니다.
function buyProduct() {
addToCart(product);
showNotification(`Added ${product.name} to the shopping cart!`);
}
function handleBuyClick() {
buyProduct();
}
function handleCheckoutClick() {
buyProduct();
navigateTo('/checkout');
}
// ...
}
이렇게 하면 불필요한 Effect가 제거되고 버그가 수정됩니다.
POST 요청 보내기
이 폼 컴포넌트는 두 가지 종류의 POST 요청을 전송합니다. 마운트할 때 분석 이벤트를 보냅니다. 양식을 작성하고 제출 버튼을 클릭하면 /api/register 엔드포인트로 POST 요청을 보냅니다:
function Form() {
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
// ✅ 좋습니다: 컴포넌트가 표시되었으므로 이 로직이 실행되어야 합니다.
useEffect(() => {
post('/analytics/event', { eventName: 'visit_form' });
}, []);
// 🔴 피하기: Effect 내부의 이벤트별 로직
const [jsonToSubmit, setJsonToSubmit] = useState(null);
useEffect(() => {
if (jsonToSubmit !== null) {
post('/api/register', jsonToSubmit);
}
}, [jsonToSubmit]);
function handleSubmit(e) {
e.preventDefault();
setJsonToSubmit({ firstName, lastName });
}
// ...
}
이전 예제와 동일한 기준을 적용해 보겠습니다.
애널리틱스 POST 요청은 Effect에 남아 있어야 합니다. 분석 이벤트를 전송하는 이유는 폼이 표시되었기 때문입니다. (개발에서는 두 번 실행되지만 이를 처리하는 방법은 여기를 참조하세요.)
그러나 /api/register POST 요청은 폼이 표시되어서 발생하는 것이 아닙니다. 사용자가 버튼을 누를 때라는 특정 시점에만 요청을 보내려고 합니다. 이 요청은 해당 특정 상호작용에서만 발생해야 합니다. 두 번째 효과를 삭제하고 해당 POST 요청을 이벤트 핸들러로 이동합니다:
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 });
}
// ...
}
어떤 로직을 이벤트 핸들러에 넣을지 이펙트에 넣을지 선택할 때, 사용자 관점에서 어떤 종류의 로직인지에 대한 답을 찾아야 합니다. 이 로직이 특정 상호작용으로 인해 발생하는 것이라면 이벤트 핸들러에 보관하세요. 사용자가 화면에서 컴포넌트를 보는 것이 원인이라면 이펙트에 보관하세요.
연속적 계산
때로는 다른 상태에 따라 각각 상태를 조정하는 이펙트를 체인으로 연결하고 싶을 때가 있습니다:
function Game() {
const [card, setCard] = useState(null);
const [goldCardCount, setGoldCardCount] = useState(0);
const [round, setRound] = useState(1);
const [isGameOver, setIsGameOver] = useState(false);
// 🔴 피하기: 서로를 트리거하기 위해서만 상태를 조정하는 Effect Cahin
useEffect(() => {
if (card !== null && card.gold) {
setGoldCardCount(c => c + 1);
}
}, [card]);
useEffect(() => {
if (goldCardCount > 3) {
setRound(r => r + 1)
setGoldCardCount(0);
}
}, [goldCardCount]);
useEffect(() => {
if (round > 5) {
setIsGameOver(true);
}
}, [round]);
useEffect(() => {
alert('Good game!');
}, [isGameOver]);
function handlePlaceCard(nextCard) {
if (isGameOver) {
throw Error('Game already ended.');
} else {
setCard(nextCard);
}
}
// ...
이 코드에는 두 가지 문제가 있습니다.
한 가지 문제는 매우 비효율적이라는 점입니다. 컴포넌트(및 그 자식)는 체인의 각 세트 호출 사이에 다시 렌더링해야 합니다. 위의 예시에서 최악의 경우(setCard → 렌더링 → setGoldCardCount → 렌더링 → setRound → 렌더링 → setIsGameOver → 렌더링)에는 아래 트리를 불필요하게 세 번 다시 렌더링합니다.
속도가 느리지 않더라도 코드가 발전함에 따라 작성한 '체인'이 새로운 요구 사항에 맞지 않는 경우가 발생할 수 있습니다. 게임 이동의 기록을 단계별로 살펴볼 수 있는 방법을 추가한다고 가정해 보겠습니다. 각 상태 변수를 과거의 값으로 업데이트하여 이를 수행할 수 있습니다. 하지만 카드 상태를 과거의 값으로 설정하면 이펙트 체인이 다시 트리거되고 표시되는 데이터가 변경됩니다. 이러한 코드는 딱딱하고 취약한 경우가 많습니다.
이 경우 렌더링 중에 가능한 것을 계산하고 이벤트 핸들러에서 상태를 조정하는 것이 좋습니다:
function Game() {
const [card, setCard] = useState(null);
const [goldCardCount, setGoldCardCount] = useState(0);
const [round, setRound] = useState(1);
// ✅ 렌더링 중 계산
const isGameOver = round > 5;
function handlePlaceCard(nextCard) {
if (isGameOver) {
throw Error('Game already ended.');
}
// ✅ 이벤트 핸들러에서 다음 상태를 모두 계산합니다.
setCard(nextCard);
if (nextCard.gold) {
if (goldCardCount <= 3) {
setGoldCardCount(goldCardCount + 1);
} else {
setGoldCardCount(0);
setRound(round + 1);
if (round === 5) {
alert('Good game!');
}
}
}
}
훨씬 더 효율적입니다. 또한 게임 기록을 볼 수 있는 방법을 구현하면 이제 다른 모든 값을 조정하는 이펙트 체인을 트리거하지 않고도 각 상태 변수를 과거의 움직임으로 설정할 수 있습니다. 여러 이벤트 핸들러 간에 로직을 재사용해야 하는 경우 함수를 추출하여 해당 핸들러에서 호출할 수 있습니다.
이벤트 핸들러 내부에서 상태는 스냅샷처럼 동작한다는 점을 기억하세요. 예를 들어 setRound(round + 1)를 호출한 후에도 라운드 변수는 사용자가 버튼을 클릭한 시점의 값을 반영합니다. 계산에 다음 값을 사용해야 하는 경우 const nextRound = round + 1과 같이 수동으로 정의하세요.
이벤트 핸들러에서 직접 다음 상태를 계산할 수 없는 경우도 있습니다. 예를 들어 여러 개의 드롭다운이 있는 폼에서 다음 드롭다운의 옵션이 이전 드롭다운의 선택된 값에 따라 달라진다고 가정해 보겠습니다. 이 경우 네트워크와 동기화하기 때문에 Effect 체인이 적절합니다.
애플리케이션 초기화
일부 로직은 앱이 로드될 때 한 번만 실행되어야 합니다.
최상위 컴포넌트의 Effect에 배치하고 싶을 수도 있습니다:
function App() {
// 🔴 피하기: 한 번만 실행되어야 하는 로직이 포함된 이펙트
useEffect(() => {
loadDataFromLocalStorage();
checkAuthToken();
}, []);
// ...
}
하지만 개발 과정에서 이 함수가 두 번 실행된다는 사실을 금방 알게 될 것입니다. 함수가 두 번 호출되도록 설계되지 않았기 때문에 인증 토큰이 무효화되는 등의 문제가 발생할 수 있습니다. 일반적으로 컴포넌트는 다시 마운트할 때 복원력이 있어야 합니다. 여기에는 최상위 앱 컴포넌트가 포함됩니다.
프로덕션 환경에서 실제로 다시 마운트되지 않을 수도 있지만 모든 컴포넌트에서 동일한 제약 조건을 따르면 코드를 이동하고 재사용하기가 더 쉬워집니다. 일부 로직이 컴포넌트 마운트당 한 번이 아니라 앱 로드당 한 번 실행되어야 하는 경우 최상위 변수를 추가하여 이미 실행되었는지 여부를 추적하세요:
let didInit = false;
function App() {
useEffect(() => {
if (!didInit) {
didInit = true;
// ✅ 앱 로드당 한 번만 실행
loadDataFromLocalStorage();
checkAuthToken();
}
}, []);
// ...
}
모듈 초기화 중이나 앱 렌더링 전에 실행할 수도 있습니다:
if (typeof window !== 'undefined') { // 브라우저에서 실행 중인지 확인합니다.
// ✅ 앱 로드당 한 번만 실행
checkAuthToken();
loadDataFromLocalStorage();
}
function App() {
// ...
}
컴포넌트를 임포트할 때 최상위 레벨의 코드는 렌더링되지 않더라도 한 번 실행됩니다. 임의의 컴포넌트를 임포트할 때 속도 저하나 예상치 못한 동작을 방지하려면 이 패턴을 과도하게 사용하지 마세요. 앱 전체 초기화 로직은 App.js와 같은 루트 컴포넌트 모듈이나 애플리케이션의 엔트리 포인트에 유지하세요.
상태 변경에 대해 부모 컴포넌트에 알리기
참 또는 거짓이 될 수 있는 내부 isOn 상태를 가진 Toggle 컴포넌트를 작성하고 있다고 가정해 봅시다. 클릭 또는 드래그를 통해 토글하는 방법에는 몇 가지가 있습니다. Toggle 내부 상태가 변경될 때마다 부모 컴포넌트에 알리고 싶으면 onChange 이벤트를 노출하고 Effect에서 호출하면 됩니다:
function Toggle({ onChange }) {
const [isOn, setIsOn] = useState(false);
// 🔴 피하기: onChange 핸들러가 너무 늦게 실행됨
useEffect(() => {
onChange(isOn);
}, [isOn, onChange])
function handleClick() {
setIsOn(!isOn);
}
function handleDragEnd(e) {
if (isCloserToRightEdge(e)) {
setIsOn(true);
} else {
setIsOn(false);
}
}
// ...
}
앞서와 마찬가지로 이것은 이상적이지 않습니다. Toggle이 먼저 상태를 업데이트하고 React가 화면을 업데이트합니다. 그런 다음 React는 Effect를 실행하고 부모 컴포넌트에서 전달된 onChange 함수를 호출합니다. 이제 부모 컴포넌트는 자신의 상태를 업데이트하고 다른 렌더 패스를 시작합니다. 모든 것을 한 번의 패스로 처리하는 것이 좋습니다.
Effect를 삭제하고 대신 동일한 이벤트 핸들러 내에서 두 컴포넌트의 상태를 업데이트합니다:
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);
}
}
// ...
}
이 접근 방식을 사용하면 토글 컴포넌트와 그 부모 컴포넌트 모두 이벤트가 진행되는 동안 상태를 업데이트합니다. React는 서로 다른 컴포넌트의 업데이트를 일괄 처리하므로 렌더링 패스는 한 번만 발생합니다.
상태를 완전히 제거하고 대신 부모 컴포넌트로부터 isOn을 수신할 수도 있습니다:
// ✅ 이것도 좋음: 컴포넌트가 부모에 의해 완전히 제어됩니다.
function Toggle({ isOn, onChange }) {
function handleClick() {
onChange(!isOn);
}
function handleDragEnd(e) {
if (isCloserToRightEdge(e)) {
onChange(true);
} else {
onChange(false);
}
}
// ...
}
"상태 올리기"는 부모 컴포넌트가 부모 자체의 상태를 토글하여 토글을 완전히 제어할 수 있게 해줍니다. 즉, 부모 컴포넌트에 더 많은 로직을 포함해야 하지만 전체적으로 걱정해야 할 상태는 줄어듭니다. 두 개의 서로 다른 상태 변수를 동기화하려고 할 때마다 대신 상태 리프팅을 사용해 보세요!
부모에게 데이터 전달하기
이 자식 컴포넌트는 일부 데이터를 가져온 다음 이펙트의 부모 컴포넌트에 전달합니다:
function Parent() {
const [data, setData] = useState(null);
// ...
return <Child onFetched={setData} />;
}
function Child({ onFetched }) {
const data = useSomeAPI();
// 🔴 피하십시오: Effect에서 부모에게 데이터 전달
useEffect(() => {
if (data) {
onFetched(data);
}
}, [onFetched, data]);
// ...
}
React에서 데이터는 부모 컴포넌트에서 자식 컴포넌트로 흐릅니다. 화면에 뭔가 잘못된 것이 보이면 컴포넌트 체인을 따라 올라가서 어떤 컴포넌트가 잘못된 prop을 전달하거나 잘못된 상태를 가지고 있는지 찾아내면 정보의 출처를 추적할 수 있습니다. 자식 컴포넌트가 Effects에서 부모 컴포넌트의 상태를 업데이트하면 데이터 흐름을 추적하기가 매우 어려워집니다. 자식과 부모 모두 동일한 데이터가 필요하므로 부모 컴포넌트가 해당 데이터를 가져와 자식에게 대신 전달하도록 하세요:
function Parent() {
const data = useSomeAPI();
// ...
// ✅ 좋아요: 자녀에게 데이터 전달
return <Child data={data} />;
}
function Child({ data }) {
// ...
}
이렇게 하면 데이터가 부모에서 자식으로 내려오기 때문에 데이터 흐름이 더 간단하고 예측 가능하게 유지됩니다.
외부 저장소에 구독하기
때때로 컴포넌트가 React 상태 외부의 일부 데이터를 구독해야 할 수 있습니다. 이 데이터는 타사 라이브러리 또는 내장 브라우저 API에서 가져올 수 있습니다. 이 데이터는 React가 모르는 사이에 변경될 수 있으므로 컴포넌트를 수동으로 구독해야 합니다. 예를 들어 이 작업은 종종 Effect를 통해 수행됩니다:
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();
// ...
}
여기서 컴포넌트는 외부 데이터 저장소(이 경우 브라우저 navigator.onLine API)를 구독합니다. 이 API는 서버에 존재하지 않으므로(초기 HTML에 사용할 수 없으므로) 처음에 상태는 true로 설정됩니다. 브라우저에서 해당 데이터 저장소의 값이 변경될 때마다 컴포넌트는 해당 상태를 업데이트합니다.
이를 위해 Effect를 사용하는 것이 일반적이지만, React에는 외부 저장소를 구독하기 위해 특별히 제작된 Hook이 있습니다. Effect를 삭제하고 useSyncExternalStore에 대한 호출로 대체합니다:
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();
// ...
}
이 접근 방식은 변경 가능한 데이터를 Effect를 사용해 React state에 수동으로 동기화하는 것보다 오류가 덜 발생합니다. 일반적으로 위의 useOnlineStatus()와 같은 사용자 정의 Hook을 작성하여 개별 컴포넌트에서 이 코드를 반복할 필요가 없도록 합니다. React 컴포넌트에서 외부 store를 구독하는 방법에 대해 자세히 읽어보세요.
데이터 가져오기
많은 앱이 데이터 불러오기를 시작하기 위해 Effect를 사용합니다. 이와 같은 데이터 불러오기 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);
}
// ...
}
이 가져오기를 이벤트 핸들러로 옮길 필요는 없습니다.
이벤트 핸들러에 로직을 넣어야 했던 앞선 예제와 모순되는 것처럼 보일 수 있습니다! 하지만 가져오기를 해야 하는 주된 이유가 입력 이벤트가 아니라는 점을 고려하세요. 검색 입력은 URL에서 미리 채워지는 경우가 많으며, 사용자는 입력을 건드리지 않고 앞뒤로 탐색할 수도 있습니다.
페이지와 쿼리의 출처가 어디인지는 중요하지 않습니다. 이 구성 요소가 표시되는 동안에는 현재 페이지 및 쿼리에 대한 네트워크의 데이터와 결과를 동기화하려고 합니다. 이것이 바로 Effect입니다.
하지만 위의 코드에는 버그가 있습니다. "hello"를 빠르게 입력한다고 상상해 보세요. 그러면 쿼리가 "h"에서 "he", "hel", "hell", "hello"로 바뀝니다. 이렇게 하면 별도의 가져오기가 시작되지만 응답이 어떤 순서로 도착할지는 보장할 수 없습니다. 예를 들어, "hello" 응답 이후에 "hell" 응답이 도착할 수 있습니다. 이 경우 setResults()를 마지막으로 호출하므로 잘못된 검색 결과가 표시될 수 있습니다. 이를 "경쟁 조건"이라고 하는데, 두 개의 서로 다른 요청이 서로 '경쟁'하여 예상과 다른 순서로 도착한 것입니다.
경쟁 조건을 수정하려면 오래된 응답을 무시하는 정리 함수를 추가해야 합니다:
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);
}
// ...
}
이렇게 하면 이펙트가 데이터를 가져올 때 마지막으로 요청된 응답을 제외한 모든 응답이 무시됩니다.
데이터 불러오기를 구현할 때 경합 조건을 처리하는 것만이 어려운 것은 아닙니다. 응답 캐싱(사용자가 뒤로 버튼을 클릭하여 이전 화면을 즉시 볼 수 있도록), 서버에서 데이터를 가져오는 방법(초기 서버 렌더링 HTML에 스피너 대신 가져온 콘텐츠가 포함되도록), 네트워크 워터폴을 피하는 방법(자식이 모든 부모를 기다리지 않고 데이터를 가져올 수 있도록) 등도 고려해야 할 사항입니다.
이러한 문제는 React뿐만 아니라 모든 UI 라이브러리에 적용됩니다. 이러한 문제를 해결하는 것은 간단하지 않기 때문에 최신 프레임워크는 Effects에서 데이터를 불러오는 것보다 더 효율적인 내장 데이터 불러오기 메커니즘을 제공합니다.
프레임워크를 사용하지 않고(직접 빌드하고 싶지 않다면) Effects에서 데이터 불러오기를 보다 인체공학적으로 만들고 싶다면, 이 예시처럼 불러오기 로직을 커스텀 Hook으로 추출하는 것을 고려해 보세요:
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;
}
또한 오류 처리와 콘텐츠 로딩 여부를 추적하기 위한 로직을 추가하고 싶을 것입니다. 이와 같은 Hook을 직접 빌드하거나 React 에코시스템에서 이미 사용 가능한 많은 솔루션 중 하나를 사용할 수 있습니다. 이 방법만으로는 프레임워크에 내장된 데이터 불러오기 메커니즘을 사용하는 것만큼 효율적이지는 않지만, 데이터 불러오기 로직을 사용자 정의 Hook으로 옮기면 나중에 효율적인 데이터 불러오기 전략을 채택하기가 더 쉬워집니다.
일반적으로 Effects를 작성해야 할 때마다 위의 useData와 같이 보다 선언적이고 목적에 맞게 구축된 API를 사용하여 일부 기능을 커스텀 Hook으로 추출할 수 있는 경우를 주시하세요. 컴포넌트에서 원시 사용 효과 호출이 적을수록 애플리케이션을 유지 관리하기가 더 쉬워집니다.
요약
- 렌더링 중에 무언가를 계산할 수 있다면 Effect가 필요하지 않습니다.
- 비용이 많이 드는 계산을 캐시하려면 useEffect 대신 useMemo를 추가하세요.
- 전체 컴포넌트 트리의 상태를 재설정하려면 다른 키를 전달하세요.
- 소품 변경에 대한 응답으로 특정 상태 비트를 재설정하려면 렌더링 중에 설정하세요.
- 컴포넌트가 표시되어 실행되는 코드는 Effects에 있어야 하고, 나머지는 이벤트에 있어야 합니다.
- 여러 컴포넌트의 상태를 업데이트해야 하는 경우 단일 이벤트 중에 수행하는 것이 좋습니다.
- 여러 컴포넌트의 상태 변수를 동기화하려고 할 때마다 상태 리프팅을 고려하세요.
- Effects로 데이터를 가져올 수 있지만 경쟁 조건을 피하기 위해 정리를 구현해야 합니다.
과제 1 / 4: Effect 없이 데이터 변환하기
아래의 TodoList에는 할 일 목록이 표시됩니다. "활성 할 일만 표시" 확인란을 선택하면 완료된 할 일은 목록에 표시되지 않습니다. 표시되는 할 일과 관계없이 바닥글에는 아직 완료되지 않은 할 일의 수가 표시됩니다.
불필요한 상태와 Effect를 모두 제거하여 이 구성 요소를 단순화하세요.
https://codesandbox.io/s/transform-data-without-effects-2v6fjs?file=/App.js
과제 2/4: Effect 없이 계산 캐시하기
이 예제에서는 할 일 필터링이 getVisibleTodos()라는 별도의 함수로 추출되었습니다. 이 함수 안에는 언제 호출되는지 알 수 있도록 console.log() 호출이 포함되어 있습니다. "활성 할일만 표시"를 토글하면 getVisibleTodos()가 다시 실행되는 것을 확인할 수 있습니다. 이는 표시할 할 일을 토글하면 표시되는 할 일이 변경되기 때문에 예상되는 현상입니다.
여러분의 임무는 TodoList 컴포넌트에서 보이는 할 일 목록을 다시 계산하는 효과를 제거하는 것입니다. 하지만 입력을 입력할 때 getVisibleTodos()가 다시 실행되지 않도록(따라서 로그를 인쇄하지 않도록) 해야 합니다.
https://codesandbox.io/s/cache-a-calculation-without-effects-2o98b3?file=/App.js
과제 3/4: Effect 없이 상태 재설정하기 (이거 헷갈렸다)
이 편집 연락처 컴포넌트는 { id, 이름, 이메일 } 모양의 연락처 객체를 저장된 연락처 프로퍼티로 받습니다. 이름과 이메일 입력 필드를 편집해 보세요. 저장을 누르면 양식 위의 연락처 버튼이 편집된 이름으로 업데이트됩니다. 재설정을 누르면 양식의 보류 중인 변경 사항이 모두 삭제됩니다. 이 UI를 사용해 보면서 사용법을 익혀 보세요.
상단의 버튼으로 연락처를 선택하면 해당 연락처의 세부 정보를 반영하도록 양식이 재설정됩니다. 이 작업은 EditContact.js 내부의 Effect로 수행됩니다. 이 Effect를 제거합니다. savedContact.id가 변경될 때 양식을 재설정하는 다른 방법을 찾아보세요.
https://codesandbox.io/s/reset-state-without-effects-1qbmc9?file=/EditContact.js
과제 4/4: Effect 없이 양식 제출하기
이 양식 컴포넌트를 사용하면 친구에게 메시지를 보낼 수 있습니다. 양식을 제출하면 showForm 상태 변수가 false로 설정됩니다. 그러면 sendMessage(message)를 호출하는 Effect가 트리거되어 메시지를 전송합니다(콘솔에서 확인할 수 있음). 메시지가 전송되면 양식으로 돌아갈 수 있는 "채팅 열기" 버튼이 있는 "감사합니다" 대화 상자가 표시됩니다.
앱 사용자가 너무 많은 메시지를 보내고 있습니다. 채팅을 조금 더 어렵게 만들기 위해 양식 대신 "감사합니다" 대화 상자를 먼저 표시하기로 결정했습니다. showForm 상태 변수를 참이 아닌 거짓으로 초기화하도록 변경합니다. 이렇게 변경하자마자 콘솔에 빈 메시지가 전송된 것으로 표시됩니다. 이 로직의 뭔가 잘못되었습니다!
이 문제의 근본 원인은 무엇일까요? 그리고 어떻게 해결할 수 있을까요?
https://codesandbox.io/s/submit-a-form-without-effects-dqrpvp?file=/App.js
발표요약)
1. 비싼 계산의 결과값을 캐싱할때는 useMemo Hook을 쓴다.
결과값이 비싼지 아닌지는 어떻게 아냐? 콘솔타임으로 찍어볼수도 있는데 현실적으로 일일이 할수는없고..
우리회사에서는 일단 기본적으로는 useCallback과 useMemo를 사용하지않고, 꼭 필요할때만 재고하여 사용하는것을 기본으로 하고있다.
2. Effect내에서 부모로부터 받아온 prop이 변경되었을때 state를 바꾸는짓을 하지마라.
3. 컴포넌트가 사용자에게 표시되었기 때문에 실행되어야 하는 코드에만 이펙트를 사용한다.
참고) 이벤트 핸들러 내의 상태는 스냅샷처럼 동작한다.
4. 앱이 로드될 때 한 번만 실행되어야할때에 Effect를 사용하고싶을 수 있다. 그러나 스트릭모드에서는 2번 실행하므로, 컴포넌트를 임포트할 때 최상위 레벨의 코드는 렌더링되지 않더라도 한 번 실행되는점을 이용하여, 마운트 시에만 작동하는 코드를 짠다.
5.데이터를 가져올 때 (get) Effect내에서 데이터를 호출하는것은 일반적이다.if (typeof window !== 'undefined') { // 브라우저에서 실행 중인지 확인합니다. checkAuthToken(); loadDataFromLocalStorage(); }