React Learn - useEffect 보다 다른 것을 사용해야하는 상황들

ChoiYongHyeun·2024년 3월 2일
2

리액트

목록 보기
13/31
post-thumbnail

해당 게시글은 리액트 공식문서인 You Might Not Need an Effect 를 보고 정리했습니다.

해당 공식 문서와 관련된 리액트 컨퍼런스인 GoodBye, useEffect 도 참고하였습니다.


useEffect 는 컴포넌트의 라이프 사이클과 관련이 없다.

useEffect 는 컴포넌트의 lifecycle 과 관련된 훅이 아니다.

컴포넌트는 Mounting , Updating , Unmounting 3가지 경우에 따라 동일한 3 단계를 갖는다.

  1. Render Phase

컴포넌트들이 호출되며 Virtual DOM 을 구성하는 단계이다.

  1. Pre-Commit phase

이전에 생성된 Virtual DOM 이 존재하지 않는다면 Pre-Commit phase 를 건너뛴다.

최근에 생성된 Virtual DOM 이 존재한다면 , 현재 만들어진 Virtual DOM 과 얕은 비교를 통해

변경이 일어난 컴포넌트를 찾는다.

변경이 일어난 컴포넌트만을 찾아, 최소한의 DOM 조작으로 빠르게 Actual DOM 을 렌더링 하고자 한다.

  1. Commit phase

Pre-Commit phase 단계에서 발생한 변경 사항들을 Actual DOM 에 적용한다.

변경된 Actual DOM 은 사용자 UI 에 나타나며 Commit phase 단계에서 사용자는 변경된 사항을 확인 할 수 있다.

이것이 컴포넌트의 라이프 사이클이다.

해당 라이프 사이클 안에서 리액트 훅들은 작동한다.

그러면 useEffect 는 언제 작동할까 ?

Commit phase 단계 이후에 작동한다.

useEffect 콜백 함수의 반환함수인 clean up methodrender phase 이전에 작동한다.

clean up method 는 컴포넌트가 update 되기 이전이나 unmount 되기 전에 작동하여
useEffect 가 호출됐을 때 발생한 Effect 들을 지우는 역할을 한다.


useEffect 가 필요한 이유

이전 게시글에서도 몇 번 이야기 하였으나 리액트의 렌더링 단계가 모두 지난 후 (render , commit)

작동하는 useEffect 는 왜 필요할까 ?

1. 컴포넌트를 외부 환경과 동기화 하기 위해

리액트 컴포넌트는 useEffect 를 이용하지 않을 경우, 외부 상태와 동기화 할 수 없다.

애초에 외부 상태가 변경되는 Effect 자체가 컴포넌트의 호출로 인해 발생하기 때문이다.

하지만 우리는 때때로 외부 상태와 관련된 로직을 컴포넌트 내부에서 처리하고 싶을 때가 있다.

예를 들어 변경된 Actual DOM 을 조작하는 로직을 컴포넌트 내부에 정의하고 싶다든지 말이다.

생성된 Actual DOM 의 노드에 애니메이션 효과를 준다거나
이벤트 핸들러를 장착한다는 행위들처럼 말이다.

2. 컴포넌트가 렌더링만 하여 최대한 빠르게 UI 에 실제 Actual DOM 을 렌더링 하고 싶을 때

컴포넌트가 Virtual DOM 을 빠르게 구성하고 차이점을 발견해 Actual DOM 에 적용을 빠르게 하여야

최대한 UI 에 화면을 띄울 수 있다.

하지만 컴포넌트 렌더링 단계에서 시간이 오래 걸리는 로직들을 처리하는 경우

예를 들어 비동기적으로 네트워크 요청을 보내거나 , 외부 API 를 이용하는 경우에 말이다.

Virtual DOM 을 생성하는 시간이 지연되며, 실제 UI 에 화면을 띄우는데 오랜 시간이 걸릴 것이다.

자바스크립트는 싱글 스레드 엔진이기에, 컴포넌트 호출이 지연되는 동안 모든 동작이 멈추기 때문이다.

출처 : How to Create a Loading Animation in React from Scratch

빠르게 컴포넌트 호출을 완료하여 미완된 Actual DOM 이 띄운 후

변화에 따라 컴포넌트를 재호출하는 편이 UX 입장에선 훨씬 도움이 될 것이다.


useEffect 가 우리를 괴롭혀요


??? : 감정과 관련된 수 많은 이모지들이 존재합니다

??? : 이렇게 생긴 웃음 이모지도 보셨나요 ?

??? : 당신을 괴롭히는 useEffect 의 웃음입니다.

useEffect 는 종종 , 혹은 훨씬 자주 우리를 괴롭힌다.

컴포넌트가 렌더링 되는 동안 다른 곳에 영향을 끼치지 않게 pure component 를 유지하여

흐름을 추적하기 쉽게 만들도록 해두고서는

컴포넌트 내에서 pure 하지 못하게 다양한 곳에 Effect 를 미치는 useEffect 를 사용해야 한다니

이는 골치 아픈게 이만 저만이 아니다.

오히려 즉각적으로 상태 변화와 UI 를 대응시키기 위한 Declarative 방식의 리액트가

더욱 복잡해보이는 정도이다.

그 이유는 useEffect 를 실제로 사용 할 때 복잡한 로직이 들어가게 되거나

useEffect 를 사용하지않고 렌더링 단계에서 처리 할 수 있는 로직들을

useEffect 를 이용하고자 하니 생기는 문제들이다.

useEffect 를 사용 할 때는 신경 써야 할 부분이 이만 저만이 아니다.

  1. 우선 컴포넌트가 재호출될때마다 이전에 실행된 useEffect 가 영향을 끼치고 있지 않도록 CleanupMethod 도 정의해둬야 한다.

  2. 컴포넌트가 재호출될 때 마다 항상 useEffect 가 시행되지 않도록 deps 배열도 신경써야 한다.

  3. useEffect 로 인해 상태가 변경되고 이로인해 컴포넌트가 또 재호출되고 또 useEffect 가 호출되고 .. 와 같은 무한 로직이 일어나지 않도록 신경써야 한다.

우리는 useEffect 를 사용하지 않아도 되는 경우에는 최대한 useEffect 를 지양하여

컴포넌트가 우리의 예상대로 움직 일 수 있도록 해야 한다.


useEffect 보다 다른 로직을 사용하는게 나은 경우

렌더링 단계에서 처리 가능한 로직일 경우

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

현재 컴포넌트는 fullName 이란 state 를 정해두고 컴포넌트가 모두 호출된 후 useEffect 를 이용하여 상태를 업데이트 하고 있다.

다행히 해당 컴포넌트에서 firstName , lastNamedeps 배열에 담아두었기 때문에

무한적으로 render -> useEffect -> render ... 가 일어나는 현상은 막을 수 있지만

결국 render -> commit -> (useEffect 발동 ) render -> commit 이 일어나

불필요한 단계가 두 번 일어나는 모습을 볼 수 있다.

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

렌더링 단계에서 처리 가능한 로직은 렌더링 단계에서 처리하여

불필요한 render commit 의 반복을 지양하도록 하자

state 를 초기화 하고 싶을 때

컴포넌트는 들어오는 props 가 변경되면 새롭게 렌더링 된다.

이 때 useState 로 정의된 stateprops 가 변경되더라도

이전 state 를 기억하고 있는다.

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

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

만약 어떤 유저에게 보낼 메시지를 입력하다가, 다른 유저를 선택했을 경우

이전 유저에게 보내던 메시지가 남아있어 실수로 전송이 되는 경우

끔찍한 일이 발생 할 수 있다.

이에 개발자는 항상 컴포넌트를 렌더링 이후에는 state 를 초기화 하는데 userId 가 변하지 않으면 초기화 하지마 ~ 라며 deps 배열에 값을 담은 useEffect 를 선언했다.

뭐, 문제가 되는 것은 아니지만 useEffect 는 컴포넌트의 흐름을 이해하는 것을 막기에

더 근본적인 방법을 이용하자

export default function ChatPage({ userId }) {
  return (
    <Chat
      userId={userId}
      key={userId}
    />
  );
}

function Chat({ userId }) {
  // ✅ This and any other state below will reset on key change automatically
  const [comment, setComment] = useState('');
  // ...
}

차라리 컴포넌트에 key 값을 이용해, userId 가 변경이 되면 컴포넌트를

아예 새롭게 생성해 state 를 초기화 하자

이 편이 훨씬 컴포넌트의 흐름을 이해하는 것이 쉽다.

stateprop 에 따라 변경하고 싶을 때

List 라는 컴포넌트를 만들어 items 라는 배열을 props 로 받는다.

이 때 items 에서 어떤 특정한 값이 존재하는 경우엔 selection state 를 변경하고자 한다.

이 때 items 가 항상 새롭게 들어오면 selection 을 초기화 하고 찾는다고 하자.

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

  // 🔴 Avoid: Adjusting state on prop change in an Effect
  useEffect(() => {
    setSelection(null);
  }, [items]);
  // ...
  
  // 이후 Selection 을 변경시키는 어떤 로직들 .. 
}

렌더링 단계에서는 Selection 을 렌더링 단계에서 어떻게 변경하고자 하고

만약 items 가 변경되면 selection statenull 로 다시 초기화 하고자 한다.

이것을 useEffect 를 사용하지 않고선 어떻게 할 수 있을까 ?

내가 원하는 것은, items 가 이전에 들어온 것과 같다면 selection 을 초기화 하지 않는 것이다.

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);
  }
  // 이후 Selection 을 변경시키는 어떤 로직들 .. 

그럼 이전 아이템을 담는 pervItems 상태를 만들고, 호출 될 때 마다 이전 아이템과 비교하면 어떨까 ?

이전보다는 낫다.

컴포넌트의 흐름을 이해하는 것을 방해하는 useEffect 는 빠졌으니까

하지만 이 또한 이상적이지는 않다.

만약 이전 items 와 다르다면 결국 prevItems , selection 의 상태가 변경되니

render -> (previtems , selection 변경) render -> commit 가 된다.

렌더링 단계에서 state 를 변경시키는 행위 또한 컴포넌트가 pure 하지 못하게 되기에 지양해야 한다.

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

차라리 select 시킬 인수를 상태로 정의하고 렌더링 단계에서

state 가 아닌 다른 변수에 값을 저장하는 것이 훨씬 이상적이다.

불필요한 useEffect 를 이용하지 않는 것도 중요하지만, 렌더링 단계에서 상태를 변경시키지 않는 것도 중요하다. 불필요한 re-render 를 막고, 컴포넌트의 흐름을 이해 하기 편해진다.

이벤트 핸들러에서 처리 할 수 있는 로직을 이용 할 때

Actual DOM 에 영향 (Effect)을 끼치는 행위는 두가지다.

하나는 컴포넌트가 새롭게 렌더링 되거나 이벤트에 의해 상태가 변경되고 컴포넌트가 새롭게 렌더링 되는 경우이다.


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

ProductPage 컴포넌트는 어떤 상품이 장바구니에 담겨있으면 알림을 보낼 때

useEffect 를 이용했다.

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

이는 그냥 장바구니로 담는 행위에서 해줌으로서

Effect 를 발생시키는 주체가 useEffect 가 아닌 이벤트 핸들러가 담당 하도록 한다.

이를 통해 Effect 의 주체를 명확하게 할 수 있다.

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' });
  }, []); // deps 를 비워둬 컴포넌트가 호출 될 때 한 번 로그를 남기도록 함 

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

Form 컴포넌트는 호출 될 때 서버에 로그를 남기고, 실제 jsonToSubmit 을 서버에 보낸다.

이 때 submit 버튼을 누르면 JsonToSubmit 의 상태가 변경되면서 컴포넌트가 새로 호출되고

useEffect 가 호출되면서 POST 요청이 보내진다.

submit 버튼을 누르기 전엔 jsonToSubmitnull 이라 useEffect 가 보내지지 않는다.

컴포넌트가 처음 호출 될 때 서버에 로그를 남기기 위해서 useEffect 를 이용하는 것은 올바른 방법이다.

(한 번만 호출된다면 말이다)

하지만 두 번째 useEffect 는 불필요하게 보인다.

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 를 끼치도록 위임해주자


useEffect chain

자 카드게임을 하는 컴포넌트를 구성할 것이다.

카드 게임의 규칙은 이렇다.

  1. 5번의 라운드 동안 게임을 진행하며 게임이 5번 초과일 때는 게임이 종료된다.
  2. 사용자는 게임하는 동안 카드를 뽑는데, goldCard 를 3개 이상 뽑으면 뽑은 카드를 다시 원상복구하고 다음 게임을 진행한다.

뭐 이런 게임이다.

useEffect 를 이용해서 필요한 로직들을 구현해보자

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
  
  // 1. 카드가 골드 카드면 골드카드 증가
  useEffect(() => {
    if (card !== null && card.gold) {
      setGoldCardCount(c => c + 1);
    }
  }, [card]);

  // 2. 뽑은 골드 카드가 3개가 넘어가면 다음 게임으로 진행하며, 골드카드는 원상 복구
  useEffect(() => {
    if (goldCardCount > 3) {
      setRound(r => r + 1)
      setGoldCardCount(0);
    }
  }, [goldCardCount]);

  // 3. 게임 라운드가 5번을 넘어가면 게임을 종료하기
  useEffect(() => {
    if (round > 5) {
      setIsGameOver(true);
    }
  }, [round]);

  // 4. isGameOver 의 상태가 변경되면 게임 종료되었단 알람 띄우기 
  useEffect(() => {
    alert('Good game!');
  }, [isGameOver]);

  function handlePlaceCard(nextCard) {
    if (isGameOver) {
      throw Error('Game already ended.');
    } else {
      setCard(nextCard);
    }
  }

  // ...

이게 뭐냐

이렇게 useEffect 가 계속하여 나오는 경우를 useEffect chain 이라고 한다.

물론 여러가지 useEffect 를 사용하는 것은 문제가 아닐 수 있지만

위 코드는 두 가지 문제가 존재한다.

첫 번째는 굳이 commit 단계 이후에 발생 할 이유가 없는 로직들이다.

두 번째는 각 useEffect 는 순차적으로 발생하며 Effect 가 발생하여 state 가 변경되면 새롭게 render -> commit -> state change -> render .. 의 흐름으로 진행된다.

다행히 deps 를 설정해둬 무한 궤도에 빠지지 않는다.

문제들을 해결해보자

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

  // ...

그냥 렌더링 로직 내에서 끝낼 수 있다.

이렇게 하니 컴포넌트의 흐름을 추적하기도 훨씬 쉬워졌다.

어플리케이션을 초기화 하고 싶을 때

컴포넌트가 처음 렌더링 되었을 때 딱 한 번만 시행하고 싶은 로직들이 있다.

예를 들어 쿠키를 설정한다거나, 첫 접속 로그를 보낸다거나 하는 행위들 말이다.

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

하지만 이러한 로직들은 딱 한번만 적용되어야 하는데 , 이는 몇 가지 문제가 존재한다.

  1. Window , document 처럼 Commit 단계 이후에 적용되어야 하는 로직일 경우에는 적용이 되지 않는다.
  2. StricMode 에서는 컴포넌트가 처음 렌더링 될 때 에도 Mount - Demount - Mount 가 일어나기 때문에 두 번 시행된다.

해결하기 위해서는 두 가지 방법이 존재한다.

let didInit = false; // 플래그 설정 

function App() {
  useEffect(() => {
    if (!didInit) { // 첫 실행일 경우에만 실행되도록 하기 
      didInit = true; // 첫 실행 인후엔 플래그를 true 로 설정
      // ✅ Only runs once per app load
      loadDataFromLocalStorage();
      checkAuthToken();
    }
  }, []);
  // ...
}

한 가지 방법은 플래그를 이용하여, 첫 실행에만 useEffect 가 발동되도록 하게 하는 것이다.

이는 좋은 방법이기도 하지만 문제점은 App 컴포넌트가 렌더링 될 대 마다

useEffect 문이 호출된다는 것이다.

(물론 아무런 영향을 미치지 않는다 하더라도)

if (typeof window !== 'undefined') { // Check if we're running in the browser.
   // ✅ Only runs once per app load
  checkAuthToken();
  loadDataFromLocalStorage();
}

function App() {
  // ...
}

이에 아예 컴포넌트 외부에서 , 특히 엔트리 파일이 로드 될 때 한 번만 호출되도록

하는 방법도 존재한다.

컴포넌트의 상태를 변경하고, 부모 컴포넌트에게도 변경된 상태를 알리는 상황

컴포넌트의 상태를 변경시키는데 있어 useEffect 를 사용하는 것 만큼 최악의 선택이 없다.

이는 두 번의 render pass 를 가지게 되기 대문이다.

Toggle 컴포넌트의 예시를 들어보자

Toggle 컴포넌트는 부모 컴포넌트로부터 onChange 메소드를 받아 사용하며,

onChange 메소드는 Toggle 컴포넌트의 상태에 따라 부모 컴포넌트의 상태도 같이 변경시키는 어떤 메소드라고 가정하자

토글 컴포넌트는 본인만의 state 를 가지고 있다. 이 때 토글이 클릭 되거나 , 드래그됨에 따라 토글의 상태를 변경한다고 해보자

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

  // 🔴 Avoid: The onChange handler runs too late
  useEffect(() => {
    // 2. useEffect 에서는 부모 컴포넌트에게 자식 컴포넌트의 상태가 변경됨을 알려줌 
    onChange(isOn);
  }, [isOn, onChange])

  // 1. 이벤트 핸들러에서는 자식 컴포넌트의 상태 변경 
  function handleClick() {
    setIsOn(!isOn);
  }

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

  // ...
}

위 예시에서 이벤트 핸들러들로 현재 토글의 state 를 변경하고 , useEffect 로 부모 컴포넌트에게 자식 컴포넌트의 상태가 변경되었음을 알려준다.

이 컴포넌트는 재렌더링 되게 되면

Toggle event -> Toggle re-render -> Toggle commit -> useEffect call -> parent Component re-render -> Toggle re-render -> commit 의 상황을 겪게 된다.

(자식 컴포넌트는 부모 컴포넌트가 re-render 되면 같이 re-render 된다.)

리액트를 사용하는 이유가 최소한의 Actual DOM 조작으로 렌더링 성능을 높이기 위함인데

Actual DOM 조작이 일어나는 commit 단계를 두 번씩이나 할 필요가 없다.

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

  // ...
}

차라리 Toggle 컴포넌트의 re-render 가 일어날 때 parent componentre-render 도 동시에 일어나도록 변경해주도록 하자

이렇게 하게 되면

Toggle event -> Toggle re-render -> parent component re-render -> Toggle re-render -> Commit 단계로 일어나

한 번의 Commit 만으로 최소화 할 수 있다.

부모 컴포넌트가 re-render 가 일어날 때 자식 컴포넌트의 re-render 가 일어나 어쩔 수 없이 Toggle 컴포넌트가 반복적으로 re-render 가 일어났다.
하지만 컴포넌트의 re-render 는 단순한 자바스크립트 객체형태인, 리액트 엘리먼트를 생성하는 것이라 성능에 대한 오버헤드가 크지 않다.

부모 컴포넌트에게 데이터를 전달하는 경우

function Parent() {
  const [data, setData] = useState(null);
  // ...
  return <Child onFetched={setData} />;
}

function Child({ onFetched }) {
  const data = useSomeAPI();
  // 🔴 Avoid: Passing data to the parent in an Effect
  useEffect(() => {
    if (data) {
      onFetched(data);
    }
  }, [onFetched, data]);
  // 이후 data 를 이용해 무엇인가를 render 하는 반환 값 .. 
}

위 예시는 자식 컴포넌트에서 useSomeAPI 를 통해 데이터를 패칭해오고

useEffect 를 이용해 자식 컴포넌트 단에서 부모 컴포넌트의 상태를 변경시킨다.

이는 리액트 컴포넌트의 기본 개념 중 하나인

데이터는 부모 컴포넌트로부터 자식 컴포넌트에게 하향식으로 전달된다

라는 규칙에 어긋나는 행위이다.

이렇게 규칙에 어긋나도록 구성된 컴포넌트들의 문제점은

부모 컴포넌트와 자식 컴포넌트 간의 상태 변경 방향이 기존과 달라 상태의 변경을 추적하기가 매우 어렵고

하위 컴포넌트가 부모 컴포넌트와 강한 의존성을 가지고 있기 때문에 재활용이 매우 어렵다.

function Parent() {
  const data = useSomeAPI();
  // 차라리 부모 컴포넌트에서 데이터를 불러오고 
  // ✅ Good: Passing data down to the child
  return <Child data={data} />;
}

function Child({ data }) {
  // 자식 컴포넌트 단에서 무엇인가를 렌더링 하자 
}

차라리 부모 컴포넌트에서 data 를 정의하고 자식 컴포넌트에게 데이터를 하향식으로 전달해주도록 하자

이를 통해 데이터의 흐름을 단방향적으로 유지하여 컴포넌트들의 흐름을 예측 가능하게 하자

외부에 저장된 데이터를 이용하는 경우

컴포넌트들은 본래 부모 컴포넌트로부터 전달 받은 props 를 이용해 데이터를 전달 받아 사용하였다.

그러나 가끔 컴포넌트들은 리액트 내부의 데이터들이 아닌

외부에 저장된 데이터들을 이용하고자 하는 경우들도 존재한다. 예를 들어 로컬 스토리지에 저장된 데이터를 사용하거나 하는 행위 등 말이다.

이 때 외부에 저장된 데이터를 이용하기 위해선 commit 단계 이후에 호출되도록 하여야만 외부 API 를 사용 할 수 있다.

그렇기에 외부에 저장된 데이터를 이용하기 위해서는 useEffeect 사용을 피할 수 없다.

function useOnlineStatus() {
  // Not ideal: Manual store subscription in an Effect
  const [isOnline, setIsOnline] = useState(true); 
  useEffect(() => { 
    // 1. Commit 단계 이후 외부 API 를 이용해 state 를 변경
    function updateState() {
      setIsOnline(navigator.onLine);
    }

    updateState(); 
	// 2. 이벤트 핸들러를 window 에 부착하여 
    // 외부에서 커스텀훅의 상태를 변경하도록 변경 
    window.addEventListener('online', updateState);
    window.addEventListener('offline', updateState);
    return () => {
      window.removeEventListener('online', updateState);
      window.removeEventListener('offline', updateState);
    };
  }, []); 
  return isOnline; // 3. 외부에서 커스텀훅의 상태를 변경하고 변경된 상태를 반환함 
}

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

useOnlinestatus 커스텀 훅은 useEffect 를 이용해 외부 APInavigator 를 이용하여

이벤트 핸들러를 선언하고 , 외부 API 에 의해 변경되는 isOnline 이라는 state 를 반환하는 커스텀 훅이다.

이런 커스텀훅이 왜 필요한지에 대한 생각을 먼저 해보자면

ChatIndicator 라는 컴포넌트에서 사용할 isOnline 의 값이 렌더링 될 때

외부에 저장된 데이터의 값과 동기화 한 후 렌더링 하고 싶기 때문이다.

useOnlineStatus 와 같은 커스텀 훅은 ChatIndicator 가 호출될 때 마다
새롭게 평가되고 선언되는 것이 아닌 최초 선언 이후에는 반환값인 isOnline 만 반환한다.

이에 ChatIndicator 컴포넌트는 항상 외부에 저장된 값 (사실은 외부 API 에 의해 상태가 변경되는) isOnline 값을 받기에

외부 데이터와 항상 동기화 된 상태로 렌더링을 진행 할 수 있다.

이를 위해 이러한 과정이 잘 구현되어 있는 훅인 useSyncExternalStore 훅을 제공한다.

function subscribe(callback) {
  window.addEventListener('online', callback);
  window.addEventListener('offline', callback);
  return () => {
    window.removeEventListener('online', callback);
    window.removeEventListener('offline', callback);
  };
}

function useOnlineStatus() {
  // ✅ Good: Subscribing to an external store with a built-in Hook
  return useSyncExternalStore(
    subscribe, // React won't resubscribe for as long as you pass the same function
    () => navigator.onLine, // How to get the value on the client
    () => true // How to get the value on the server
  );
}

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

하 나중에 좀 더 각 잡고 이 것에 대해 공부해봐야겠다.
공부하다 보니 tearing 이니 , concurrent feature 니 개념이 더 복잡하다.

API 요청을 보낼 때 race condition 을 주의하자

function SearchResults({ query }) {
  const [results, setResults] = useState([]);
  const [page, setPage] = useState(1);

  useEffect(() => {
    // 🔴 Avoid: Fetching without cleanup logic
    fetchResults(query, page).then(json => {
      setResults(json);
    });
  }, [query, page]);

  function handleNextPageClick() {
    setPage(page + 1);
  }
  // ...
}

이 컴포넌트는 부모 컴포넌트에서 전달받은 query 값에 따라 API 요청을 보내고

받은 요청값에 따라 results state 를 변경한다.

예를 들면, 사용자가 키보드로 어떤 것을 검색하면

해당 검색어에 따른 연관검색어들을 보내준다고 해보자

위 컴포넌트에서 문제는 useEffect 를 이용한 점이 문제가 아니다.

오히려 race condition 문제를 해결하기 위해서 useEffect 를 이용하는 것이 효율적일 수 있다.

사용자가 성수동 맛집 이라는 키워드를 검색한다고 해보자

성 -> 성과 관련된 연관 검색어
성수 -> 성수와 관련된 연관 검색어
...

성수동 맛-> 성수동 맛과 관련된 연관 검색어
성수동 맛집-> 성수동 맛과 관련된 연관 검색어

이렇게 띄어쓰기를 포함하여 6번의 API 요청을 보내고 받은 요청값에 따라서 results 값을 변경하게 되는데

resultsstate 는 요청이 도착한 순서에 따라 변경되게 된다.

만약 성 일때 보낸 요청이 성수동 맛집에 대한 요청보다 늦게 도착했다면

results 는 성 일때 보낸 요청에 대한 값을 담고 있게 된다.

이와 같은 현상을 요청이 도착한 순서에 따라 결정되는, race condition 문제라고 부른다.

이를 해결하기 위해선 저번 게시글에서 이야기했던 flag 를 설정해야 한다.

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

해당 컴포넌트는 propsquery 값이 변경 될 때 마다 재호출된다.

재호출 될 때 마다 컴포넌트가 업데이트 될 텐데

useEffect 내에서 업데이트 되기 전, flag 를 꺼줌으로서

아직 도착하지 않은 요청이 다음 컴포넌트의 state 에 영향을 미치지 않도록 해주자

하지만 이러한 문제는 여전히 6번의 API 요청을 보낸다는 단점이 존재한다.
이러한 문제를 해결하기 위해 고안된 다양한 프레임워크를 이용해서 문제를 해결할 수 있도록 하자

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

만약 프레임워크를 이용하지 않는다고 하더라도

위의 flag 와 관련된 요청 부분을 따로 커스텀 훅으로 이용해 컴포넌트를 구성 할 때 사용해준다면

코드를 더욱 간결하게 유지 할 수 있다.

profile
빨리 가는 유일한 방법은 제대로 가는 것이다

0개의 댓글