[TIL] 당신은 useEffect가 필요없을 수도 있다!

기성·2024년 8월 19일
1

TIL

목록 보기
40/81

useEffect

저번 React-Hooks 정리 글에서 useEffect에 대해서 간략하게 정리했었는데 여기서 한번 간단하게 다시 정리하면 외부 시스템과 컴포넌트를 동기화하는 React Hook 이다. 동시에 문서에서는 외부시스템이 관여하지 않는 경우에는 Effect가 필요 하지 않다고 말하고 있다. 이런 불필요한 Effect 로직들을 제거하면 코드를 더 쉽게 따라갈 수 있고 실행 속도가 빨라지며, 에러 발생 가능성이 줄어든다고 한다. 이하로는 어떤 점들이 Effect를 사용하는데에 있어 헷갈리고 Effect가 필요하지 않은 경우인지 알아보자.

props 또는 state에 따라 state 업데이트하기

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

  // 🔴 피하세요: 중복된 state 및 불필요한 Effect
  const [fullName, setFullName] = useState('');
  useEffect(() => {
    setFullName(firstName + ' ' + lastName);
  }, [firstName, lastName]);
  // ...
  
  // ✅ 렌더링 중에 계산하기
  const fullName = firstName+ ' ' + lastName;
}

기존 props나 state에서 계산할 수 있는 값은 state에 넣지 말고 렌더링 중에 계산하게 해라. 이렇게 하면 코드가 더 빠르고, 간단하고, 에러가 덜 발생한다.

비용이 많이 드는 계산 캐싱하기

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

  // 🔴 피하세요: 중복된 state 및 불필요한 효과
  const [visibleTodos, setVisibleTodos] = useState([]);
  useEffect(() => {
    setVisibleTodos(getFilteredTodos(todos, filter));
  }, [todos, filter]);
  
  // ✅ getFilterdTodos가 느리지 않다면 괜찮다.
  const visibleTodos = getFilteredTodos(todos,filter);
  
  // ✅ useMemo로 메모이제이션하기
  const visibleTodos = useMemo(()=>getFilteredTodos(todos,filter),[todos,filter]);
  // ...
}

위의 Hooks들을 사용하는 코드는 괜찮긴 하지만 getFilteredTodos()가 느리거나 todos가 많을 경우 newTodo와 같이 관련이 없는 state변수가 변경된 경우 getFilteredTodos()를 실행하고 싶지 않을 것이다. 이는 useMemo()를 통해 계산을 캐싱할 수 있다.

prop 변경 시 모든 state 초기화

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

  // 🔴 피하세요: Effect에서 prop 변경 시 state 초기화
  useEffect(() => {
    setComment('');
  }, [userId]);
  // ...
}
//////////////////////////////////////////////////
export default function ProfilePage({ userId }) {
  return (
    <Profile
      userId={userId}
      key={userId}
    />
  );
}

function Profile({ userId }) {
  // ✅ 이 state 및 아래의 다른 state는 key 변경 시 자동으로 재설정됩니다.
  const [comment, setComment] = useState('');
  // ...
}

일반적으로 React는 동일한 컴포넌트가 같은 위치에 렌더링 될 때 state를 보존한다. Profile 컴포넌트에 userId를 key로 전달하면 React가 userId가 다른 두 개의 Profile 컴포넌트를 state를 공유해서는 안 되는 두 개의 다른 컴포넌트로 취급하도록 요청하는 것이다. userId로 설정한 key가 변경될 때마다 React는 DOM을 다시 생성하고 Profile 컴포넌트와 그 모든 자식의 state를 재설정한다. 이제 프로필 사이를 탐색할 때 comment 필드가 자동으로 비워진다.

prop이 변경될 때 일부 state 조정하기

prop이 변경될 때 일부만 state를 변경하고 싶을 수도 있다. 이때는 Effect보다는 렌더링 중에 계산하도록 한다.

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

  // 🔴 피하세요: Effect에서 prop 변경 시 state 조정하기
  useEffect(() => {
    setSelection(null);
  }, [items]);
  // ...
  
  // ✅더 좋습니다: 렌더링 중 state 조정
  const [prevItems, setPrevItems] = useState(items);
  if (items !== prevItems) {
    setPrevItems(items);
    setSelection(null);
  }
}

이벤트 핸들러 간 로직 공유

장바구니에 담을 때나 구매할때 마다 알람을 주고 싶은 예시가 있다. 이때 두 버튼의 클릭 핸들러에서 모두 알람을 주는 showNotification()함수를 useEffect에서 호출하고 싶을 수 있다. 그러나 이 또한 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');
  }
  // ...
}
// ✅ 좋습니다: 이벤트 핸들러에서 이벤트별 로직이 호출됩니다.
  function buyProduct() {
    addToCart(product)<;
    showNotification(`Added ${product.name} to the shopping cart!`);
  }

  function handleBuyClick() {
    buyProduct();
  }

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

POST 요청 보내기

Form 컴포넌트에서 두 가지의 POST 요청을 전송할 예정이다. Submit버튼을 클릭하면 /api/register로, 마운트 될 때 /analytics/event로 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요청을 하도록 변경해준다.

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

연쇄 계산

때때로 다른 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호출 사이에 각각 렌더링을 다시 해야한다. 위에서 처럼 렌더링 중에 가능한 것을 effect외부에서 미리 계산할 수 있다.

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

  // ...

애플리케이션 초기화

일부 로직은 앱이 로드될 때 한 번만 실행되어야 한다. 그렇다고 useEffect에 배치하면 두 번 실행 되는 것을 알 수 있다.

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

그럴 때는 flag를 통해 한 번만 실행하도록 할 수 있다. 혹은 모듈 초기화 중이나 앱이 렌더링 되기 전에 실행할 수도 있다.

let didInit = false;

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

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

function App() {
  // ...
}

state 변경을 부모 컴포넌트에게 알리기

boolean 타입을 가진 props를 받아 Toggle컴포넌트를 작성한다 가정하고 클릭 혹은 드래그를 통해 토글하기 위해서 state를 변경하고 onChange이벤트를 노출하고 Effect를 호출하는 방법이 있지만 onChange 핸들러가 너무 늦게 실행된다.

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

  // ...
}

이 또한 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);
    }
  }

  // ...
}

혹은 부모 컴포넌트에서 한번에 일괄 처리를 한다.

// ✅ 이것도 좋습니다: 컴포넌트는 부모에 의해 완전히 제어됩니다.
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]);
  // ...
}

부모 자식 컴포넌트 사이에서 데이터를 주고 받고 하고 싶다면 부모에서부터 props를 넘겨라.

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

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

외부 저장소 구독하기

React state 외부의 일부 데이터를 구독해야 할 수도 있다. 이 데이터는 React가 모르는 사이에 변경 될 수 있으므로 컴포넌트를 수동으로 구독해야하는데 useEffect를 통해 수행할 수 있다.

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

하지만 이 또한 이 작업만을 위한 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() {
  // ✅ 좋습니다: 내장 Hook으로 외부 스토어 구독하기
  return useSyncExternalStore(
    subscribe, // 동일한 함수를 전달하는 한 React는 다시 구독하지 않습니다.
    () => navigator.onLine, // 클라이언트에서 값을 얻는 방법
    () => true // 서버에서 값을 얻는 방법
  );
}

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

이 훅으로 대체가 가능하다.

데이터 가져오기

보통 데이터를 가져올 때 우리는 useEffect를 통해 가져오고 있다.

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

데이터 가져오기를 이벤트 핸들러로 옮길 필요는 없다.
하지만 위의 코드에는 버그가 있다고 한다. "hello"를 빠르게 입력했을 때 query가 "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);
  }
  // ...
}

이렇게 하면 Effect가 데이터를 가져올 때 마지막 요청 제외하고 모든 응답을 무시하게 된다.

Effect가 필요하지 않거나 정리할 수 있는 순간이 이렇게나 많다. 앞으로 useEffect를 사용하기 전에 내가 정말 필요한 상황인지 생각해 보는 시간을 가져야 겠다.

내용 출처: https://ko.react.dev/learn/you-might-not-need-an-effect

profile
프론트가 하고싶어요

0개의 댓글