데이터를 요청하는 동안 Suspense로 로딩 화면 구현하기

aken·2023년 9월 27일
3
post-thumbnail

Suspense

공식 문서에 나와있는 정의는 아래와 같다.

children 로딩이 마칠 때까지 fallback UI를 보여주는 컴포넌트

즉, children에서 비동기 작업이 완료되기 전까지 fallback UI를 보여주고, 비동기 작업이 완료되면 실제 children을 보여준다. 예시로 서버에 데이터를 요청하여 실제 데이터가 올 때까지 로딩화면을 보여주는 것이 있다.

Suspense 예시 gif

Suspense를 안써도 로딩 화면 보여줄 수 있는데 왜 쓸까?

아래 코드는 if문을 통해 user에 저장된 데이터가 없으면 loading 화면을, 있다면 실제 UI를 보여준다. 상황에 따라 분기처리 했기 때문에 이 코드는 명령형 코드에 가깝다. 또한 아래와 같은 문제점이 있다. (문제점에 대해 잘 작성되어 있는 예전 공식 문서 링크카카오 기술 블로그 링크를 남긴다.)

  • 각 상황마다 if문으로 분기 처리한다면 내부 코드가 복잡해질 것이다.
  • waterfall 현상이 발생할 수 있다.
  • 경쟁 상태에 취약하다.
  • useEffect에서 비동기 작업을 마친 다음 렌더링을 진행한다.

따라서, Suspense를 사용해 선언적으로 loading UI(fallback UI)를 지정할 수 있다.

const User = ({ userId }: { userId: number }) => {
  const [user, setUser] = useState<UserData>();
  const [isLoading, setIsLoading] = useState<boolean>(false);

  useEffect(() => {
    setIsLoading(true);
   
    fetch(`/users/${userId}`)
      .then((res) => res.json())
      .then(({ data }) => {
        setUser(data);
      	setIsLoading(false);
      });
  }, [userId]);

  if (!isLoading) return <div>loading</div>;

  return (
    user && (
      <div style={{ padding: "20px", border: "1px solid black" }}>
        <div>{`userId: ${user?.id}`}</div>
        <div>{`name: ${user?.name}`}</div>
        <div>{`email: ${user?.email}`}</div>
      </div>
    )
  );
};

Suspense 동작 방식

User 컴포넌트를 Suspense로 감싸면 fallback UI가 나오겠지 기대했지만 나오지 않았다.

<Suspense fallback={<div>loading</div>}>
  <User userId={1} />
</Suspense>

공식 문서에서 찾아보니

Suspense does not detect when data is fetched inside an Effect or event handler.

Suspense는 Effect나 event handler에서 데이터를 fetch하는 것을 알아차리지 못한다.

그럼 Suspense가 이를 알아차리기 위해 어떻게 하면 될까?
이를 이해하기 위해서는 Suspense의 동작 방식에 대해 알아야 할 필요가 있다.

<Suspense fallback={<div>loading</div>} >
	{children}
</Suspense>
  1. children에서 pending 상태인 Promise가 throw됐다면 Suspense는 이를 감지하여 fallback 프로퍼티로 받은 컴포넌트를 렌더링한다.
  2. throw된 Promise가 fulfilled됐다면 fallback으로 받은 컴포넌트는 사라지고 children이 보인다.

children에서 Promise를 throw하지 않고 useEffect에서 데이터를 요청했기 때문에 fallback UI가 화면에 보이지 않았던 것이다.
useEffect에서 fetch 요청하면 데이터가 완전히 받아질 때까지 기다려야 하지만, Suspense를 사용하면 fetch 요청을 시작하고 데이터가 도착하기를 기다리지 않고 바로 렌더링을 시작한다.

fetch 로직에서 Suspense 적용

Suspense를 사용하기 위해 비동기 작업을 처리하는 라이브러리(react-query, swr)를 사용하거나, Promise를 throw하는 함수나 커스텀 hook을 만들면 된다.

1. 함수

Promise의 상태, data, error를 저장할 변수를 선언한 다음, Promise의 상태에 따라 분기처리하는 함수(read)를 만들어야 한다.

  1. pending 상태이면, Suspense가 이를 감지하기 위해 Promise 객체(suspense)를 throw한다.
  2. fulfilled 상태이면, 서버에서 온 데이터를 반환한다.
  3. rejected 상태이면, 에러를 throw한다.
    read 함수를 가진 객체를 반환한다.
    User 컴포넌트의 user에 fetchData(1)를 줌으로써 실제 User 컴포넌트를 렌더링하기 전에 데이터를 가져오기 시작한다.
    (자세한 설명은 공식 문서 참고)
const fetchData = <T,>(url: string) => {
  let status: "pending" | "fulfilled" | "rejected" = "pending";
  let data: T;
  let error: Error;

  const suspense = fetch(url)
    .then((res) => res.json())
    .then(
      (res) => {
        status = "fulfilled";
        data = res;
      },
      (err) => {
        status = "rejected";
        error = err;
      }
    );

  const read = () => {
    switch (status) {
      case "pending": {
        throw suspense; // a
      }
      case "rejected": {
        throw error; // c
      }
      case "fulfilled": {
        return data; // b
      }
    }
  };

  return { read };
};

interface UserData {
  id: number;
  name: string;
  email: string;
}

interface UserProps {
  user: { read: () => UserData };
}

const User = ({ user }: UserProps) => {
  const userData = user.read();

  return (
    userData && (
      <div style={{ padding: "20px", border: "1px solid black" }}>
        <div>{`userId: ${userData?.id}`}</div>
        <div>{`name: ${userData?.name}`}</div>
        <div>{`email: ${userData?.email}`}</div>
      </div>
    )
  );
};

export default function App() {
  return (
    <Suspense fallback={<div>loading</div>}>
      <User user={fetchData<UserData>("/users/1")} />
    </Suspense>
  );
}

2. Custom hook

Promise, Promise의 상태, data, error를 저장할 state를 만든다. Promise의 상태에 따라 분기처리를 해야 한다.
1. pending 상태이고 promise가 존재하면, Suspense가 이를 감지하기 위해 promise를 throw한다.
2. fulfilled 상태이면, 서버에서 온 데이터를 반환한다.
3. rejected 상태이면, 에러를 throw한다.

function useFetch<T>(url: string) {
  const [promise, setPromise] = useState<Promise<void>>();
  const [state, setState] = useState<"pending" | "fulfilled" | "rejected">(
    "pending"
  );
  const [data, setData] = useState<T>();
  const [error, setError] = useState<Error>();

  useEffect(() => {
    setState("pending"); // fetch 하기 전 status "pending"으로 설정

    setPromise(
      fetch(url)
        .then((res) => res.json())
        .then(
          (result: T) => {
            setState("fulfilled");
            setData(result);
          },
          (error: Error) => {
            setState("error");
            setError(error);
          }
        )
    );
  }, [url]);

  if (state === "pending" && promise) {
    throw promise; // a
  }
  if (state === "rejected") {
    throw error; // c
  }
  return data; // b
}

const User = ({ userId }: { userId: number }) => {
  const user = useFetch<UserData>(`/users/${userId}`);
  //...
}

3. useQuery (with tanstack query)

tanstack query(구: react-query) v3이라면 useQuery에 세번째 파라미터로 { suspense: true }을 주고, v4이상이라면 useQuery 인수 객체의 파라미터인 suspensetrue를 주면 된다.

// v3
const { data: user } = useQuery(
    ["user", userId],
    async () => {
      const response = await (await fetch(`/users/${userId}`)).json();
      return response;
    },
    { suspense: true },
);
// v4   
const { data: user } = useQuery({
    queryKey: ["user", userId],
    queryFn: async () => {
      const response = await (await fetch(`/users/${userId}`)).json();
      return response;
    },
    suspense: true,
});

참고

전 react 공식 문서
[react 공식 문서] Suspense
[카카오 기술 블로그] Suspense와 선언적으로 Data fetching처리
[React Suspense 소개 (feat. React v18)]

0개의 댓글