React Query에서 'suspense: true' 옵션을 사용했는데도, 'TData' | undefined' 타입이 반환되네요...?!

정우시·2023년 9월 23일
15

우아한테크코스

목록 보기
12/13

서론

안녕하세요. 요즘카페 팀에서 프론트엔드를 맡고 있는 정우시입니다. 요즘카페는 성수 지역의 트렌디한 카페를 쉽게 찾을 수 있도록 해주는 서비스입니다.

저희는 서버 상태를 관리하기 위해 React Query를 사용하고 있습니다. 리액트 쿼리의 수많은 커스텀 훅 중에서 특히 fetch된 data를 효율적으로 사용하기 위해 useQuery를 즐겨 사용하고 있습니다.

다만 한가지 문제점을 발견했는데요...?! 그것은 바로 'suspense: true'를 이용해서 옵션을 지정했는데도, 타입이 TData | undefined이 나온다는 것이었습니다.

본문에서는 예제 코드와 함께 관련된 내용을 구체적으로 전달하도록 하겠습니다.

본론

useQuery의 suspense 옵션 사용법

React Query v4에서 suspense 옵션을 사용하면, useQuery를 리액트의 Suspense와 함께 사용할 수 있습니다.

아래의 예제와 같이 suspense 옵션은 전역 또는 각각의 쿼리 레벨에서 설정 할 수 있습니다.

  • 전역에서 설정
// Configure for all queries
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      suspense: true,
    },
  },
});

const root = createRoot(document.getElementById("root") as HTMLElement);
root.render(
  <QueryClientProvider client={queryClient}>
    <App />
  </QueryClientProvider>
);
  • 각각의 쿼리에서 설정
import { useQuery } from '@tanstack/react-query'

// Enable for an individual query
const { data } = useQuery({ queryKey: ["todos"], queryFn: fetchTodos, suspense: true });

저희는 원활한 코드 작성을 위해 전역에서 suspense 옵션을 설정하고 진행하도록 하겠습니다.

문제 상황

const App = () => {
  return (
    <>
      <h1>Todo List</h1>
      <Suspense fallback={<div>Loading...</div>}>
        <TodoList />
      </Suspense>
    </>
  );
};

전역에서 Suspense 옵션을 true로 설정을 한 이후 TodoList에 Suspense를 이용하기 위해 TodoList를 Suspense로 감싸주었습니다.

import { useQuery } from "@tanstack/react-query";

type Todo = {
  id: number;
  text: string;
  completed: boolean;
};

const TodoList = () => {
  const { data } = useQuery({ queryKey: ["todos"], queryFn: fetchTodos }); // const data: Todo[] | undefined

  return (
    <ul>
      {data.map((todo) => ( //'data'은(는) 'undefined'일 수 있습니다.ts(18048)
        <li key={todo.id}>{todo.text}</li>
      ))}
    </ul>
  );
};

이제 useQuery를 이용해서 fetch된 데이터를 이용하려고 합니다. TodoList의 경우 Suspense로 감싸주었으니, 당연히 { data }는 항상 성공해야 할 것입니다. 그러나 리액트 쿼리는 타입적으로 이러한 부분을 인식하지 못하고 const data: Todo[] | undefined라는 타입을 반환하였습니다.

const TodoList = () => {
  const { data, isSuccess } = useQuery({queryKey: ["todos"], queryFn: fetchTodos });

  if(isSuccess) {
    return (
      <ul>
        {data.map((todo) => ( // const data: Todo[]
          <li key={todo.id}>{todo.text}</li>
        ))}
      </ul>
    );
  }
};

이해가 안간다고 하시면 위의 예제 코드를 참고하시면 될 것입니다. Suspense를 이용한 방법이 바로 isSuccess를 이용한 방법과 같다고 생각하시면 됩니다. 위의 코드는 항상 성공한 값을 반환하기에 undefined와 관련된 에러가 나오지 않습니다.

이와 관련하여 다음과 같은 문제가 깃허브 이슈를 통해 저희만 겪은 것이 아니라는 것을 알게 되었습니다.

문제 해결 방법

위와 같은 문제를 해결하는 방법으로 여러가지가 있을 것입니다.

첫 번째는 옵셔널 체이닝입니다. 옵셔널 체이닝을 통해 undefined라는 타입을 반환하여도 무시하고 코드를 작성할 수 있습니다.

const TodoList = () => {
  const { data } = useQuery({queryKey: ["todos"], queryFn: fetchTodos });

    return (
      <ul>
        {data?.map((todo) => ( // 옵셔널 체이닝 추가
          <li key={todo.id}>{todo.text}</li>
        ))}
      </ul>
    );
};

두 번째는 '타입 좁히기'입니다. if문과 isSuccess 옵션을 이용하여 성공한 데이터만을 반환하도록 할 수 있습니다. 관련된 코드는 '문제 상황'의 마지막 예제 코드를 참고하시면 됩니다.

마지막으로 useQuery를 suspense 옵션을 적용하면 데이터의 타입만 나올 수 있도록 wrapping을 해주는 방법이 있습니다.

대표적으로 토스 Slash 라이브러리의 useSuspendedQuery가 있으며, 이와 같은 커스텀 훅을 사용한다면 일일이 옵셔널 체이닝, 타입 좁히기를 사용하지 않아도 되는 편리함이 있습니다.

요즘카페 팀에서 해당 문제를 해결한 방법

저희는 Slash 라이브러리를 참고하여 useSuspenseQuery라는 커스텀 훅을 제작하게 되었습니다.

import {
  QueryKey,
  UseQueryOptions,
  UseQueryResult,
} from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";

type UseSuspenseQueryResult<TData> =
  | UseSuspenseQueryResultOnSuccess<TData>
  | UseSuspenseQueryResultOnIdle<TData>;

type UseSuspenseQueryOption<
  TQueryFnData = unknown,
  TError = unknown,
  TData = TQueryFnData,
  TQueryKey extends QueryKey = QueryKey
> = Omit<UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>, "suspense">;

function useSuspenseQuery<
  TQueryFnData = unknown,
  TError = unknown,
  TData = TQueryFnData,
  TQueryKey extends QueryKey = QueryKey
>(
  options: UseSuspenseQueryOption<TData, TError, TData, TQueryKey>
): UseSuspenseQueryResultOnSuccess<TData>;

// ... (enabled 및 기타 옵션에 따른 오버로드 함수 정의)

function useSuspenseQuery<
  TQueryFnData = unknown,
  TError = unknown,
  TData = TQueryFnData,
  TQueryKey extends QueryKey = QueryKey
>(
  options: UseSuspenseQueryOption<TQueryFnData, TError, TData, TQueryKey> & {
    enabled?: boolean;
  }
): UseSuspenseQueryResult<TData> {
  const queryResult = useQuery({
    ...options,
    suspense: true,
  });
  return {
    ...queryResult,
    isIdle: "isIdle" in queryResult ? queryResult.isIdle : false,
  } as UseSuspenseQueryResult<TData>;
}

"useSuspenseQuery"는 React Query의 "useQuery"를 기반으로한 커스텀 훅으로, Suspense를 사용할 수 있도록 설계되었습니다. 이 훅은 다음과 같은 주요 특징을 갖습니다:

  1. suspense: true 옵션을 사용하여 데이터 로딩 중에 Suspense를 활성화합니다.

  2. enabled 옵션을 설정하지 않으면 기본값으로 true로 설정되어 성공한 결과만 반환하고, 나머지 속성들은 생략합니다.

  3. enabled 옵션이 false로 설정된 경우, 데이터를 가져오지 않고 대기 중 상태를 나타내는 UseSuspenseQueryResultOnIdle 타입을 반환합니다.

이러한 커스텀 훅을 활용하여 데이터 로딩과 Suspense를 원활하게 사용할 수 있었으며, 또한 코드를 간결하게 유지시킬 수 있었습니다.

결론

위와 같은 문제를 해결하면서 다음과 같은 능력을 향상 시킬 수 있었다고 생각합니다.

  • 개발자 경험: 물론 타입 좁히기, 옵셔널 체이닝을 통해 문제를 해결할 수도 있을 것입니다. 하지만 그렇게 코드를 작성하면 useQuery를 사용하는 모든 곳에 일일이 관련된 코드를 작성해야합니다. 저희는 '어떻게 하면 개발자 경험을 상승시킬 수 있을까?'라는 것에 대해서 고민을 하였으며, 커스텀 훅을 통해 이러한 문제를 해결할 수 있었습니다.

  • 라이브러리의 소스 코드를 분석: React Query와 Slash와 같은 라이브러리를 자세히 살펴보면서, 동작 원리와 내부 구현을 파악하기 위해 노력하였습니다. 이로 인해 프로젝트에 맞는 커스터마이징 방법을 숙지할 수 있었습니다.

  • 다양한 해결책을 통한 문제 해결 경험: 단순히 구현에만 급급하지 않고, '다른 개발자는 어떻게 문제를 해결했을까?'라는 호기심 아래에 깃허브 등 다양한 의견을 최대한 찾아보았습니다. 이러한 접근을 통해 다양한 관점에서 문제를 해결할 수 있는 능력을 향상시킬 수 있었습니다.

이러한 경험을 통해 개발자로서의 역량을 향상시키고, 라이브러리를 더 깊이 이해하고 문제를 해결하는 데 있어서 보다 자신감을 가질 수 있게 되었습니다.

profile
프론트엔드 공부하고 있는 정우시입니다.

2개의 댓글

comment-user-thumbnail
2023년 10월 31일

v5에서는 useSuspenseQuery를 지원하네요!

1개의 답글