본문 바로가기

React/실험실

[React] Effect가 필요하지 않을 수 있다.

Effect는 React 패러다임에서 벗어날 수 있는 탈출구이다. Effect를 사용하면 React의 외부로 나가서 

컴포넌트를 React가 아닌 위젯, 네트워크 또는 브라우저 DOM과 같은 외부 시스템과 동기화할 수 있다. 

 

즉, 외부 시스템이 관여하지 않는 경우에는 Effect가 필요하지 않다.

불필요한 Effect를 제거하면 코드의 가독성이 좋아지며, 실행 속도가 빨라진다. 또한 오류 발생률도 줄어든다. 

 

불필요한 Effect를 제거하는 방법 

Effect가 필요하지 않는 경우는 일반적으로 2가지가 있다. 

렌더링을 위해 데이터를 변환하는 경우. 
     예를들어, 목록을 표시하기 전에 필터링을 하고 싶은 경우를 가정해보자. 목록이 변경될 때 상태 변수를

     업데이트하는 Effect를 작성하고 싶을 수 있다. 하지만 이것은 비효율적이다. 

     상태를 업데이트할 때 React는 컴포넌트 함수를 호출해 화면에 표시될 내용을 계산한다. 

     그리고 React가 이러한 변경 사항을 DOM에 커밋하여 화면을 업데이트한다. 그 후, Effect가 실행된다. 

    여기서 만약 Effect가 상태를 업데이트한다면 전체 프로세스가 처음부터 다시 시작된다. 

사용자 이벤트를 처리하는 경우, 

    예를들어, 사용자가 제품을 구매할 때 알림을 표시하기 위해 /api/buy 요청을 보내려한다고 가정해보자. 

    구매 버튼 클릭 이벤트 핸들러에서는 정확히 어떤 일이 일어났는지 알 수 있다. 

    그래서 굳이 Effect에서 처리하기 보다는 이벤트 핸들러에서 사용자 이벤트를 처리한다. 

 

이제 예시 코드와 함께 더 디테일하게 알아보자! 

 

Props or State에 따른 상태 업데이트 

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

  // 🔴 Avoid: redundant state and unnecessary Effect
  const [fullName, setFullName] = useState('');
  useEffect(() => {
    setFullName(firstName + ' ' + lastName);
  }, [firstName, lastName]);
  // ...
}

두 개의 상태 변수( fitstName, lastName ) 가 있는 컴포넌트가 있다고 가정해보자 

우리는 그것을 연결하여 fullName을 계산해서 얻기를 원한다. 또한 firName이나, lastName이 변경될 때마다

fullName이 변경되길 원하고 있어서 useEffect를 통해서 위와 같이 작성하였다. 

 

하지만 이것은 필요 이상으로 복잡하고 비효율적이다. 

function Form() {
  const [firstName, setFirstName] = useState('Taylor');
  const [lastName, setLastName] = useState('Swift');
  // ✅ Good: calculated during rendering
  const fullName = firstName + ' ' + lastName;
  // ...
}

기존 Props나 State를 통해서 계산하는 것이 있다면 이것을 State에 넣을 필요가 없다. 

대신 렌더링 중에 계산하면 된다. 이렇게 작성한다면 코드가 더 빨리자고 ( 계산식 업데이트를 피할 수 있으므로 ) 

더 간단해지며 ( 일부 코드를 제거하므로 ) 오류가 덜 발생한다. ( 다른 State가 동기화되지 않아 발생하는 버그를 회피 )

 

고비용 계산 캐싱

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

  // 🔴 Avoid: redundant state and unnecessary Effect
  const [visibleTodos, setVisibleTodos] = useState([]);
  useEffect(() => {
    setVisibleTodos(getFilteredTodos(todos, filter));
  }, [todos, filter]);

  // ...
}

TodoList 컴포넌트는 Props으로 todo를 가져와서 fileter에 따라 필터링하여 visibleTodls를 계산한다. 

앞선 예시와 마찬가지로 이것은 불필요하고 비효율적이다. 

function TodoList({ todos, filter }) {
  const [newTodo, setNewTodo] = useState('');
  // ✅ This is fine if getFilteredTodos() is not slow.
  const visibleTodos = getFilteredTodos(todos, filter);
  // ...
}

다음과 같이 state에 넣는 것이 아닌 바로 계산하고 처리하면 된다. 

하지만 getFilteredTodos가 느리거나 하는 일이 많은 경우가 있다. 이런 경우 관련 없는 상태 변수로 인해 다시

계산되는 일을 최대한 막고 싶을 것이다. 

import { useMemo, useState } from 'react';

function TodoList({ todos, filter }) {
  const [newTodo, setNewTodo] = useState('');
  // ✅ Does not re-run getFilteredTodos() unless todos or filter change
  const visibleTodos = useMemo(() => getFilteredTodos(todos, filter), [todos, filter]);
  // ...
}

위와 같이 작성한다면 todos나 filter가 변경되지 않는 한 내부 함수가 다시 실행되지 않을 것이다. 

 

Props 변경 시 모든 상태 초기화 

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

  // 🔴 Avoid: Resetting state on prop change in an Effect
  useEffect(() => {
    setComment('');
  }, [userId]);
  // ...
}

ProfilePage 컴포넌트는 userId Props를 받는다. page는 댓글 입력이 포함되어 있으며, 댓글 상태 변수를 사용해서 

해당 값을 보관한다. 

이때, 다른 프로필로 변경하면 댓글의 상태를 초기화하기 위해서 useEffect를 사용해서 지워주었다. 

 

이는 비효울적인 방법이다.  ProfilePage 내부에 어떤 상태가 있는 모든 컴포넌트에서 이러한 작업을 

반복해야 하기 때문이다. 

만약 댓글 UI가 중첩된 경우 중첩된 댓글 상태도 모두 지워야 한다. 

 

useEffect를 사용하는 대신, 명시적인 key를 제공하여 각 사용자의 프로필이 개념적으로 서로 다른 프로필임을 

React에게 알릴 수 있다. 

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
  const [comment, setComment] = useState('');
  // ...
}

일반적으로 React는 동일한 컴포넌트가 같은 위치에 렌더링 될 때 상태를 보존한다. 

하지만 Profile 컴포넌트에 userId를 키로 전달하면 React가 userId가 다른 두 컴포넌트를 상태를 공유해서는 안되는 

컴포넌트로 취급하도록 요청한다. 

 

즉, userId로 설정한 키가 변경될 때마다 React는 DOM을 다시 생성하고 Profile 컴포넌트와 그 모든 자식들의 상태를 

재설정한다. 그러므로 comment 필드가 자동으로 지워진다. 

 

props가 변경될 때 일부 상태 조정하기

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

  // 🔴 Avoid: Adjusting state on prop change in an Effect
  useEffect(() => {
    setSelection(null);
  }, [items]);
  // ...
}

props가 변경될 경우 전체를 초기화 시키기보다 일부를 재설정하거나 조정하고 싶을 때가 있습니다. 

List 컴포넌트는 items를 props로 받고 선택된 item은 selection state에 저장한다. 

 

그리고 items 가 변경될 때마다 selection만 null로 초기화를 시키길 희망합니다. 

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

  // Better: Adjust the state while rendering
  const [prevItems, setPrevItems] = useState(items);
  if (items !== prevItems) {
    setPrevItems(items);
    setSelection(null);
  }
  // ...
}

이전 렌더링 정보를 가지고 있다가 변경하는 방법은 이해하기 어려울 수 있지만 Effect에서 동일한 상태를 

업데이트 하는 것 보다는 좋다. 

 

이러한 방식은 Effect를 사용하는 것 보다는 효율적이지만 대부분의 컴포넌트에서는 필요하지 않다. 

props나 다른 상태에 따라 상태를 조정하면 데이터의 흐름을 이해하고 디버깅을 하기 어렵기 떄문이다. 

 

key를 사용해서 모든 상태를 재설정하거나 렌더링 중 모든 상태를 계산할 수 있는지 항상 확인하자. 

예를 들어, 선택한 항목을 저장하는 대신 선택한 항목 ID를 저장하는 것이 있다. 

function List({ items }) {
  const [isReverse, setIsReverse] = useState(false);
  const [selectedId, setSelectedId] = useState(null);
  // ✅ Best: Calculate everything during rendering
  const selection = items.find(item => item.id === selectedId) ?? null;
  // ...
}

이제 상태를 조정할 필요가 없다. 

선택한 ID를 가진 항목이 목록에 있으면 성택된 상태로 유지된다. 그렇지 않은 경우 일치하는 항목을 찾을 수 없으므로 

렌더링 중 계산된 선택 항목은 null이 된다. 

 

물론 100% 완벽한 방법은 아니지만 대부분의 상황에서 선택한 항목이 동일하다면 선택 항목을 유지하므로 

더 좋은 방법이 될 것이다. 

 

이벤트 헨들러가 포함된 로직 

제품을 구매할 수 있는 버튼 두개가 있는 제품 페이지가 있다고 가정해보자 .

사용자가 제품을 장바구니에 넣을 때마다 알림을 표시하고 싶다. 

 

두 버튼의 클릭 핸들러에서 모두 showNotification()을 호출하는 것은 반복적으로 느껴지므로 이것을 효과적으로 배치하고

싶다는 생각이 들 것이다. 

function ProductPage({ product, addToCart }) {
  // 🔴 Avoid: Event-specific logic inside an Effect
  useEffect(() => {
    if (product.isInCart) {
      showNotification(`Added ${product.name} to the shopping cart!`);
    }
  }, [product]);

  function handleBuyClick() {
    addToCart(product);
  }

  function handleCheckoutClick() {
    addToCart(product);
    navigateTo('/checkout');
  }
  // ...
}

이 방법은 불필요하다. 

또한 버그를 유발할 가능성이 높다. 예를 들어, 페이지가 새로 고쳐질 때마다 앱이 장바구니를

" 기억 " 한다고 가정해보자. 

 

카트에 제품을 한 번 추가하고 페이지를 새로 고치면 알림이 표시된다. 

해당 제품 페이지를 새로 고칠 떄마다 알림이 계속 표시된다. 페이지 로드 시 product.isInCart가 이미 true이므로 

showNotification을 호출하기 때문이다. 

 

어떤 코드가 Effect에 있어야 하는지 이벤트 핸들러에 있어야 하는지 확실하지 않은 경우 해당 코드가 실행되어야 하는

이유를 자문해보면 된다. 

 

컴포넌트가 사용자에게 표시되었기 때문에 실행되어야 하는 코드에만 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');
  }
  // ...
}

 

POST 요청 보내기

Form 컴포넌트가 있는데, 이 컴포넌트에서는 두 가지 종류의 POST 요청을 보낸다. 

마운트할 때 분석 이벤트를 보낸다. 양식을 작성하고 제출 버튼을 클릭하면 /api/register 엔드 포인트로 POST 요청을 

보낸다. 

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

  // ✅ Good: This logic should run because the component was displayed
  useEffect(() => {
    post('/analytics/event', { eventName: 'visit_form' });
  }, []);

  // 🔴 Avoid: Event-specific logic inside an 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('');

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

어떤 로직을 이벤트 헨들러에 넣을지 Effect에 넣을지 선택할 때, 사용자 관점에서 어떤 종류의 로직인지에 대한 

답을 찾아야 한다. 

 

해당 로직이 특정 상호작용으로 인해 발생하는 것이라면 이벤트 헨들러에 보관해야 한다. 

사용자가 화면에서 컴포넌트를 보는 것이 원인이라면 Effect에 보관하면 된다. 

 

연쇄 계산

때로는 다른 상태에 따라 각각의 상태를 조정하는 체인 Effect를 사용하고 싶을 때가 있다. 

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

  // ✅ 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
    setCard(nextCard);
    if (nextCard.gold) {
      if (goldCardCount <= 3) {
        setGoldCardCount(goldCardCount + 1);
      } else {
        setGoldCardCount(0);
        setRound(round + 1);
        if (round === 5) {
          alert('Good game!');
        }
      }
    }
  }

  // ...

체인 Effect를 사용하지 않고 각 상태 변수를 과거의 움직임으로 설정할 수 있다. 

여러 이벤트 핸들러 간에 로직을 재사용하는 경우 함수를 추출하여 해당 함수를 핸들러에서 호출할 수 있다.

 

단 이벤트 핸들러에서 다음 상태를 직접 계산할 수 없는 경우도 있다. 

예를들어, 다음 드롭다운의 옵션이 이전 드롭다움의 선택된 값에 따라 달라지는 경우 네트워크와 동기화하기 때문에

체인 Effect가 적절할 수 있습니다. 

 

어플리케이션 초기화 

어떠한 로직들은 App이 로딩되는 한 번만 실행되면 되는 경우가 있다. 

그런 경우 제일 상단 컴포넌트에서 아래와 같이 제일 최상위 컴포넌트에서 Effect를 실행한다. 

function App() {
  // 🔴 Avoid: Effects with logic that should only ever run once
  useEffect(() => {
    loadDataFromLocalStorage();
    checkAuthToken();
  }, []);
  // ...
}

 

하지만 개발 단계에서 때때로 함수가 두 번 실행되는 경우가 있다. 

배포 환경에서는 다시 마운트되는 일이 발생하지 않을 수 있지만 모든 컴포넌트에서 동일한 제약 조건을 따르면 

코드를 이동하고 재사용하기가 쉬워진다. 

let didInit = false;

function App() {
  useEffect(() => {
    if (!didInit) {
      didInit = true;
      // ✅ Only runs once per app load
      loadDataFromLocalStorage();
      checkAuthToken();
    }
  }, []);
  // ...
}

 

상태 변경에 대한 상위 컴포넌트 

Toggle 컴포넌트는 isOn 상태의 변화를 기다리고 있다. 

부모 컴포넌트에 알리고 싶을 때, onChange 이벤트를 노출하고 Effect에서 호출하고 있다. 

function Toggle({ onChange }) {
  const [isOn, setIsOn] = useState(false);

  // 🔴 Avoid: The onChange handler runs too late
  useEffect(() => {
    onChange(isOn);
  }, [isOn, onChange])

  function handleClick() {
    setIsOn(!isOn);
  }

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

  // ...
}

당연히 이러한 방법은 이상적이지 않는 방식이다. 

Toggle이 먼저 상태를 업데이트하면 React가 화면을 업데이트한다. 그 다음 Effect를 실행하고 부모 컴포넌트에서 

전달된 onChange 함수를 호출한다. 

 

이러면 부모 컴포넌트는 자식의 상태를 업데이트하고 다른 렌더링을 업데이트합니다. 

function Toggle({ onChange }) {
  const [isOn, setIsOn] = useState(false);

  function updateToggle(nextIsOn) {
    // ✅ Good: Perform all updates during the event that caused them
    setIsOn(nextIsOn);
    onChange(nextIsOn);
  }

  function handleClick() {
    updateToggle(!isOn);
  }

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

  // ...
}

토글 컴포넌트와 부모 컴포넌트 모두에서 이벤트가 진행되는 동안 상태를 업데이트한다. 

React는 서로 다른 컴포넌트의 업데이트를 일괄 처리하므로 렌더링 업데이트를 한번만 발생시킨다.

 

isOn 상태를 완전히 제거하고 부모 컴포넌트로 받아서 사용할 수도 있다. 

// ✅ Also good: the component is fully controlled by its parent
function Toggle({ isOn, onChange }) {
  function handleClick() {
    onChange(!isOn);
  }

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

  // ...
}

부모 로직에서 더 많은 로직을 포함해야 하지만 전체적으로 걱정해야할 상태는 줄어든다. 

 

이 외에도 더 많는 정보가 있지만 그것은 직접 확인하시는 것으로! 

 

You Might Not Need an Effect – React

The library for web and native user interfaces

react.dev

 

반응형

'React > 실험실' 카테고리의 다른 글

[React] 아이폰 100vh 오류  (2) 2023.12.30
[React] Children  (0) 2023.12.10
[React] Swiper 잘 쓰기 - AutoPlay Pause Resume  (1) 2023.11.04
[React] 빌어먹을 iOS - vh 편  (0) 2023.08.29
[React] 메일 템플릿 만들기  (1) 2023.08.07