You Might Not Need an Effect

김동현·2026년 3월 16일

Effect가 필요하지 않을 수도 있어요

소개

Effect는 React 패러다임에서 벗어나는 탈출구예요. Effect를 사용하면 React "밖으로 나가서" 컴포넌트를 외부 시스템, 예를 들어 React가 아닌 위젯, 네트워크, 또는 브라우저 DOM과 동기화할 수 있어요. 외부 시스템이 관련되어 있지 않다면 (예를 들어, props나 state가 변경될 때 컴포넌트의 state를 업데이트하고 싶은 경우), Effect가 필요하지 않아요. 불필요한 Effect를 제거하면 코드를 따라가기 더 쉽고, 더 빠르게 실행되며, 오류가 덜 발생하게 돼요.

이 페이지에서 배울 것들

  • 컴포넌트에서 불필요한 Effect를 제거하는 이유와 방법
  • Effect 없이 비용이 많이 드는 계산을 캐시하는 방법
  • Effect 없이 컴포넌트 state를 리셋하고 조정하는 방법
  • 이벤트 핸들러 간에 로직을 공유하는 방법
  • 어떤 로직을 이벤트 핸들러로 옮겨야 하는지
  • 부모 컴포넌트에 변경 사항을 알리는 방법

불필요한 Effect를 제거하는 방법 {/how-to-remove-unnecessary-effects/}

Effect가 필요하지 않은 일반적인 두 가지 경우가 있어요:

  • 렌더링을 위해 데이터를 변환하는 데는 Effect가 필요하지 않아요. 예를 들어, 목록을 표시하기 전에 필터링하고 싶다고 해볼게요. 목록이 변경될 때 state 변수를 업데이트하는 Effect를 작성하고 싶은 마음이 들 수 있어요. 하지만 이건 비효율적이에요. state를 업데이트하면, React는 먼저 여러분의 컴포넌트 함수를 호출해서 화면에 무엇이 있어야 하는지 계산해요. 그다음 React는 이러한 변경 사항을 DOM에 "커밋"해서 화면을 업데이트해요. 그런 다음 React가 Effect를 실행하죠. 만약 Effect가 즉시 state를 업데이트하면, 이 전체 프로세스가 처음부터 다시 시작돼요! 불필요한 렌더링 패스를 피하려면, 컴포넌트의 최상위 레벨에서 모든 데이터를 변환하세요. 그 코드는 props나 state가 변경될 때마다 자동으로 다시 실행될 거예요.
  • 사용자 이벤트를 처리하는 데는 Effect가 필요하지 않아요. 예를 들어, 사용자가 제품을 구매할 때 /api/buy POST 요청을 보내고 알림을 표시하고 싶다고 해볼게요. Buy 버튼 클릭 이벤트 핸들러에서는 정확히 무슨 일이 일어났는지 알 수 있어요. Effect가 실행될 때쯤이면, 사용자가 무엇을 했는지 (예를 들어, 어떤 버튼이 클릭되었는지) 알 수 없어요. 그래서 보통 사용자 이벤트는 해당 이벤트 핸들러에서 처리해야 해요.

외부 시스템과 동기화하려면 Effect가 필요해요. 예를 들어, jQuery 위젯을 React state와 동기화된 상태로 유지하는 Effect를 작성할 수 있어요. Effect로 데이터를 가져올 수도 있어요: 예를 들어, 검색 결과를 현재 검색 쿼리와 동기화할 수 있죠. 최신 프레임워크는 컴포넌트에서 직접 Effect를 작성하는 것보다 더 효율적인 내장 데이터 페칭 메커니즘을 제공한다는 걸 기억하세요.

올바른 직관을 얻는 데 도움이 되도록, 몇 가지 일반적인 구체적인 예시를 살펴볼게요!

props나 state를 기반으로 state 업데이트하기 {/updating-state-based-on-props-or-state/}

두 개의 state 변수 firstNamelastName이 있는 컴포넌트가 있다고 가정해 볼게요. 이 둘을 연결해서 fullName을 계산하고 싶어요. 게다가 firstName이나 lastName이 변경될 때마다 fullName이 업데이트되었으면 해요. 첫 번째 본능은 fullName state 변수를 추가하고 Effect에서 업데이트하는 거예요:

function Form() {
  const [firstName, setFirstName] = useState('Taylor');
  const [lastName, setLastName] = useState('Swift');

  // 🔴 피하세요: 중복된 state와 불필요한 Effect
  const [fullName, setFullName] = useState('');
  useEffect(() => {
    setFullName(firstName + ' ' + lastName);
  }, [firstName, lastName]);
  // ...
}

이건 필요 이상으로 복잡해요. 비효율적이기도 하죠: fullName의 오래된 값으로 전체 렌더링 패스를 수행한 다음, 업데이트된 값으로 즉시 다시 렌더링하거든요. state 변수와 Effect를 제거하세요:

function Form() {
  const [firstName, setFirstName] = useState('Taylor');
  const [lastName, setLastName] = useState('Swift');
  // ✅ 좋아요: 렌더링 중에 계산됨
  const fullName = firstName + ' ' + lastName;
  // ...
}

기존 props나 state에서 계산할 수 있는 것은 state에 넣지 마세요. 대신 렌더링 중에 계산하세요. 이렇게 하면 코드가 더 빨라지고 (추가적인 "연쇄" 업데이트를 피할 수 있어요), 더 단순해지며 (일부 코드를 제거할 수 있어요), 오류가 덜 발생해요 (서로 다른 state 변수들이 서로 동기화되지 않아 발생하는 버그를 피할 수 있어요). 이 접근 방식이 새롭게 느껴진다면, React로 사고하기에서 무엇이 state에 들어가야 하는지 설명하고 있어요.

비용이 많이 드는 계산 캐싱하기 {/caching-expensive-calculations/}

이 컴포넌트는 props로 받은 todos를 가져와서 filter prop에 따라 필터링해서 visibleTodos를 계산해요. 결과를 state에 저장하고 Effect에서 업데이트하고 싶은 마음이 들 수 있어요:

function TodoList({ todos, filter }) {
  const [newTodo, setNewTodo] = useState('');

  // 🔴 피하세요: 중복된 state와 불필요한 Effect
  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()가 느리거나 todos가 많을 수도 있어요. 그런 경우 newTodo 같은 관련 없는 state 변수가 변경되었을 때 getFilteredTodos()를 다시 계산하고 싶지 않을 거예요.

비용이 많이 드는 계산을 useMemo Hook으로 감싸서 캐시(또는 "메모이제이션")할 수 있어요:

참고

React Compiler는 많은 경우에 비용이 많이 드는 계산을 자동으로 메모이제이션해 줄 수 있어서, 수동으로 useMemo를 사용할 필요가 없어질 수 있어요.

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]);
  // ...
}

이건 React에게 todosfilter가 변경되지 않는 한 내부 함수를 다시 실행하고 싶지 않다고 알려주는 거예요. React는 초기 렌더링 중에 getFilteredTodos()의 반환 값을 기억할 거예요. 다음 렌더링 중에는 todosfilter가 다른지 확인할 거예요. 지난번과 같다면, useMemo는 저장해 둔 마지막 결과를 반환할 거예요. 하지만 다르다면, React는 내부 함수를 다시 호출하고 (그 결과를 저장하고) 반환할 거예요.

useMemo로 감싼 함수는 렌더링 중에 실행되므로, 이건 순수한 계산에만 동작해요.

심화: 계산이 비용이 많이 드는지 어떻게 알 수 있나요? {/how-to-tell-if-a-calculation-is-expensive/}

일반적으로, 수천 개의 객체를 생성하거나 반복하는 게 아니라면, 아마 비용이 많이 들지 않을 거예요. 좀 더 확신을 갖고 싶다면, 콘솔 로그를 추가해서 코드 조각에 소요된 시간을 측정할 수 있어요:

console.time('filter array');
const visibleTodos = getFilteredTodos(todos, filter);
console.timeEnd('filter array');

측정하고 있는 상호작용을 수행하세요 (예를 들어, 입력에 타이핑하기). 그러면 콘솔에 filter array: 0.15ms 같은 로그가 보일 거예요. 전체적으로 기록된 시간이 상당한 양 (예를 들어, 1ms 이상)이 되면, 그 계산을 메모이제이션하는 게 합리적일 수 있어요. 실험으로, 계산을 useMemo로 감싸서 해당 상호작용에 대한 전체 기록된 시간이 감소했는지 확인해 볼 수 있어요:

console.time('filter array');
const visibleTodos = useMemo(() => {
  return getFilteredTodos(todos, filter); // todos와 filter가 변경되지 않았다면 건너뜀
}, [todos, filter]);
console.timeEnd('filter array');

useMemo첫 번째 렌더링을 더 빠르게 만들지 않아요. 업데이트 시 불필요한 작업을 건너뛰는 데만 도움이 돼요.

여러분의 컴퓨터가 사용자의 것보다 더 빠를 수 있다는 걸 기억하세요. 그래서 인위적인 속도 저하로 성능을 테스트하는 게 좋아요. 예를 들어, Chrome은 이를 위한 CPU Throttling 옵션을 제공해요.

또한 개발 환경에서 성능을 측정하면 가장 정확한 결과를 얻을 수 없다는 점도 유의하세요. (예를 들어, Strict Mode가 켜져 있으면, 각 컴포넌트가 한 번이 아니라 두 번 렌더링되는 걸 볼 거예요.) 가장 정확한 타이밍을 얻으려면, 프로덕션용으로 앱을 빌드하고 사용자가 사용하는 것과 같은 기기에서 테스트하세요.

prop이 변경될 때 모든 state 리셋하기 {/resetting-all-state-when-a-prop-changes/}

ProfilePage 컴포넌트는 userId prop을 받아요. 페이지에는 댓글 입력이 있고, comment state 변수를 사용해서 그 값을 보관해요. 어느 날, 문제를 발견했어요: 한 프로필에서 다른 프로필로 이동할 때 comment state가 리셋되지 않는 거예요. 결과적으로 실수로 잘못된 사용자의 프로필에 댓글을 게시하기 쉬워요. 문제를 해결하려면, userId가 변경될 때마다 comment state 변수를 지우고 싶어요:

export default function ProfilePage({ userId }) {
  const [comment, setComment] = useState('');

  // 🔴 피하세요: Effect에서 prop 변경 시 state 리셋하기
  useEffect(() => {
    setComment('');
  }, [userId]);
  // ...
}

이건 비효율적이에요. ProfilePage와 그 자식들이 먼저 오래된 값으로 렌더링된 다음, 다시 렌더링하거든요. 또한 복잡해요. ProfilePage 안에 state가 있는 모든 컴포넌트에서 이걸 해야 하니까요. 예를 들어, 댓글 UI가 중첩되어 있다면, 중첩된 댓글 state도 지우고 싶을 거예요.

대신, 명시적인 key를 전달해서 각 사용자의 프로필이 개념적으로 다른 프로필이라는 걸 React에게 알려줄 수 있어요. 컴포넌트를 둘로 나누고 외부 컴포넌트에서 내부 컴포넌트로 key 속성을 전달하세요:

export default function ProfilePage({ userId }) {
  return (
    <Profile
      userId={userId}
      key={userId}
    />
  );
}

function Profile({ userId }) {
  // ✅ 이것과 아래의 다른 모든 state는 key 변경 시 자동으로 리셋됨
  const [comment, setComment] = useState('');
  // ...
}

일반적으로, React는 같은 컴포넌트가 같은 위치에 렌더링될 때 state를 보존해요. userIdProfile 컴포넌트의 key로 전달함으로써, 다른 userId를 가진 두 Profile 컴포넌트를 state를 공유하지 않아야 하는 두 개의 다른 컴포넌트로 취급하도록 React에게 요청하는 거예요. key (여기서는 userId로 설정했어요)가 변경될 때마다, React는 DOM을 다시 생성하고 Profile 컴포넌트와 모든 자식들의 state를 리셋할 거예요. 이제 프로필 간에 이동할 때 comment 필드가 자동으로 지워질 거예요.

이 예시에서, 외부 ProfilePage 컴포넌트만 export되고 프로젝트의 다른 파일들에 보여요. ProfilePage를 렌더링하는 컴포넌트들은 key를 전달할 필요가 없어요: userId를 일반 prop으로 전달하면 돼요. ProfilePage가 내부 Profile 컴포넌트에 key로 전달한다는 사실은 구현 세부 사항이에요.

prop이 변경될 때 일부 state 조정하기 {/adjusting-some-state-when-a-prop-changes/}

때로는 prop 변경 시 state의 전부가 아니라 일부만 리셋하거나 조정하고 싶을 수 있어요.

List 컴포넌트는 items 목록을 prop으로 받고, selection state 변수에 선택된 항목을 유지해요. items prop이 다른 배열을 받을 때마다 selectionnull로 리셋하고 싶어요:

function List({ items }) {
  const [isReverse, setIsReverse] = useState(false);
  const [selection, setSelection] = useState(null);

  // 🔴 피하세요: Effect에서 prop 변경 시 state 조정하기
  useEffect(() => {
    setSelection(null);
  }, [items]);
  // ...
}

이것도 이상적이지 않아요. items가 변경될 때마다, List와 그 자식 컴포넌트들이 먼저 오래된 selection 값으로 렌더링될 거예요. 그다음 React가 DOM을 업데이트하고 Effect를 실행할 거예요. 마지막으로, setSelection(null) 호출이 List와 그 자식 컴포넌트들의 또 다른 재렌더링을 일으켜서, 이 전체 프로세스를 다시 시작하게 돼요.

Effect를 삭제하는 것부터 시작하세요. 대신, 렌더링 중에 직접 state를 조정하세요:

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);
  }
  // ...
}

이렇게 이전 렌더링의 정보를 저장하는 건 이해하기 어려울 수 있지만, Effect에서 같은 state를 업데이트하는 것보다 나아요. 위 예시에서, setSelection은 렌더링 중에 직접 호출돼요. React는 return 문으로 종료된 직후 List를 재렌더링할 거예요. React는 아직 List 자식들을 렌더링하거나 DOM을 업데이트하지 않았으므로, 이렇게 하면 List 자식들이 오래된 selection 값을 렌더링하는 걸 건너뛸 수 있어요.

렌더링 중에 컴포넌트를 업데이트하면, React는 반환된 JSX를 버리고 즉시 렌더링을 재시도해요. 매우 느린 연쇄 재시도를 피하기 위해, React는 렌더링 중에 같은 컴포넌트의 state만 업데이트할 수 있게 해요. 렌더링 중에 다른 컴포넌트의 state를 업데이트하면 에러가 나요. 루프를 피하려면 items !== prevItems 같은 조건이 필요해요. 이런 식으로 state를 조정할 수 있지만, 다른 부수 효과 (DOM 변경이나 타임아웃 설정 같은)는 컴포넌트를 순수하게 유지하기 위해 이벤트 핸들러나 Effect에 남아 있어야 해요.

이 패턴이 Effect보다 더 효율적이긴 하지만, 대부분의 컴포넌트는 이것도 필요하지 않아요. 어떻게 하든, props나 다른 state를 기반으로 state를 조정하면 데이터 흐름을 이해하고 디버그하기 더 어려워져요. 항상 key로 모든 state를 리셋할 수 있는지, 또는 렌더링 중에 모든 것을 계산할 수 있는지 확인하세요. 예를 들어, 선택된 항목을 저장(하고 리셋)하는 대신, 선택된 항목 ID를 저장할 수 있어요:

function List({ items }) {
  const [isReverse, setIsReverse] = useState(false);
  const [selectedId, setSelectedId] = useState(null);
  // ✅ 최고: 렌더링 중에 모든 것 계산하기
  const selection = items.find(item => item.id === selectedId) ?? null;
  // ...
}

이제 state를 "조정"할 필요가 전혀 없어요. 선택된 ID를 가진 항목이 목록에 있으면, 계속 선택된 상태로 유지돼요. 없으면, 렌더링 중에 계산된 selection은 일치하는 항목을 찾을 수 없어서 null이 될 거예요. 이 동작은 다르지만, 틀림없이 더 나아요. items에 대한 대부분의 변경 사항이 선택을 보존하니까요.

이벤트 핸들러 간에 로직 공유하기 {/sharing-logic-between-event-handlers/}

제품 페이지에 두 개의 버튼 (Buy와 Checkout)이 있고, 둘 다 그 제품을 구매할 수 있다고 해볼게요. 사용자가 제품을 장바구니에 넣을 때마다 알림을 표시하고 싶어요. 두 버튼의 클릭 핸들러에서 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를 사용하세요. 이 예시에서, 알림은 사용자가 버튼을 눌렀기 때문에 나타나야 하지, 페이지가 표시되었기 때문이 아니에요! 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 요청 보내기 {/sending-a-post-request/}

Form 컴포넌트는 두 종류의 POST 요청을 보내요. 마운트될 때 분석 이벤트를 보내고, 폼을 작성하고 Submit 버튼을 클릭하면 /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 요청은 폼이 표시되어서 발생하는 게 아니에요. 특정 순간, 즉 사용자가 버튼을 누를 때만 요청을 보내고 싶은 거예요. 이건 그 특정 상호작용에서만 일어나야 해요. 두 번째 Effect를 삭제하고 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 });
  }
  // ...
}

어떤 로직을 이벤트 핸들러에 넣을지 Effect에 넣을지 선택할 때, 답해야 할 주요 질문은 사용자 관점에서 어떤 종류의 로직인가예요. 이 로직이 특정 상호작용에 의해 발생하는 거라면, 이벤트 핸들러에 유지하세요. 사용자가 화면에서 컴포넌트를 보는 것에 의해 발생하는 거라면, Effect에 유지하세요.

연산의 체인 {/chains-of-computations/}

때로는 다른 state를 기반으로 각각 state 조각을 조정하는 Effect를 체인으로 연결하고 싶은 마음이 들 수 있어요:

function Game() {
  const [card, setCard] = useState(null);
  const [goldCardCount, setGoldCardCount] = useState(0);
  const [round, setRound] = useState(1);
  const [isGameOver, setIsGameOver] = useState(false);

  // 🔴 피하세요: 서로를 트리거하기 위해서만 state를 조정하는 Effect 체인
  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);
    }
  }

  // ...

이 코드에는 두 가지 문제가 있어요.

첫 번째 문제는 매우 비효율적이라는 거예요: 컴포넌트 (와 그 자식들)가 체인의 각 set 호출 사이에 재렌더링해야 하거든요. 위 예시에서, 최악의 경우 (setCard → render → setGoldCardCount → render → setRound → render → setIsGameOver → render) 아래 트리의 불필요한 재렌더링이 세 번 발생해요.

두 번째 문제는 느리지 않더라도, 코드가 발전하면서 작성한 "체인"이 새로운 요구 사항에 맞지 않는 경우가 생긴다는 거예요. 게임 이동의 히스토리를 단계별로 살펴볼 수 있는 방법을 추가한다고 상상해 보세요. 각 state 변수를 과거의 값으로 업데이트하면 되겠죠. 하지만 card state를 과거의 값으로 설정하면 Effect 체인이 다시 트리거되고 표시하는 데이터가 변경될 거예요. 이런 코드는 종종 경직되고 취약해요.

이 경우, 렌더링 중에 계산할 수 있는 것은 계산하고, 이벤트 핸들러에서 state를 조정하는 게 나아요:

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.');
    }

    // ✅ 이벤트 핸들러에서 다음 state를 모두 계산하기
    setCard(nextCard);
    if (nextCard.gold) {
      if (goldCardCount < 3) {
        setGoldCardCount(goldCardCount + 1);
      } else {
        setGoldCardCount(0);
        setRound(round + 1);
        if (round === 5) {
          alert('Good game!');
        }
      }
    }
  }

  // ...

이게 훨씬 더 효율적이에요. 또한, 게임 히스토리를 보는 방법을 구현한다면, 이제 다른 모든 값을 조정하는 Effect 체인을 트리거하지 않고 각 state 변수를 과거의 이동으로 설정할 수 있어요. 여러 이벤트 핸들러 간에 로직을 재사용해야 한다면, 함수를 추출해서 그 핸들러들에서 호출할 수 있어요.

이벤트 핸들러 안에서는 state가 스냅샷처럼 동작한다는 걸 기억하세요. 예를 들어, setRound(round + 1)을 호출한 후에도, round 변수는 사용자가 버튼을 클릭했을 때의 값을 반영할 거예요. 계산에 다음 값을 사용해야 한다면, const nextRound = round + 1 같이 수동으로 정의하세요.

어떤 경우에는 이벤트 핸들러에서 다음 state를 직접 계산할 수 없어요. 예를 들어, 여러 드롭다운이 있는 폼에서 다음 드롭다운의 옵션이 이전 드롭다운의 선택된 값에 따라 달라진다고 상상해 보세요. 그러면 네트워크와 동기화하고 있으니까 Effect 체인이 적절해요.

애플리케이션 초기화하기 {/initializing-the-application/}

일부 로직은 앱이 로드될 때 한 번만 실행되어야 해요.

최상위 컴포넌트의 Effect에 넣고 싶을 수 있어요:

function App() {
  // 🔴 피하세요: 한 번만 실행되어야 하는 로직이 있는 Effect
  useEffect(() => {
    loadDataFromLocalStorage();
    checkAuthToken();
  }, []);
  // ...
}

하지만 개발 환경에서 두 번 실행된다는 걸 빠르게 발견할 거예요. 이게 문제를 일으킬 수 있어요—예를 들어, 함수가 두 번 호출되도록 설계되지 않아서 인증 토큰이 무효화될 수도 있어요. 일반적으로, 컴포넌트는 재마운트에 탄력적이어야 해요. 여기에는 최상위 App 컴포넌트도 포함돼요.

프로덕션에서 실제로는 재마운트되지 않을 수 있지만, 모든 컴포넌트에서 같은 제약 조건을 따르면 코드를 이동하고 재사용하기 더 쉬워져요. 어떤 로직이 컴포넌트 마운트당 한 번이 아니라 앱 로드당 한 번 실행되어야 한다면, 이미 실행되었는지 추적하는 최상위 변수를 추가하세요:

let didInit = false;

function App() {
  useEffect(() => {
    if (!didInit) {
      didInit = true;
      // ✅ 앱 로드당 한 번만 실행됨
      loadDataFromLocalStorage();
      checkAuthToken();
    }
  }, []);
  // ...
}

또는 앱이 렌더링되기 전 모듈 초기화 중에 실행할 수도 있어요:

if (typeof window !== 'undefined') { // 브라우저에서 실행 중인지 확인.
   // ✅ 앱 로드당 한 번만 실행됨
  checkAuthToken();
  loadDataFromLocalStorage();
}

function App() {
  // ...
}

최상위 레벨의 코드는 컴포넌트가 import될 때 한 번 실행돼요—렌더링되지 않더라도요. 임의의 컴포넌트를 import할 때 속도 저하나 놀라운 동작을 피하려면, 이 패턴을 과도하게 사용하지 마세요. 앱 전체 초기화 로직은 App.js 같은 루트 컴포넌트 모듈이나 애플리케이션의 진입점에 유지하세요.

부모 컴포넌트에 state 변경 알리기 {/notifying-parent-components-about-state-changes/}

true 또는 false가 될 수 있는 내부 isOn state를 가진 Toggle 컴포넌트를 작성하고 있다고 해볼게요. 토글하는 방법은 몇 가지가 있어요 (클릭하거나 드래그해서). Toggle 내부 state가 변경될 때마다 부모 컴포넌트에 알리고 싶어서, 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이 먼저 state를 업데이트하면, React가 화면을 업데이트해요. 그다음 React가 Effect를 실행하면, 부모 컴포넌트에서 전달된 onChange 함수를 호출하죠. 이제 부모 컴포넌트가 자체 state를 업데이트하면서, 또 다른 렌더링 패스를 시작하게 돼요. 모든 걸 단일 패스에서 처리하는 게 나을 거예요.

Effect를 삭제하고, 대신 같은 이벤트 핸들러 안에서 컴포넌트의 state를 업데이트하세요:

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);
    }
  }

  // ...
}

이 접근 방식으로, Toggle 컴포넌트와 부모 컴포넌트 모두 이벤트 동안 state를 업데이트해요. React는 다른 컴포넌트의 업데이트를 일괄 처리하므로, 렌더링 패스는 한 번만 있게 돼요.

state를 완전히 제거하고, 대신 부모 컴포넌트로부터 isOn을 받을 수도 있어요:

// ✅ 이것도 좋아요: 컴포넌트가 부모에 의해 완전히 제어됨
function Toggle({ isOn, onChange }) {
  function handleClick() {
    onChange(!isOn);
  }

  function handleDragEnd(e) {
    if (isCloserToRightEdge(e)) {
      onChange(true);
    } else {
      onChange(false);
    }
  }

  // ...
}

"state 끌어올리기"는 부모 컴포넌트가 부모 자신의 state를 토글함으로써 Toggle을 완전히 제어하게 해줘요. 이건 부모 컴포넌트가 더 많은 로직을 포함해야 한다는 의미지만, 전체적으로 걱정해야 할 state는 줄어들어요. 두 개의 다른 state 변수를 동기화하려고 할 때마다, 대신 state를 끌어올려 보세요!

부모에게 데이터 전달하기 {/passing-data-to-the-parent/}

Child 컴포넌트는 일부 데이터를 fetch한 다음 Effect에서 Parent 컴포넌트에 전달해요:

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을 전달하거나 잘못된 state를 가진 컴포넌트를 찾을 때까지 컴포넌트 체인을 따라 올라가면서 정보가 어디서 오는지 추적할 수 있어요. 자식 컴포넌트가 Effect에서 부모 컴포넌트의 state를 업데이트하면, 데이터 흐름을 추적하기 매우 어려워져요. 자식과 부모 모두 같은 데이터가 필요하니까, 부모 컴포넌트가 그 데이터를 fetch하고 자식에게 전달하게 하세요:

function Parent() {
  const data = useSomeAPI();
  // ...
  // ✅ 좋아요: 자식에게 데이터 전달하기
  return <Child data={data} />;
}

function Child({ data }) {
  // ...
}

이게 더 단순하고 데이터 흐름을 예측 가능하게 유지해요: 데이터가 부모에서 자식으로 흘러내려가요.

외부 store 구독하기 {/subscribing-to-an-external-store/}

때로는, 컴포넌트가 React state 외부의 일부 데이터를 구독해야 할 수도 있어요. 이 데이터는 서드파티 라이브러리나 내장 브라우저 API에서 올 수 있어요. 이 데이터가 React의 지식 없이 변경될 수 있으므로, 컴포넌트를 수동으로 구독해야 해요. 이건 종종 Effect로 수행돼요, 예를 들면:

function useOnlineStatus() {
  // 이상적이지 않음: Effect에서 수동 store 구독
  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();
  // ...
}

여기서, 컴포넌트는 외부 데이터 store (이 경우, 브라우저 navigator.onLine API)를 구독해요. 이 API는 서버에 존재하지 않으므로 (초기 HTML에 사용할 수 없으므로), 초기에 state는 true로 설정돼요. 브라우저에서 그 데이터 store의 값이 변경될 때마다, 컴포넌트는 state를 업데이트해요.

이를 위해 Effect를 사용하는 게 일반적이긴 하지만, React에는 외부 store를 구독하기 위한 목적별 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으로 외부 store 구독하기
  return useSyncExternalStore(
    subscribe, // 같은 함수를 전달하는 한 React는 재구독하지 않음
    () => navigator.onLine, // 클라이언트에서 값을 가져오는 방법
    () => true // 서버에서 값을 가져오는 방법
  );
}

function ChatIndicator() {
  const isOnline = useOnlineStatus();
  // ...
}

이 접근 방식은 Effect로 변경 가능한 데이터를 React state에 수동으로 동기화하는 것보다 오류가 덜 발생해요. 일반적으로, 위의 useOnlineStatus() 같은 커스텀 Hook을 작성해서 개별 컴포넌트에서 이 코드를 반복할 필요가 없게 해요. React 컴포넌트에서 외부 store를 구독하는 방법에 대해 더 읽어보세요.

데이터 가져오기 {/fetching-data/}

많은 앱이 데이터 가져오기를 시작하기 위해 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);
  }
  // ...
}

이 fetch를 이벤트 핸들러로 옮길 필요는 없어요.

이벤트 핸들러에 로직을 넣어야 했던 이전 예시들과 모순되는 것처럼 보일 수 있어요! 하지만 타이핑 이벤트가 fetch의 주된 이유가 아니라는 걸 고려해 보세요. 검색 입력은 종종 URL에서 미리 채워지고, 사용자는 입력을 건드리지 않고 뒤로/앞으로 이동할 수 있어요.

pagequery가 어디서 오는지는 중요하지 않아요. 이 컴포넌트가 보이는 동안, 현재 pagequery에 대한 네트워크의 데이터와 results동기화된 상태로 유지하고 싶은 거예요. 그래서 Effect인 거죠.

하지만, 위 코드에는 버그가 있어요. "hello"를 빠르게 타이핑한다고 상상해 보세요. 그러면 query"h", "he", "hel", "hell", "hello"로 변경될 거예요. 이게 별도의 fetch들을 시작하지만, 응답이 어떤 순서로 도착할지 보장이 없어요. 예를 들어, "hell" 응답이 "hello" 응답 이후에 도착할 수 있어요. 마지막으로 setResults()를 호출하니까, 잘못된 검색 결과를 표시하게 될 거예요. 이걸 "경쟁 조건(race condition)"이라고 해요: 두 개의 다른 요청이 서로 "경주"해서 예상과 다른 순서로 도착한 거예요.

경쟁 조건을 고치려면, 오래된 응답을 무시하도록 클린업 함수를 추가해야 해요:

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);
  }
  // ...
}

이렇게 하면 Effect가 데이터를 fetch할 때, 마지막으로 요청된 것을 제외한 모든 응답이 무시되도록 보장돼요.

경쟁 조건 처리가 데이터 가져오기 구현의 유일한 어려움은 아니에요. 응답 캐싱 (사용자가 뒤로가기를 클릭하면 이전 화면을 즉시 볼 수 있도록), 서버에서 데이터를 가져오는 방법 (초기 서버 렌더링된 HTML이 스피너 대신 가져온 콘텐츠를 포함하도록), 네트워크 워터폴 방지 (자식이 모든 부모를 기다리지 않고 데이터를 fetch할 수 있도록)도 고려해야 할 수 있어요.

이러한 문제는 React뿐만 아니라 모든 UI 라이브러리에 적용돼요. 이를 해결하는 건 간단하지 않아서, 최신 프레임워크는 Effect에서 데이터를 fetch하는 것보다 더 효율적인 내장 데이터 가져오기 메커니즘을 제공해요.

프레임워크를 사용하지 않고 (그리고 직접 만들고 싶지도 않고) Effect에서 데이터 가져오기를 더 인체공학적으로 만들고 싶다면, 이 예시처럼 가져오기 로직을 커스텀 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으로 옮기면 나중에 효율적인 데이터 가져오기 전략을 채택하기 더 쉬워질 거예요.

일반적으로, Effect를 작성해야 할 때마다, 위의 useData처럼 더 선언적이고 목적별 API를 가진 커스텀 Hook으로 기능 조각을 추출할 수 있는지 주시하세요. 컴포넌트에 있는 원시 useEffect 호출이 적을수록, 애플리케이션을 유지보수하기 더 쉬워질 거예요.

요약

  • 렌더링 중에 뭔가를 계산할 수 있다면, Effect가 필요하지 않아요.
  • 비용이 많이 드는 계산을 캐시하려면, useEffect 대신 useMemo를 추가하세요.
  • 전체 컴포넌트 트리의 state를 리셋하려면, 다른 key를 전달하세요.
  • prop 변경에 대한 응답으로 특정 state 비트를 리셋하려면, 렌더링 중에 설정하세요.
  • 컴포넌트가 표시되어서 실행되어야 하는 코드는 Effect에 있어야 하고, 나머지는 이벤트에 있어야 해요.
  • 여러 컴포넌트의 state를 업데이트해야 한다면, 단일 이벤트 동안 하는 게 나아요.
  • 다른 컴포넌트의 state 변수를 동기화하려고 할 때마다, state 끌어올리기를 고려해 보세요.
  • Effect로 데이터를 fetch할 수 있지만, 경쟁 조건을 피하려면 클린업을 구현해야 해요.

도전과제

도전 1: Effect 없이 데이터 변환하기 {/transform-data-without-effects/}

아래의 TodoList는 할 일 목록을 표시해요. "Show only active todos" 체크박스가 선택되면, 완료된 할 일들이 목록에 표시되지 않아요. 어떤 할 일들이 보이든, 푸터는 아직 완료되지 않은 할 일의 개수를 표시해요.

불필요한 state와 Effect를 모두 제거해서 이 컴포넌트를 단순화하세요.

// App.js
import { useState, useEffect } from 'react';
import { initialTodos, createTodo } from './todos.js';

export default function TodoList() {
  const [todos, setTodos] = useState(initialTodos);
  const [showActive, setShowActive] = useState(false);
  const [activeTodos, setActiveTodos] = useState([]);
  const [visibleTodos, setVisibleTodos] = useState([]);
  const [footer, setFooter] = useState(null);

  useEffect(() => {
    setActiveTodos(todos.filter(todo => !todo.completed));
  }, [todos]);

  useEffect(() => {
    setVisibleTodos(showActive ? activeTodos : todos);
  }, [showActive, todos, activeTodos]);

  useEffect(() => {
    setFooter(
      <footer>
        {activeTodos.length} todos left
      </footer>
    );
  }, [activeTodos]);

  return (
    <>
      <label>
        <input
          type="checkbox"
          checked={showActive}
          onChange={e => setShowActive(e.target.checked)}
        />
        Show only active todos
      </label>
      <NewTodo onAdd={newTodo => setTodos([...todos, newTodo])} />
      <ul>
        {visibleTodos.map(todo => (
          <li key={todo.id}>
            {todo.completed ? <s>{todo.text}</s> : todo.text}
          </li>
        ))}
      </ul>
      {footer}
    </>
  );
}

function NewTodo({ onAdd }) {
  const [text, setText] = useState('');

  function handleAddClick() {
    setText('');
    onAdd(createTodo(text));
  }

  return (
    <>
      <input value={text} onChange={e => setText(e.target.value)} />
      <button onClick={handleAddClick}>
        Add
      </button>
    </>
  );
}
// todos.js
let nextId = 0;

export function createTodo(text, completed = false) {
  return {
    id: nextId++,
    text,
    completed
  };
}

export const initialTodos = [
  createTodo('Get apples', true),
  createTodo('Get oranges', true),
  createTodo('Get carrots'),
];
label { display: block; }
input { margin-top: 10px; }

힌트

렌더링 중에 뭔가를 계산할 수 있다면, state나 그것을 업데이트하는 Effect가 필요하지 않아요.

해답

이 예시에서 필수적인 state는 두 개뿐이에요: todos 목록과 체크박스가 선택되었는지를 나타내는 showActive state 변수예요. 다른 모든 state 변수들은 중복이고 대신 렌더링 중에 계산할 수 있어요. 여기에는 주변 JSX로 직접 옮길 수 있는 footer도 포함돼요.

결과는 이렇게 보여야 해요:

// App.js
import { useState } from 'react';
import { initialTodos, createTodo } from './todos.js';

export default function TodoList() {
  const [todos, setTodos] = useState(initialTodos);
  const [showActive, setShowActive] = useState(false);
  const activeTodos = todos.filter(todo => !todo.completed);
  const visibleTodos = showActive ? activeTodos : todos;

  return (
    <>
      <label>
        <input
          type="checkbox"
          checked={showActive}
          onChange={e => setShowActive(e.target.checked)}
        />
        Show only active todos
      </label>
      <NewTodo onAdd={newTodo => setTodos([...todos, newTodo])} />
      <ul>
        {visibleTodos.map(todo => (
          <li key={todo.id}>
            {todo.completed ? <s>{todo.text}</s> : todo.text}
          </li>
        ))}
      </ul>
      <footer>
        {activeTodos.length} todos left
      </footer>
    </>
  );
}

function NewTodo({ onAdd }) {
  const [text, setText] = useState('');

  function handleAddClick() {
    setText('');
    onAdd(createTodo(text));
  }

  return (
    <>
      <input value={text} onChange={e => setText(e.target.value)} />
      <button onClick={handleAddClick}>
        Add
      </button>
    </>
  );
}
// todos.js
let nextId = 0;

export function createTodo(text, completed = false) {
  return {
    id: nextId++,
    text,
    completed
  };
}

export const initialTodos = [
  createTodo('Get apples', true),
  createTodo('Get oranges', true),
  createTodo('Get carrots'),
];
label { display: block; }
input { margin-top: 10px; }

도전 2: Effect 없이 계산 캐싱하기 {/cache-a-calculation-without-effects/}

이 예시에서, 할 일 필터링이 getVisibleTodos()라는 별도의 함수로 추출되었어요. 이 함수 안에는 호출되는 걸 알 수 있게 해주는 console.log() 호출이 들어있어요. "Show only active todos"를 토글하면 getVisibleTodos()가 다시 실행되는 걸 확인하세요. 표시할 할 일을 토글하면 보이는 할 일이 변경되니까 예상되는 거예요.

여러분의 과제는 TodoList 컴포넌트에서 visibleTodos 목록을 다시 계산하는 Effect를 제거하는 거예요. 하지만, 입력에 타이핑할 때 getVisibleTodos()가 다시 실행되지 않도록 (그래서 로그를 출력하지 않도록) 해야 해요.

힌트

한 가지 솔루션은 보이는 할 일을 캐시하기 위해 useMemo 호출을 추가하는 거예요. 덜 명백한 다른 솔루션도 있어요.

// App.js
import { useState, useEffect } from 'react';
import { initialTodos, createTodo, getVisibleTodos } from './todos.js';

export default function TodoList() {
  const [todos, setTodos] = useState(initialTodos);
  const [showActive, setShowActive] = useState(false);
  const [text, setText] = useState('');
  const [visibleTodos, setVisibleTodos] = useState([]);

  useEffect(() => {
    setVisibleTodos(getVisibleTodos(todos, showActive));
  }, [todos, showActive]);

  function handleAddClick() {
    setText('');
    setTodos([...todos, createTodo(text)]);
  }

  return (
    <>
      <label>
        <input
          type="checkbox"
          checked={showActive}
          onChange={e => setShowActive(e.target.checked)}
        />
        Show only active todos
      </label>
      <input value={text} onChange={e => setText(e.target.value)} />
      <button onClick={handleAddClick}>
        Add
      </button>
      <ul>
        {visibleTodos.map(todo => (
          <li key={todo.id}>
            {todo.completed ? <s>{todo.text}</s> : todo.text}
          </li>
        ))}
      </ul>
    </>
  );
}
// todos.js
let nextId = 0;
let calls = 0;

export function getVisibleTodos(todos, showActive) {
  console.log(`getVisibleTodos() was called ${++calls} times`);
  const activeTodos = todos.filter(todo => !todo.completed);
  const visibleTodos = showActive ? activeTodos : todos;
  return visibleTodos;
}

export function createTodo(text, completed = false) {
  return {
    id: nextId++,
    text,
    completed
  };
}

export const initialTodos = [
  createTodo('Get apples', true),
  createTodo('Get oranges', true),
  createTodo('Get carrots'),
];
label { display: block; }
input { margin-top: 10px; }

해답

state 변수와 Effect를 제거하고, 대신 getVisibleTodos() 호출 결과를 캐시하기 위해 useMemo 호출을 추가하세요:

// App.js
import { useState, useMemo } from 'react';
import { initialTodos, createTodo, getVisibleTodos } from './todos.js';

export default function TodoList() {
  const [todos, setTodos] = useState(initialTodos);
  const [showActive, setShowActive] = useState(false);
  const [text, setText] = useState('');
  const visibleTodos = useMemo(
    () => getVisibleTodos(todos, showActive),
    [todos, showActive]
  );

  function handleAddClick() {
    setText('');
    setTodos([...todos, createTodo(text)]);
  }

  return (
    <>
      <label>
        <input
          type="checkbox"
          checked={showActive}
          onChange={e => setShowActive(e.target.checked)}
        />
        Show only active todos
      </label>
      <input value={text} onChange={e => setText(e.target.value)} />
      <button onClick={handleAddClick}>
        Add
      </button>
      <ul>
        {visibleTodos.map(todo => (
          <li key={todo.id}>
            {todo.completed ? <s>{todo.text}</s> : todo.text}
          </li>
        ))}
      </ul>
    </>
  );
}
// todos.js
let nextId = 0;
let calls = 0;

export function getVisibleTodos(todos, showActive) {
  console.log(`getVisibleTodos() was called ${++calls} times`);
  const activeTodos = todos.filter(todo => !todo.completed);
  const visibleTodos = showActive ? activeTodos : todos;
  return visibleTodos;
}

export function createTodo(text, completed = false) {
  return {
    id: nextId++,
    text,
    completed
  };
}

export const initialTodos = [
  createTodo('Get apples', true),
  createTodo('Get oranges', true),
  createTodo('Get carrots'),
];
label { display: block; }
input { margin-top: 10px; }

이 변경으로, todosshowActive가 변경될 때만 getVisibleTodos()가 호출될 거예요. 입력에 타이핑하는 건 text state 변수만 변경하니까, getVisibleTodos() 호출을 트리거하지 않아요.

useMemo가 필요 없는 다른 솔루션도 있어요. text state 변수는 할 일 목록에 영향을 줄 수 없으니까, NewTodo 폼을 별도의 컴포넌트로 추출하고 text state 변수를 그 안으로 옮길 수 있어요:

// App.js
import { useState, useMemo } from 'react';
import { initialTodos, createTodo, getVisibleTodos } from './todos.js';

export default function TodoList() {
  const [todos, setTodos] = useState(initialTodos);
  const [showActive, setShowActive] = useState(false);
  const visibleTodos = getVisibleTodos(todos, showActive);

  return (
    <>
      <label>
        <input
          type="checkbox"
          checked={showActive}
          onChange={e => setShowActive(e.target.checked)}
        />
        Show only active todos
      </label>
      <NewTodo onAdd={newTodo => setTodos([...todos, newTodo])} />
      <ul>
        {visibleTodos.map(todo => (
          <li key={todo.id}>
            {todo.completed ? <s>{todo.text}</s> : todo.text}
          </li>
        ))}
      </ul>
    </>
  );
}

function NewTodo({ onAdd }) {
  const [text, setText] = useState('');

  function handleAddClick() {
    setText('');
    onAdd(createTodo(text));
  }

  return (
    <>
      <input value={text} onChange={e => setText(e.target.value)} />
      <button onClick={handleAddClick}>
        Add
      </button>
    </>
  );
}
// todos.js
let nextId = 0;
let calls = 0;

export function getVisibleTodos(todos, showActive) {
  console.log(`getVisibleTodos() was called ${++calls} times`);
  const activeTodos = todos.filter(todo => !todo.completed);
  const visibleTodos = showActive ? activeTodos : todos;
  return visibleTodos;
}

export function createTodo(text, completed = false) {
  return {
    id: nextId++,
    text,
    completed
  };
}

export const initialTodos = [
  createTodo('Get apples', true),
  createTodo('Get oranges', true),
  createTodo('Get carrots'),
];
label { display: block; }
input { margin-top: 10px; }

이 접근 방식도 요구 사항을 만족해요. 입력에 타이핑하면 text state 변수만 업데이트돼요. text state 변수가 자식 NewTodo 컴포넌트에 있으니까, 부모 TodoList 컴포넌트는 재렌더링되지 않아요. 그래서 타이핑할 때 getVisibleTodos()가 호출되지 않는 거예요. (다른 이유로 TodoList가 재렌더링되면 여전히 호출될 거예요.)

도전 3: Effect 없이 state 리셋하기 {/reset-state-without-effects/}

EditContact 컴포넌트는 { id, name, email } 형태의 연락처 객체를 savedContact prop으로 받아요. 이름과 이메일 입력 필드를 편집해 보세요. Save를 누르면, 폼 위의 연락처 버튼이 편집된 이름으로 업데이트돼요. Reset을 누르면, 폼의 보류 중인 변경 사항이 버려져요. 이 UI를 가지고 놀아보면서 감을 잡아보세요.

상단의 버튼으로 연락처를 선택하면, 폼이 그 연락처의 세부 정보를 반영하도록 리셋돼요. 이건 EditContact.js 내부의 Effect로 수행돼요. 이 Effect를 제거하세요. savedContact.id가 변경될 때 폼을 리셋하는 다른 방법을 찾아보세요.

// App.js (hidden)
import { useState } from 'react';
import ContactList from './ContactList.js';
import EditContact from './EditContact.js';

export default function ContactManager() {
  const [
    contacts,
    setContacts
  ] = useState(initialContacts);
  const [
    selectedId,
    setSelectedId
  ] = useState(0);
  const selectedContact = contacts.find(c =>
    c.id === selectedId
  );

  function handleSave(updatedData) {
    const nextContacts = contacts.map(c => {
      if (c.id === updatedData.id) {
        return updatedData;
      } else {
        return c;
      }
    });
    setContacts(nextContacts);
  }

  return (
    <div>
      <ContactList
        contacts={contacts}
        selectedId={selectedId}
        onSelect={id => setSelectedId(id)}
      />
      <hr />
      <EditContact
        savedContact={selectedContact}
        onSave={handleSave}
      />
    </div>
  )
}

const initialContacts = [
  { id: 0, name: 'Taylor', email: 'taylor@mail.com' },
  { id: 1, name: 'Alice', email: 'alice@mail.com' },
  { id: 2, name: 'Bob', email: 'bob@mail.com' }
];
// ContactList.js (hidden)
export default function ContactList({
  contacts,
  selectedId,
  onSelect
}) {
  return (
    <section>
      <ul>
        {contacts.map(contact =>
          <li key={contact.id}>
            <button onClick={() => {
              onSelect(contact.id);
            }}>
              {contact.id === selectedId ?
                <b>{contact.name}</b> :
                contact.name
              }
            </button>
          </li>
        )}
      </ul>
    </section>
  );
}
// EditContact.js
import { useState, useEffect } from 'react';

export default function EditContact({ savedContact, onSave }) {
  const [name, setName] = useState(savedContact.name);
  const [email, setEmail] = useState(savedContact.email);

  useEffect(() => {
    setName(savedContact.name);
    setEmail(savedContact.email);
  }, [savedContact]);

  return (
    <section>
      <label>
        Name:{' '}
        <input
          type="text"
          value={name}
          onChange={e => setName(e.target.value)}
        />
      </label>
      <label>
        Email:{' '}
        <input
          type="email"
          value={email}
          onChange={e => setEmail(e.target.value)}
        />
      </label>
      <button onClick={() => {
        const updatedData = {
          id: savedContact.id,
          name: name,
          email: email
        };
        onSave(updatedData);
      }}>
        Save
      </button>
      <button onClick={() => {
        setName(savedContact.name);
        setEmail(savedContact.email);
      }}>
        Reset
      </button>
    </section>
  );
}
ul, li {
  list-style: none;
  margin: 0;
  padding: 0;
}
li { display: inline-block; }
li button {
  padding: 10px;
}
label {
  display: block;
  margin: 10px 0;
}
button {
  margin-right: 10px;
  margin-bottom: 10px;
}

힌트

savedContact.id가 다를 때, EditContact 폼이 개념적으로 다른 연락처의 폼이고 state를 보존하지 말아야 한다고 React에게 알려줄 방법이 있으면 좋을 텐데요. 그런 방법을 기억하시나요?

해답

EditContact 컴포넌트를 둘로 나누세요. 모든 폼 state를 내부 EditForm 컴포넌트로 옮기세요. 외부 EditContact 컴포넌트를 export하고, savedContact.id를 내부 EditForm 컴포넌트에 key로 전달하게 하세요. 결과적으로, 내부 EditForm 컴포넌트는 다른 연락처를 선택할 때마다 모든 폼 state를 리셋하고 DOM을 다시 생성해요.

// App.js (hidden)
import { useState } from 'react';
import ContactList from './ContactList.js';
import EditContact from './EditContact.js';

export default function ContactManager() {
  const [
    contacts,
    setContacts
  ] = useState(initialContacts);
  const [
    selectedId,
    setSelectedId
  ] = useState(0);
  const selectedContact = contacts.find(c =>
    c.id === selectedId
  );

  function handleSave(updatedData) {
    const nextContacts = contacts.map(c => {
      if (c.id === updatedData.id) {
        return updatedData;
      } else {
        return c;
      }
    });
    setContacts(nextContacts);
  }

  return (
    <div>
      <ContactList
        contacts={contacts}
        selectedId={selectedId}
        onSelect={id => setSelectedId(id)}
      />
      <hr />
      <EditContact
        savedContact={selectedContact}
        onSave={handleSave}
      />
    </div>
  )
}

const initialContacts = [
  { id: 0, name: 'Taylor', email: 'taylor@mail.com' },
  { id: 1, name: 'Alice', email: 'alice@mail.com' },
  { id: 2, name: 'Bob', email: 'bob@mail.com' }
];
// ContactList.js (hidden)
export default function ContactList({
  contacts,
  selectedId,
  onSelect
}) {
  return (
    <section>
      <ul>
        {contacts.map(contact =>
          <li key={contact.id}>
            <button onClick={() => {
              onSelect(contact.id);
            }}>
              {contact.id === selectedId ?
                <b>{contact.name}</b> :
                contact.name
              }
            </button>
          </li>
        )}
      </ul>
    </section>
  );
}
// EditContact.js
import { useState } from 'react';

export default function EditContact(props) {
  return (
    <EditForm
      {...props}
      key={props.savedContact.id}
    />
  );
}

function EditForm({ savedContact, onSave }) {
  const [name, setName] = useState(savedContact.name);
  const [email, setEmail] = useState(savedContact.email);

  return (
    <section>
      <label>
        Name:{' '}
        <input
          type="text"
          value={name}
          onChange={e => setName(e.target.value)}
        />
      </label>
      <label>
        Email:{' '}
        <input
          type="email"
          value={email}
          onChange={e => setEmail(e.target.value)}
        />
      </label>
      <button onClick={() => {
        const updatedData = {
          id: savedContact.id,
          name: name,
          email: email
        };
        onSave(updatedData);
      }}>
        Save
      </button>
      <button onClick={() => {
        setName(savedContact.name);
        setEmail(savedContact.email);
      }}>
        Reset
      </button>
    </section>
  );
}
ul, li {
  list-style: none;
  margin: 0;
  padding: 0;
}
li { display: inline-block; }
li button {
  padding: 10px;
}
label {
  display: block;
  margin: 10px 0;
}
button {
  margin-right: 10px;
  margin-bottom: 10px;
}

도전 4: Effect 없이 폼 제출하기 {/submit-a-form-without-effects/}

Form 컴포넌트는 친구에게 메시지를 보낼 수 있게 해줘요. 폼을 제출하면, showForm state 변수가 false로 설정돼요. 이게 sendMessage(message)를 호출하는 Effect를 트리거해서 메시지를 보내죠 (콘솔에서 볼 수 있어요). 메시지가 전송된 후, "Open chat" 버튼이 있는 "Thank you" 대화 상자가 보여서 폼으로 돌아갈 수 있게 해줘요.

앱 사용자들이 너무 많은 메시지를 보내고 있어요. 채팅을 조금 더 어렵게 만들기 위해, 폼 대신 "Thank you" 대화 상자를 먼저 보여주기로 했어요. showForm state 변수를 true 대신 false로 초기화하도록 변경하세요. 그 변경을 하자마자, 콘솔에 빈 메시지가 전송되었다고 나올 거예요. 이 로직에 뭔가 잘못됐어요!

이 문제의 근본 원인이 뭔가요? 그리고 어떻게 고칠 수 있나요?

힌트

사용자가 "Thank you" 대화 상자를 봐서 메시지가 전송되어야 하나요? 아니면 그 반대인가요?

// App.js
import { useState, useEffect } from 'react';

export default function Form() {
  const [showForm, setShowForm] = useState(true);
  const [message, setMessage] = useState('');

  useEffect(() => {
    if (!showForm) {
      sendMessage(message);
    }
  }, [showForm, message]);

  function handleSubmit(e) {
    e.preventDefault();
    setShowForm(false);
  }

  if (!showForm) {
    return (
      <>
        <h1>Thanks for using our services!</h1>
        <button onClick={() => {
          setMessage('');
          setShowForm(true);
        }}>
          Open chat
        </button>
      </>
    );
  }

  return (
    <form onSubmit={handleSubmit}>
      <textarea
        placeholder="Message"
        value={message}
        onChange={e => setMessage(e.target.value)}
      />
      <button type="submit" disabled={message === ''}>
        Send
      </button>
    </form>
  );
}

function sendMessage(message) {
  console.log('Sending message: ' + message);
}
label, textarea { margin-bottom: 10px; display: block; }

해답

showForm state 변수는 폼을 보여줄지 "Thank you" 대화 상자를 보여줄지 결정해요. 하지만, "Thank you" 대화 상자가 표시되어서 메시지를 보내는 게 아니에요. 사용자가 폼을 제출했기 때문에 메시지를 보내고 싶은 거예요. 오해의 소지가 있는 Effect를 삭제하고 sendMessage 호출을 handleSubmit 이벤트 핸들러 안으로 옮기세요:

// App.js
import { useState, useEffect } from 'react';

export default function Form() {
  const [showForm, setShowForm] = useState(true);
  const [message, setMessage] = useState('');

  function handleSubmit(e) {
    e.preventDefault();
    setShowForm(false);
    sendMessage(message);
  }

  if (!showForm) {
    return (
      <>
        <h1>Thanks for using our services!</h1>
        <button onClick={() => {
          setMessage('');
          setShowForm(true);
        }}>
          Open chat
        </button>
      </>
    );
  }

  return (
    <form onSubmit={handleSubmit}>
      <textarea
        placeholder="Message"
        value={message}
        onChange={e => setMessage(e.target.value)}
      />
      <button type="submit" disabled={message === ''}>
        Send
      </button>
    </form>
  );
}

function sendMessage(message) {
  console.log('Sending message: ' + message);
}
label, textarea { margin-bottom: 10px; display: block; }

이 버전에서는, 폼을 제출하는 것(이벤트)만 메시지를 보내게 해요. showForm이 처음에 true로 설정되든 false로 설정되든 똑같이 잘 작동해요. (false로 설정하고 추가 콘솔 메시지가 없는지 확인해 보세요.)

profile
프론트에_가까운_풀스택_개발자

0개의 댓글