sohyeon kim

** 작성 중 ** [React] 리액트 공식문서 정리 4(2) : 탈출구 - useEffect, 클린업 함수, Effect 가 필요하지 않은 때, Handler 를 사용해야 할 때 본문

React

** 작성 중 ** [React] 리액트 공식문서 정리 4(2) : 탈출구 - useEffect, 클린업 함수, Effect 가 필요하지 않은 때, Handler 를 사용해야 할 때

aotoyae 2024. 12. 6. 14:43
728x90

 

 

💡 리액트 공식문서 정리 : 탈출구 - Effect 로 값 동기화하기, Effect 가 필요하지 않은 경우

 

1. Effect 로 값 동기화하기 : 컴포넌트를 외부 시스템과 동기화하는 방법

이벤트 핸들러와 달리 Effect 를 사용하면 렌더링 후에 코드를 실행해 React 외부의 시스템과 컴포넌트를 동기화할 수 있다.

e.g. 채팅 서버에 대한 연결 설정, 컴포넌트가 화면에 나타났을 때 분석 로그 전송

 

Effect 란 무엇이고 이벤트와 어떻게 다른가?

우선 컴포넌트 내부의 2가지 로직 유형에 대해 알아보자.

  • 렌더링 코드를 주관하는 로직
    • 컴포넌트 최상단에 위치
    • props 와 state 를 적절히 변형해 결과적으로 JSX 를 반환
    • 렌더링 코드 로직은 순수해야 함
    • 수학 공식과 같이 결과만 계산해야 하고, 그 외엔 아무것도 하지 않아야 한다.
  • 이벤트 핸들러
    • 단순한 계산 용도가 아닌 무언가를 하는 컴포넌트 내부의 중첩 함수
    • input 을 업데이트하거나, 제품을 구입하기 위해 HTTP POST 요청을 보내거나, 사용자를 다른 화면으로 이동시키거나 한다.
    • 이벤트 핸들러에는 특정 사용자 작업(e.g. 버튼 클릭, 입력)으로 발생하는 '부수 효과'를 포함한다.
      (이러한 부수 효과가 프로그램 상태를 변경한다.)

가끔은 이것으로 충분하지 않다. 화면에 보일 때마다 채팅 서버에 접속해야 하는 ChatRoom 컴포넌트를 생각해 보자.

서버에 접속하는 것은 순수한 계산이 아니고 부수 효과를 일이키기 때문에 렌더링 중엔 할 수 없다.

하지만 클릭 한 번으로 ChatRoom 이 표시되는 특정 이벤트는 없다.

 

Effect 는 렌더링 자체에 의해 발생하는 부수 효과를 특정하는 것으로, 특정 상호작용이 아닌 렌더링에 의해 직접 발생한다.

채팅에서 메시지를 보내는 것은 이벤트다. 사용자가 특정 버튼을 클릭함에 따라 발생하기 때문이다.

그러나 서버 연결 설정은 Effect 다. 이것은 컴포넌트의 표시를 주관하는 어떤 상호 작용과도 상관 없이 발생해야 하기 때문이다.

Effect 는 커밋이 끝난 후 화면 업데이트가 이루어지고 나서 실행된다.

이 시점이 Reacrt 컴포넌트를 외부 시스템(네트워크 or 써드파티 라이브러리와 같은)과 동기화하기 좋은 타이밍이다.

 

❗️ Effect 는 렌더링에 의한 부수 효과

 

Effect 를 작성하는 법

  • Effect 선언 : 기본적으로 Effect 는 모든 commit 이후 실행. 화면에 렌더링이 반영될 때까지 코드 실행을 '지연'시킨다.
  • Effect 의존성 지정 : 대부분의 Effect 는 모든 렌더링 후가 아닌 필요할 때만 다시 실행되어야 한다. 의존성을 지정해 이를 제어하자.
    e.g. 페이드 인 애니메이션은 컴포넌트가 나타날 때에만 트리거되어야 한다. 채팅 방에 연결, 연결 해제하는 것은 컴포넌트가 나타나거나 사라질 때 또는 방이 변경될 때만 발생해야 한다.
  • 필요한 경우 클린업 함수 추가 : 일부 Effect 는 수행 중이던 작업을 중지, 취소 또는 정리하는 방법을 지정해야 할 수도 있다.
    e.g. '연결'은 '연결 해제'가 필요하며, '구독'은 '구독 취소'가 필요하고, '불러오기(fetch)'는 '취소' 또는 '무시'가 필요하다.
    이런 경우 Effect 에서 클린업 함수(cleanup function)를 반환해 수행한다.

1️⃣ Effect 선언하기

function MyComponent() {
  useEffect(() => {
    // 이곳의 코드는 *모든* 렌더링 후에 실행됩니다
  });
  return <div />;
}

 

<VideoPlayer> 라는 React 컴포넌트를 살펴보자.

❌ 잘못된 코드

import { useState, useRef, useEffect } from 'react';

function VideoPlayer({ src, isPlaying }) {
  const ref = useRef(null);

  if (isPlaying) {
    ref.current.play();  // 렌더링 중에 이를 호출하는 것이 허용되지 않습니다.
  } else {
    ref.current.pause(); // 역시 이렇게 호출하면 바로 위의 호출과 충돌이 발생합니다.
  }

  return <video ref={ref} src={src} loop playsInline />;
}
export default function App() {
  const [isPlaying, setIsPlaying] = useState(false);
  return (
    <>
      <button onClick={() => setIsPlaying(!isPlaying)}>
        {isPlaying ? '일시정지' : '재생'}
      </button>
      <VideoPlayer
        isPlaying={isPlaying}
        src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
      />
    </>
  );
}

 

렌더링 중에 DOM 노드를 조작하려 하기 때문.

React 에서는 렌더링이 JSX 의 순수한 계산이어야 하며 DOM 수정과 같은 부수 효과를 포함해선 안된다.

게다가 VideoPlayer 가 호출될 때 해당 DOM 이 아직 존재하지 않는다.

➡️ 이를 해결하기 위해 부수 효과를 렌더링 연산에서 분리해 useEffect 로 감싸자.

 

⭕️ 올바른 코드

function VideoPlayer({ src, isPlaying }) {
  const ref = useRef(null);

  useEffect(() => {
    if (isPlaying) {
      ref.current.play();
    } else {
      ref.current.pause();
    }
  });

  return <video ref={ref} src={src} loop playsInline />;
}

 

이제 React 는 화면을 업데이트해 <video /> 태그가 올바른 속성과 함께 DOM 에 있는지 확인한다.

이후 Effect 를 실행하고 isPlaying 값에 따라 play() 또는 pause() 를 호출한다.

 

이 예시에서 React 상태와 동기화된 '외부 시스템'은 브라우저 미디어 API 로, 실제로 비디어 플레이어를 제어하는 것은

훨씬 복잡하다. play() 를 호출하는 것이 실패할 수 있으며, 사용자는 컴포넌트의 UI 가 아닌 브라우저 내장 컨트롤을 통해 동영상을 재생 또는 정지할 수 있다. 이 예시는 매우 단순화되었고 불완전한 것임을 유의하자.

 

❗️ 주의 : Effect 는 무한 루프를 만들어낼 수도 있다.

const [count, setCount] = useState(0);
useEffect(() => {
  setCount(count + 1);
});

 

Effect 는 렌더링의 결과로 실행된다. state 를 설정하면 렌더링이 트리거된다.

Effect 실행 > 상태 설정 > 재렌더링 발생 > Effect 다시 실행 > ...

외부 시스템이 없고 다른 상태에 기반해 상태를 조정하려는 경우엔 Effect 가 필요하지 않을 수도 있다.

 

2️⃣ Effect 의 의존성 지정하기

Effect 가 불필요하게 다시 실행되지 않도록 하려면 useEffect 호출의 두 번째 인자로 의존성 배열을 지정하자.

  useEffect(() => {
    if (isPlaying) { // 여기서 사용하니까...
      // ...
    } else {
      // ...
    }
  }, [isPlaying]); // ...여기에 선언되어야겠네!

 

이제 isPlaying 이 변경될 때만 Effect 가 실행된다.

 

❗️ 주의 : 의존성 배열이 없을 때와 빈 [] 의존성 배열이 있을 때의 동작은 다르다.

 

useEffect(() => {
  // 모든 렌더링 후에 실행됩니다
});

useEffect(() => {
  // 마운트될 때만 실행됩니다 (컴포넌트가 나타날 때)
}, []);

useEffect(() => {
 // 마운트될 때 실행되며, *또한* 렌더링 이후에 a 또는 b 중 하나라도 변경된 경우에도 실행됩니다
}, [a, b]);

 

3️⃣ 필요하다면 클린업 추가하기

사용자에게 표시될 때 채팅 서버에 연결해야 하는 ChatRoom 컴포넌트를 작성 중이다.

createConnection() API 가 주어지며, 이 API 는 connect() 및 disconnect() 메서드를 가진 객체를 반환한다.

사용자에게 표시되는 동안 컴포넌트가 채팅 서버와의 연결을 유지되도록 하자.

 

화면에 처음으로 나타날 때에만 실행되도록 빈 배열을 의존성 배열에 넣어주었다.

이 Effect 는 마운트될 때만 실행되므로 콘솔에 '연결 중..'이 한 번 출력될 것 같지만, 두 번 출력되고 있다.

왜 그럴까? 예시를 들어보자.

  • 사용자가 ChatRoom 페이지에 들어왔다. > 컴포넌트가 마운트되고 connection.connect() 호출 > 다른 화면으로 이동
    > ChatRoom 컴포넌트 마운트 해제 > 뒤로 가기 버튼을 클릭해 ChatRoom 다시 마운트!

👀 여기서 두 번째 연결이 설정되지만 첫 번째 연결은 종료되지 않았다. 사용자가 앱을 탐색하는 동안 연결은 종료되지 않고 계속 쌓일 것!

이러한 문제를 빠르게 파악할 수 있도록 React 는 개발 모드에서 초기 마운트 후 모든 컴포넌트를 한 번 더 마운트한다.

로그가 두 번 출력되는 것을 보면 컴포넌트가 마운트 해제될 때 연결을 닫지 않았다는 것을 알 수 있다.

 

클린업 함수를 반환해 해결하자.

React 는 Effect 가 다시 실행되기 전마다 클린업 함수를 호출하고, 컴포넌트가 마운트 해제(제거)될 때에도 마지막으로 호출한다.

  useEffect(() => {
    const connection = createConnection();
    connection.connect();
    return () => {
      connection.disconnect();
    };
  }, []);

 

이처럼 세 로그가 뜨는 것이 개발 모드에서의 올바른 동작이다. 배포 환경에선 '연결 중..'이 한 번만 출력된다.

컴포넌트를 다시 마운트하는 것은 개발 중에만 발생하며 클린업이 필요한 Effect 를 찾아주는 데 도움을 준다.

Stric Mode 를 꺼서 개발 동작에서 벗어날 수도 있지만, 쉽게 버그를 찾을 수 있으니 켜둘 것을 권장한다.

 

개발 환경과 운영 환경 모두 사용자가 볼 수 있는 동작은 동일해야 한다.

// 🔴 개발 환경에서 카운트가 2씩 증가한다.
export default function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    function onTick() {
      setCount(c => c + 1);
    }

    setInterval(onTick, 1000);
  }, []);

  return <h1>{count}</h1>;
}

// ✅ 개발 환경, 제품 환경 모두 1씩 증가한다.
export default function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    function onTick() {
      setCount(c => c + 1);
    }

    const intervalId = setInterval(onTick, 1000);
    return () => clearInterval(intervalId);
  }, []);

  return <h1>{count}</h1>;
}

 

❗️ 주의 : 서버 연결 같은 경우 Effect 가 두 번 실행되는 것을 막기 위해 ref 를 사용하지 말 것

개발 모드에서 흔히 빠지는 함정은 ref 를 사용해 Effect 가 한 번만 실행되도록 하는 것이다.

이렇게 하면 콘솔 로그는 한 번 찍히지만 버그가 수정 된 것은 아니어서, 사용자가 다른 곳에 가면 연결이 계속 쌓이게 된다.

 

이벤트 구독 & 해제

Effect 가 어떤 것을 구독한다면, 클린업 함수에서 구독을 해지해야 한다.

개발 중엔 Effect 가 addEventListener() 를 호출한 다음 즉시 remove..() 호출, 그 다음 다시 add..() 를 호출한다.

따라서 한 번에 하나의 활성 구독만 있게 된다. 이것은 제품 환경에서 한번 addEventListener() 를 호출하는 것과 동일한 동작이 된다.

useEffect(() => {
  function handleScroll(e) {
    console.log(window.scrollX, window.scrollY);
  }
  window.addEventListener('scroll', handleScroll);
  return () => window.removeEventListener('scroll', handleScroll);
}, []);

 

애니메이션 트리거

Effect 가 어떤 요소를 애니메이션으로 표시하는 경우, 클린업 함수에서 애니메이션을 초기 값으로 재설정해야 한다.

개발 중엔 불투명도가 1 > 0 > 1 로 다시 설정되고, 제품 환경에선 1로 한 번 설정된다.

useEffect(() => {
  const node = ref.current;
  node.style.opacity = 1; // Trigger the animation
  return () => {
    node.style.opacity = 0; // Reset to the initial value
  };
}, []);

 

데이터 페칭 🔗

Effect 가 어떤 데이터를 가져온다면, 클린업 함수에선 fetch 를 중단하거나 결과를 무시해야 한다.

이미 발생한 네트워크 요청을 '실행 취소'할 순 없지만, 클린업 함수는 더 이상 관련이 없는 fetch 가 앱에 계속 영향을 미치지 않도록 해야 한다. userId 가 'Alice'에서 'Bob'으로 변경되면 클린업은 'Bob'이후에 도착하더라도 'Alice' 응답을 무시하도록 보장한다.

useEffect(() => {
  let ignore = false;

  async function startFetching() {
    const json = await fetchTodos(userId);
    if (!ignore) {
      setTodos(json);
    }
  }

  startFetching();

  return () => {
    ignore = true;
  };
}, [userId]);

 

분석 보내기

페이지 방문 시 분석 이벤트를 보내는 코드를 살펴보자.

개발 환경에선 logVisit 가 각 URL 에 대해 두 번 호출되지만, 제품 환경에선 중복된 방문 로그가 없을 것이다.

이 코드는 그대로 유지하는 것을 권장한다.

useEffect(() => {
  logVisit(url); // POST 요청을 보냄
}, [url]);

 

Effect 가 아닌 경우 : 애플리케이션 초기화

일부 로직은 앱 시작 시 한 번만 실행되어야 한다. 이러한 로직은 컴포넌트 외부에 배치할 수 있다.

아래와 같이 컴포넌트 외부에 코드를 작성하면, 해당 로직은 브라우저가 페이지를 로드한 후 한 번만 실행된다.

if (typeof window !== 'undefined') { // 브라우저에서 실행 중인지 확인합니다.
  checkAuthToken();
  loadDataFromLocalStorage();
}

function App() {
  // ...
}

 

Effect 가 아닌 경우 : 제품 구매하기

useEffect(() => {
  // 🔴 잘못된 방법: 이 Effect는 개발 환경에서 두 번 실행되며 코드에 문제가 드러납니다.
  fetch('/api/buy', { method: 'POST' });
}, []);

 

사용자는 제품을 두 번 구매하고 싶지 않을 것이다. 사용자가 다른 페이지 이동 후 뒤로가기 버튼을 누르면 Effect 가 다시 실행된다.

우리는 사용자가 페이지를 방문할 때가 아닌 '구매' 버튼을 클릭할 때 이벤트를 실행하고 싶다.

  function handleClick() {
    // ✅ 구매는 특정 상호 작용에 의해 발생하는 이벤트입니다.
    fetch('/api/buy', { method: 'POST' });
  }

 

만약 컴포넌트를 다시 마운트했을 때 앱의 로직이 깨진다면, 기존에 존재하던 버그가 드러난 것이다.

❗️ 사용자의 관점에서 페이지를 방문하는 것과 페이지를 방문하고, 링크를 클릭한 다음, 뒤로 가기 버튼으로 다시 페이지로 돌아온 것의 차이가 없어야 한다. React 는 개발 환경에서 컴포넌트를 한 번 더 마운트해 이 원칙을 준수하는지 확인한다.

 

각각의 렌더링은 각각의 고유한 Effect 를 갖는다. 🔗

첫 렌더링 의존성 배열 값 ['general'] > 두 번째 렌더링 의존성 배열 값 ['general'] > 세 번째 렌더링 의존성 배열 값 ['travel']

두 번째에서 첫 렌더링의 값과 비교했을 때 의존성이 동일하므로 React 는 두 번째 렌더링에서의 Effect 를 무시한다.

다음 세번 째에서 의존성 값이 달라진다. React 는 세 번째 렌더링의 Effect 를 적용하기 전 먼저 실행된 Effect 를 정리한다.

두 번째 Effect 가 건너뛰어졌기 때문에, React 는 첫 렌더링의 Effect 를 정리한다.

 

경쟁 조건 race condition 해결 : Effect 내의 잘못된 데이터 패칭 고치기

아래 컴포넌트는 선택한 사람에 따라 일대기를 보여준다.

person 이 변경될 때, 마운트 될 때마다 비동기 함수 fetchBio 를 호출해 일대기를 불러온다.

하지만 'alice'를 선택하고 'Bob'을 선택 후 바로 'Taylor'를 선택하면 버그가 발생한다.

아래처럼 Taylor 가 선택되었지만 Bob 의 일대기가

표시되고 있다.

  • Bob 을 선택해 fetchBio('Bob') 트리거(실행)
  • Taylor 를 선택해 fetchBio('Taylor') 트리거
  • Taylor 의 일대기를 가져오는 작업이 Bob 의 일대기를 가져오는 작업보다 먼저 완료
  • Taylor 렌더링의 Effect 가 setBio('이것은 Taylor 의 일대기입니다.') 를 호출
  • Bob 의 일대기를 가져오는 작업이 완료
  • Bob 렌더링의 Effect 가 setBio('이것은 Bob 의 일대기입니다.') 를 호출

이렇게 진행되어 Bob 의 일대기가 표시된 것이다.

이와 같은 버그는 두 개의 비동기 작업이 경쟁하여 작업 완료의 순서를 예측할 수 없는 경쟁 조건이라고 한다.

 

이를 해결하기 위해 클린업 함수를 추가하자.

  useEffect(() => {
    let ignore = false; // 각 렌더링의 Effect 는 자체 ignore 변수를 가지고 있다. 처음엔 false 로 설정
    setBio(null);
    fetchBio(person).then(result => {
      if (!ignore) {
        setBio(result);
      }
    });
    
    // Effect 가 클린업되면(e.g. 다른 사람 선택 시), 해당 Effect 의 ignore 변수를 true 로 설정
    return () => {
      ignore = true;
    }
  }, [person]);

 

이제 어떤 순서로 요청이 완료되는지는 중요하지 않다.
마지막 사람의 Effect 만 ignore 가 false 로 설정되므로 setBio(result)를 호출하고,
이전 Effect 는 정리되었으므로 if (!ignroe) 검사가 setBio 호출을 방지한다.

  • Bob 을 선택해 fetchBio('Bob') 트리거
  • Taylor 를 선택하면 fetchBio('Taylor') 가 트리거되며 이전(Bob 의) Effect 가 ** 정리 cleaned up ** 된다.
  • Taylor 의 일대기를 가져오는 작업이 Bob 의 일대기를 가져오는 작업보다 먼저 완료
  • Taylor 렌더링의 Effect 가 setBio('이것은 Taylor 의 일대기입니다.') 를 호출
  • Bob 의 일대기를 가져오는 작업 완료
  • Bob 렌더링의 Effect 는 ignore 플래그가 true 로 설정되어 있기 때문에 아무 일도 수행하지 않음

 

2. Effect 가 필요하지 않은 경우 : 컴포넌트에서 불필요한 Effect 제거하기

외부 시스템이 관여하지 않는 경우(e.g. 일부 props 또는 state 가 변경될 때 컴포넌트의 state 를 업데이트 하려는 경우) Effect 가 필요하지 않다. 불필요한 Effect 를 제거하면 코드를 더 쉽게 따라갈 수 있고, 실행 속도가 빨라지며, 에러 발생 가능성이 줄어든다.

 

다음과 같은 두 가지 일반적인 경우엔 Effect 가 필요하지 않다.

  • 렌더링을 위해 데이터를 변환하는 경우
    예시로, 리스트를 표시하기 전 필터링하고 싶다고 가정해 보자.
    리스트가 변경될 때 state 변수를 업데이트하는 Effect 를 작성하고 싶을 수 있다. 하지만 이는 비효율적이다.
    state 를 업데이트할 때 React 는 먼저 컴포넌트 함수를 호출해 화면에 표시될 내용을 계산한다. 그 다음 변경 사항을 DOM 에 commit 해 화면을 업데이트하고 그 후, Effect 를 실행한다. 만약 Effect 도 즉시 state 를 업데이트한다면 전체 프로세스가 처음부터 다시 실행된다. 불필요한 렌더링 패스를 피하려면 컴포넌트의 최상위 레벨에서 모든 데이터를 변환하자. 그럼 props 나 state 변경 시 해당 코드가 자동으로 다시 실행된다.
  • 사용자 이벤트를 처리하는 경우
    예시로 사용자가 제품을 구매할 때 /api/buy POST 요청을 전송하고 알림을 표시하고 싶다고 가정해 보자.
    구매 버튼 클릭 이벤트 핸들러에선 정확히 어떤 일이 일어났는지 알 수 있다. 하지만 Effect 는 실행될 때까지 사용자가 무엇을 했는지(e.g. 어떤 버튼을 클릭했는지) 알 수 없다. 때문에 일반적으로 해당되는 이벤트 핸들러에서 사용자 이벤트를 처리한다.

하지만, 외부 시스템과 동기화하려면 Effect 가 반드시 필요하다. (e.g. jQuery 위젯이 React state 와 동기화되도록 유지하는 Effect, 데이터를 가져오는 Effect, 검색 결과를 현재 검색 쿼리와 동기화하는 Effect)

 

Effect 가 불필요한 일반적인 예를 살펴보자.

1️⃣ props 또는 state 에 따라 state 업데이트하기 : 다른 state 에 따라 state 를 조정하는 데에는 Effect 가 필요하지 않다.

firstName 과 lastName 이라는 state 변수가 있을 때, 두 변수를 연결해 fullName 을 계산하고 싶고, 한 상태가 변경되면 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 변수가 동기화되지 않아 발생하는 버그 방지)

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

아래 컴포넌트는 props 로 받은 todos 를 filter prop 에 따라 필터링해 visibleTodos 를 계산하는데,

결과를 state 에 저장하고 Effect 에서 업데이트하고 싶을 수도 있다.

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

  // 🔴 피하세요: 중복된 state 및 불필요한 효과
  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 으로 래핑해 값비싼 계산을 캐시(or 메모이제이션) 하자!

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 는 마지막으로 저장한 결과를 반환한다. 다르다면 새로 저장!

** useMemo 로 감싸는 함수는 렌더링 중에 실행되므로, 순수한 계산에만 작동한다.

 

3️⃣ prop 변경 시 모든 state 초기화

아래 컴포넌트는 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 와 그 자식이 오래된 값으로 처음 렌더링한 후, 다시 렌더링하기에 비효율적이다.

대신 명시적인 key 를 전달해 각 사용자의 프로필이 각각 다른 프로필임을 React 에 알릴 수 있다.

컴포넌트를 둘로 나누고 외부 컴포넌트에서 내부 컴포넌트로 key 어트리뷰트를 전달하자.

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 state 가 초기화된다.

 

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

prop 이 변경될 때 전체가 아닌 일부 state 만 재설정하고 싶을 때가 있다.

아래 List 컴포넌트는 items 목록을 prop 으로 받아 selection state 변수에 선택된 item 을 유지한다.

item prop 이 다른 배열을 받을 때마다 selection 을 null 로 재설정하고 싶다.

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

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

 

이 역시 items 가 변경될 때마다 List 와 그 자식 컴포넌트들은 불필요한 리렌더링을 거치게 된다.

이를 위해 key 를 사용해 모든 state 를 초기화하거나 렌더링 중 모든 state 를 계산할 수 있는지 항상 확인해보자.

예를 들어 선택한 Item 을 저장(및 초기화)하는 대신 선택하 item 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 를 가진 item 이 목록에 있으면 선택된 state 로 유지된다. 없다면 selection = null

 

5️⃣ 이벤트 핸들러 간 로직 공유

제품을 구매할 수 있는 두 개의 버튼(구매 및 결제)이 있는 제품 페이지가 있을 때, 사용자가 제품을 장바구니에 넣을 때마다 알림을 표시하고 싶다. 두 버튼의 클릭 핸들러에서 모두 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 에 있어야 하는지, 이벤트 핸들러에 있어야 하는지 확실하지 않다면 코드가 실행되어야 하는 이유를 다시 살펴보자.

🌟 컴포넌트가 사용자에게 표시되었기 때문에 실행되어야 하는 코드에만 Effect 를 사용해야 한다.

위 예시에선 페이지가 나타났기 때문이 아니라 사용자가 버튼을 눌렀기 때문에 알림이 표시되어야 하므로 Effect 가 아닌 이벤트 핸들러에서 함수를 호출해야 한다.

function ProductPage({ product, addToCart }) {
  // ✅ 좋습니다: 이벤트 핸들러에서 이벤트별 로직이 호출됩니다. 불필요한 Effect 가 제거되고 버그가 수정됩니다.
  function buyProduct() {
    addToCart(product);
    showNotification(`Added ${product.name} to the shopping cart!`);
  }

  function handleBuyClick() {
    buyProduct();
  }

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

 

6️⃣ POST 요청 보내기

아래 Form 컴포넌트는 두 종류의 POST 요청을 전송한다.

마운트 될 때 analytics 이벤트를, 폼을 작성하고 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 });
  }
  // ...
}

 

analytics POST 요청은 Effect 에 남아 있는 것이 맞다. ** 이 이벤트를 전송하는 이유는 Form 이 표시되었기 때문이다.

그러나 /api/register 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 });
  }
  // ...
}

 

7️⃣ 연쇄 계산

다른 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 → 렌더링 → setGoldCardCount → 렌더링 → setRound → 렌더링 → setIsGameOver → 렌더링)에는 아래 트리의 불필요한 리렌더링이 세 번 발생한다.

 

두 번째 문제는 코드가 발전함에 따라 작성한 '체인'이 새 요구 사항에 맞지 않는 경우가 생길 수 있다는 것이다.

게임 이동의 기록을 단계별로 살펴볼 수 있는 방법을 추가한다고 가정해 보자.

각 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 를 계산할 수 없는 경우도 있다.

e.g. 여러 개의 드롭 다운에 있는 폼에서 다음 드롭 다운의 옵션이 이전 드롭 다운의 선택 값에 따라 달라질 때

이 경우 네트워크와 동기화하기 때문에 Effect 체인이 적합하다.

 

8️⃣ 애플리케이션 초기화

일부 로직은 앱이 로드될 때 한 번만 실행되어야 한다. 이를 위해 그 로직을 최상위 컴포넌트의 Effect 에 배치하고 싶을 텐데,

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

 

이는 개발 중 두 번 실행된다. 함수가 두 번 호출되도록 설계되지 않았기에 인증 토큰이 무효화되는 등 문제가 발생할 수 있다.

실제로 프로덕션 환경에선 다시 마운트되지 않을 수 있지만,

제약 조건을 걸어두고 모든 환경에서 동일하게 작동하도록 하면 코드를 재사용하기 더 쉬워진다.

let didInit = false;

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

 

모듈 초기화 중이나 앱이 렌더링 되기 전에 실행하고 싶다면,

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

function App() {
  // ...
}

 

컴포넌트를 import 할 때 최상위 레벨의 코드는 렌더링되지 않더라도 한 번 실행된다.

어떤 컴포넌트를 import 할 때 속도 저하나 예상치 못한 동작을 방지하려면 이 패턴을 과도하게 사용하지 말자.

app 전체 초기화 고직은 App.js 와 같은 루트 컴포넌트 모듈이나 애플리케이션의 엔트리 포인트(시작 시점)에 두자.

 

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

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 가 화면을 업데이트 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 끌어올리기를 사용해 보자!

 

🔟 부모에게 데이터 전달하기

아래 Child 컴포넌트는 일부 데이터를 가져와 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 를 업데이트하면 데이터 흐름을 추적하기 매우 어려워진다.

자식과 부모 모두 동일한 데이터가 필요하다면 부모에게서 해당 데이터를 가져와 자식에게 내려주자.

 

이제 데이터 흐름이 더 간단하고, 예측 가능하게 유지된다.

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

 

1️⃣1️⃣ 외부 저장소 구독하기

때론 컴포넌트가 React state 외부의 데이터를 구독해야할 수도 있다.

이 데이터는 서드파티 라이브러리 or 내장 브라우저 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 에 사용할 수 없으므로) 처음 state 는 true 로 설정된다. 브라우저에서 해당 데이터 저장소의 값이 변경될 떄마다 컴포넌트는 해당 state 를 업데이트한다.

 

이를 위해 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 을 작성해 개별 컴포넌트에서 이 코드를 반복할 필요가 없도록 한다.

 

1️⃣2️⃣ 데이터 가져오기

많은 앱들이 일반적으로 데이터를 가져오기 위해서 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 에서 미리 채워지는 경우가 많고 사용자는 입력을 건드리지 않고 뒤로, 앞으로 탐색할 수도 있다.

 

page 와 query 의 출처가 어디인진 중요하지 않다. 이 컴포넌트가 표시되는 동안 현재 page 및 query 에 대한 네트워크 데이터와 results 를 동기화하고 싶을 것이다. 이것이 Effect 를 사용하는 이유!

 

하지만 위 코드엔 버그가 있다. '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 가 데이터를 가져올 때 마지막으로 요청된 응답을 제외한 모든 응답이 무시된다.

데이터를 가져올 때 경쟁 조건 말고도 고려해야 할 것 들이 있다.

  • 응답 캐싱 : 사용자가 뒤로가기 버튼을 클릭했을 때 이전 화면을 즉시 볼 수 있도록
  • 서버에서 데이터를 가져오는 방법 : 초기 서버 렌더링 HTML 에 스피너 대신 가져온 콘텐츠가 포함되도록
  • 네트워크 워터폴을 피하는 방법 : 자식이 모든 부모를 기다리지 않고 데이터를 가져올 수 있도록

이러한 문제는 React 뿐 아니라 모든 UI 라이브러리에 적동된다. 이를 해결하는 것은 간단하지 않기에 모든 프레임워크는 Effect 에서 데이터를 가져오는 것보다 더 효율적인 내장 데이터 가져오기 매커니즘을 제공한다.

 

프레임워크를 사용하지 않고(또한 직접 빌드하고 싶지 않고) 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 호출이 적을수록 앱을 유지 관리하기 더 쉬워진다.

 

 

 

 

728x90
반응형