React 18 Suspense 이해하고 사용하자

Taemin Jang·2024년 2월 14일
0

React 18 이전 Suspense는 실험적으로 사용되었으며, React.lazy를 통해 코드 스플리팅만 가능했었다.

React 18이 되면서 Suspense를 사용하면 코드 스플리팅, 비동기 데이터, 이미지 레이지로딩 등으로 기능이 확장됐다.

Code Spliting

const ProfilePage = React.lazy(() => import('./ProfilePage')); // 지연 로딩// 프로필을 불러오는 동안 스피너를 표시합니다.
<Suspense fallback={<Spinner />}>
  <ProfilePage />
</Suspense>;

위 코드처럼 React.lazy를 통해 첫 번째 렌더링 때 ProfilePage 컴포넌트를 불러오지 않고, 최초 렌더링 이후에 컴포넌트를 지연시켜 불러오는 코드 스플리팅 역할을 한다.

지연되는 컴포넌트를 Suspense로 감싸면, 지연되는 동안 Suspense에서 props로 전달받은 fallback을 보여준다.

Data Fetching

Fetch-on-render

const App = () => {
  const [userDetails, setUserDetails] = useState({});

  useEffect(() => {
    fetchUserDetails().then(setUserDetails);
  }, []);

  if (!userDetails.id) return <p>Fetching user details...</p>;

  return (
    <div className="app">
      <h2>Simple Todo</h2>

      <UserWelcome user={userDetails} />
      <Todos />
    </div>
  );
};

위 방식은 컴포넌트가 마운트 된 후 Data Fetching을 시작한다.
즉, 컴포넌트 렌더링을 먼저 시작하고 useEffect나 componentDidMount로 비동기 처리를 한다.

여기서 문제점은 Todos 컴포넌트는 fetchiUserDetails()가 resolve되기 전까지 보여지지 않는다. 만약 Todos에서도 fetch 요청이 있다면 병렬적으로 진행되지 않으므로 waterfall 문제가 발생한다.

Fetch-then-render

function fetchUserDetailsAndTodos() {
  return Promise.all([fetchUserDetails(), fetchTodos()]).then(
    ([userDetails, todos]) => ({ userDetails, todos })
  );
}

const fetchDataPromise = fetchUserDetailsAndTodos(); // We start fetching here

const App = () => {
  const [userDetails, setUserDetails] = useState({});
  const [todos, setTodos] = useState([]);

  useEffect(() => {
    fetchDataPromise.then((data) => {
      setUserDetails(data.userDetails);
      setTodos(data.todos);
    });
  }, []);

  return (
    <div className="app">
      <h2>Simple Todo</h2>

      <UserWelcome user={userDetails} />
      <Todos todos={todos} />
    </div>
  );
};

위 방식은 컴포넌트가 렌더링 되기 이전에 Data Fetching을 시작한다.

비동기 요청하는 로직을 App 컴포넌트 밖으로 옮기면서, 컴포넌트가 마운트 되기 이전에 데이터를 요청하고 Promise.all을 통해 비동기 작업들의 동시성을 보장할 수 있다.

하지만, 비동기 작업 중 더 느린 요청이 있다면 해당 요청이 완료될 때까지 기다려야 렌더링이 되고, 요청이 하나라도 실패하면 다른 요청도 reject가 되기 때문에 높은 결합도를 만들 수 있어 좋지 않다.

Render-as-you-fetch

각각의 컴포넌트에서 자신의 데이터를 각자 책임지고 요청할 수 있도록 도입한 것이 Suspense다.

const data = fetchData();

const App = () => (
  <>
    <Suspense fallback={<p>Fetching user details...</p>}>
      <UserWelcome />
    </Suspense>

    <Suspense fallback={<p>Loading todos...</p>}>
      <Todos />
    </Suspense>
  </>
);

const UserWelcome = () => {
  const userDetails = data.userDetails.read();
  // code to render welcome message
};

const Todos = () => {
  const todos = data.todos.read();
  // code to map and render todos
};

위 방식은 비동기 작업과 렌더링을 동시에 시작하여 즉시 초기 상태를 렌더링(fallback rendering)하고, 비동기 작업이 완료되면 다시 렌더링한다.

데이터 요청을 React-Query나 SWR이 아닌 fetch나 axios로 사용해서 Suspense를 적용하면 제대로 동작하지 않는다.

Suspense 동작 방식

Suspense가 감싸고 있는 컴포넌트에서 비동기 요청을 하고, 아직 데이터가 준비되지 않았다면(= 요청이 resolve되지 않았다면) Suspense에 있는 fallback이 렌더링되고, 요청이 resolve되면 해당 컴포넌트로 다시 렌더링이 된다.

내가 가장 궁금해했던 부분은 그렇다면 Suspense는 이를 어떻게 감지하고 동작하는 걸까?

//wrapPromise.js
function wrapPromise(promise) {
  let status = "pending";
  let response;

  const suspender = promise.then(
    (res) => {
      status = "success";
      response = res;
    },
    (err) => {
      status = "error";
      response = err;
    }
  );

  const read = () => {
    switch (status) {
      case "pending":
        throw suspender;
      case "error":
        throw response;
      default:
        return response;
    }
  };
  return { read };
}

export default wrapPromise;
  1. wrapPromise는 promse를 인자로 받는다.
  2. Promise는 기본적으로 pending 상태이며, pending 상태이면 Promise를 던지고 fallback UI를 렌더링한다.
  3. 만약 error 상태라면 reject된 결과 값을 던지고, 이를 ErrorBoundary가 전달 받아 처리하게 된다.
  4. Promise가 success이면 resolve된 결과 값을 반환하고, 반환된 데이터로 다시 렌더링하게 된다.

Suspense가 인지할 수 있도록 wrapPromise를 만들었으니, 이를 사용한 fetch 코드는 다음과 같다.

import wrapPromise from "./wrapPromise";

function fetchData(url) {
  const promise = fetch(url)
    .then((res) => res.json())
    .then((res) => res.data);

  return wrapPromise(promise);
}

export default fetchData;

axios & custom hook

위 예시는 fetch를 사용했다면, axios를 사용한 사례와 이를 간편하게 사용할 수 있도록 custom hook을 이용한 방법도 있다.

// src/useGetData.js
import { useState, useEffect } from "react";
import axios from "axios";

const promiseWrapper = (promise) => {
  let status = "pending";
  let result;

  const s = promise.then(
    (value) => {
      status = "success";
      result = value;
    },
    (error) => {
      status = "error";
      result = error;
    }
  );

  return () => {
    switch (status) {
      case "pending":
        throw s;
      case "success":
        return result;
      case "error":
        throw result;
      default:
        throw new Error("Unknown status");
    }
  };
};

function useGetData(url, setData = data => data) {
  const [resource, setResource] = useState(null);

  useEffect(() => {
    const getData = async () => {
      const promise = axios.get(url).then((response) => setData(response.data));
      setResource(promiseWrapper(promise));
    };

    getData();
  }, [url]);

  return resource;
}

export default useGetData;

useGetData 커스텀 훅은 인자로 요청할 url과 응답 데이터를 변경하여 새로운 데이터로 반환하는 함수 setData를 전달 받는다.

그리고 위에서 만든 promiseWrapper를 감싸주면 된다.

이를 컴포넌트에 적용하면 다음과 같다.

// Parent
...
    <Suspense fallback={<TableLoading />}>
      <FetchTable params={params} />
    </Suspense>
...

// Children
const FetchTable = ({ params }) => {
  const problemData = useGetData(
    `achievement?id=${params.id}`,
    getBackjoonSolvedData,
  );

  return (
    <>
      {problemData.map((problem, index) => (
		...
      ))}
    </>
  );
};

export default FetchTable;

참고

profile
하루하루 공부한 내용 기록하기

0개의 댓글