RAKSUL TechBlog

RAKSULグループのエンジニアが技術トピックを発信するブログです

TanStack Query + Suspenseでデータフェッチを簡潔に行う

はじめに

こんにちは。ラクスル株式会社 エンジニアの小谷です。

この記事は

の 8 日目です。

最近、ReactでSPAを構築する中でTanStack Query v5を導入しました。特に useSuspenseQuery と React の Suspense を組み合わせたデータフェッチが非常に開発者体験が良かったので、その知見を共有したいと思います。

この記事では、バックエンドからのデータ取得方法を3つのアプローチで比較しながら、useSuspenseQuery の利点と注意点について解説します。

バックエンドからのデータ取得方法

Reactでバックエンドからデータを取得する方法として、以下の3つのアプローチを順に見ていきます。

  1. useEffect を使う方法
  2. TanStack Query の useQuery を使う方法
  3. TanStack Query の useSuspenseQuery + ReactのSuspense を使う方法

それぞれのアプローチを、同じ「ユーザー一覧を取得して表示する」という例で比較してみます。

1. useEffectを使う方法

まずは最も基本的な方法として、useEffectfetch を組み合わせたアプローチです。 データフェッチライブラリを使わずAIへ実装させると大体以下のようなコードになり、コンポーネント間でローディングやエラーの状態管理で重複した実装が多く発生します。

import { useEffect, useState } from 'react';

type User = {
  id: number;
  name: string;
  email: string;
};

function UserList() {
  const [users, setUsers] = useState<User[]>([]);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    const fetchUsers = async () => {
      try {
        setIsLoading(true);
        const response = await fetch('/api/users');
        if (!response.ok) {
          throw new Error('Failed to fetch users');
        }
        const data = await response.json();
        setUsers(data);
      } catch (e) {
        setError(e instanceof Error ? e : new Error('Unknown error'));
      } finally {
        setIsLoading(false);
      }
    };

    fetchUsers();
  }, []);

  if (isLoading) {
    return <div>Loading...</div>;
  }

  if (error) {
    return <div>Error: {error.message}</div>;
  }

  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

この方法には以下の問題点があります。

  1. 状態管理が煩雑: isLoading, error, data の3つの状態を手動で管理する必要がある
  2. ボイラープレートが多い: 毎回同じようなtry-catch-finallyのパターンを書く必要がある
  3. キャッシュがない: コンポーネントのアンマウント・再マウントのたびにデータを再取得する
  4. 重複リクエストの制御が難しい: 同じデータを複数のコンポーネントで使う場合、それぞれでAPIリクエストが発生する

2. TanStack QueryのuseQueryを使う方法

TanStack Queryの useQuery を使うと、useEffectでの問題が大幅に改善され、以下のような実装になります。

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

type User = {
  id: number;
  name: string;
  email: string;
};

const fetchUsers = async (): Promise<User[]> => {
  const response = await fetch('/api/users');
  if (!response.ok) {
    throw new Error('Failed to fetch users');
  }
  return response.json();
};

function UserList() {
  const { data, isLoading, error } = useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
  });

  if (isLoading) {
    return <div>Loading...</div>;
  }

  if (error) {
    return <div>Error: {error.message}</div>;
  }

  return (
    <ul>
      {data?.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

useQuery を使うことで、以下のメリットが得られます。

  1. 状態管理の簡素化: isLoading, error, data が自動的に管理される
  2. キャッシュ機能: 同じ queryKey に対するリクエストはキャッシュされ、重複リクエストが防止される
  3. バックグラウンド更新: フォーカス時やネットワーク復帰時に自動的にデータを再取得
  4. リトライ機能: 失敗時に自動リトライ。 リトライ数はuseQueryのretryオプションで設定可能です

しかし、コンポーネント内でまだ isLoadingerror の分岐処理を書く必要があります。また、型定義を見ると dataUser[] | undefined となっており、データを使う際は data?.map のようにオプショナルチェーンが必要です。

3. useSuspenseQuery + React Suspenseを使う方法

useSuspenseQuery と React の Suspense を組み合わせると、コンポーネントがさらにシンプルになります。

import { useSuspenseQuery } from '@tanstack/react-query';
import { Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary';

type User = {
  id: number;
  name: string;
  email: string;
};

const fetchUsers = async (): Promise<User[]> => {
  const response = await fetch('/api/users');
  if (!response.ok) {
    throw new Error('Failed to fetch users');
  }
  return response.json();
};

// データ取得コンポーネント - loading/errorの分岐がない
function UserList() {
  const { data } = useSuspenseQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
  });

  // data は User[] 型(User[] | undefinedではない)
  return (
    <ul>
      {data.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

// 親コンポーネントでSuspenseとErrorBoundaryをラップ
function App() {
  return (
    <ErrorBoundary fallback={<div>Something went wrong</div>}>
      <Suspense fallback={<div>Loading...</div>}>
        <UserList />
      </Suspense>
    </ErrorBoundary>
  );
}

このアプローチには以下の大きなメリットがあります。

メリット1: loading/errorをコンポーネントの外に追い出せる

UserList コンポーネントの中に isLoadingerror の分岐がありません。ローディング状態は親の Suspense コンポーネントの fallback で、エラー状態は ErrorBoundary でそれぞれハンドリングされます。

これにより、データを取得するコンポーネントは「データがある前提」でUIをレンダリングするコードだけを書けばよくなり、コンポーネントの責務が明確になります。

メリット2: dataが常に存在することが保証される

useQuery の場合:

const { data } = useQuery({ queryKey: ['users'], queryFn: fetchUsers });
// data の型は User[] | undefined

useSuspenseQuery の場合:

const { data } = useSuspenseQuery({ queryKey: ['users'], queryFn: fetchUsers });
// data の型は User[](undefinedではない)

useSuspenseQuery ではデータが取得されるまでコンポーネントがサスペンドされるため、レンダリング時には必ずデータが存在します。これにより、data?.map のようなオプショナルチェーンや、if (!data) のようなnullチェックが不要になります。

型安全性が高まり、コードがより簡潔になります。

useSuspenseQuery使用時の注意点: Request Waterfalls

useSuspenseQuery は非常に便利ですが、一つ注意すべき点があります。それは Request Waterfalls(リクエストの直列化) の問題です。

Request Waterfallsとは

Request Waterfallsとは、「あるリソースのリクエストが、別のリソースのリクエストが完了するまで開始されない」状態を指します。

例えば、以下のようなコンポーネントを考えてみましょう。

// これは問題のあるコード!
function Dashboard() {
  const { data: users } = useSuspenseQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
  });

  const { data: posts } = useSuspenseQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
  });

  const { data: comments } = useSuspenseQuery({
    queryKey: ['comments'],
    queryFn: fetchComments,
  });

  return (
    <div>
      {/* users, posts, comments を使ったUI */}
    </div>
  );
}

このコードでは、3つのクエリが 直列 に実行されます。

時間 →
|-- users fetch --|
                  |-- posts fetch --|
                                    |-- comments fetch --|

最初の useSuspenseQuery でコンポーネントがサスペンドされ、users のデータが取得されるまで次の useSuspenseQuery は実行されません。これが繰り返されるため、本来並列で実行できるはずのリクエストが直列化され、総待ち時間が長くなってしまいます。

解決策: useSuspenseQueriesを使う

この問題を解決するには、useSuspenseQueries を使って複数のクエリを並列実行します。

import { useSuspenseQueries } from '@tanstack/react-query';

function Dashboard() {
  const [
    { data: users },
    { data: posts },
    { data: comments },
  ] = useSuspenseQueries({
    queries: [
      { queryKey: ['users'], queryFn: fetchUsers },
      { queryKey: ['posts'], queryFn: fetchPosts },
      { queryKey: ['comments'], queryFn: fetchComments },
    ],
  });

  return (
    <div>
      {/* users, posts, comments を使ったUI */}
    </div>
  );
}

useSuspenseQueries を使うと、すべてのクエリが 並列 に実行されます。

時間 →
|-- users fetch --|
|-- posts fetch --|
|-- comments fetch --|

すべてのリクエストが同時に開始されるため、総待ち時間は最も遅いリクエストの時間のみとなり、大幅なパフォーマンス改善が期待できます。

親子コンポーネント間のWaterfall

親コンポーネントと子コンポーネントの両方がクエリを持つ場合もWaterfallが発生します。以下の例を見てみましょう。

function UserProfile({ userId }: { userId: number }) {
  const { data: user } = useSuspenseQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  });

  return (
    <div>
      <h1>{user.name}</h1>
      <UserPosts userId={userId} />
    </div>
  );
}

function UserPosts({ userId }: { userId: number }) {
  const { data: posts } = useSuspenseQuery({
    queryKey: ['posts', userId],
    queryFn: () => fetchPostsByUser(userId),
  });

  return <PostList posts={posts} />;
}

この場合、UserPostsは親からuserIdを受け取っていますが、このuserIdUserProfileがレンダリングされる時点で既に利用可能です。しかし、親のクエリが完了するまで子はレンダリングされないため、Waterfallが発生します。

時間 →
|-- fetchUser --|
                |-- fetchPostsByUser --|

解決策: クエリを親にまとめる

依存関係にないクエリを親コンポーネントにまとめてuseSuspenseQueriesで並列実行します。

function UserProfile({ userId }: { userId: number }) {
  const [{ data: user }, { data: posts }] = useSuspenseQueries({
    queries: [
      { queryKey: ['user', userId], queryFn: () => fetchUser(userId) },
      { queryKey: ['posts', userId], queryFn: () => fetchPostsByUser(userId) },
    ],
  });

  return (
    <div>
      <h1>{user.name}</h1>
      <PostList posts={posts} />
    </div>
  );
}

依存関係のあるWaterfall

より難しいケースとして、子のクエリが親のデータに依存する場合があります。

function UserProfile({ userId }: { userId: number }) {
  const { data: user } = useSuspenseQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  });

  return (
    <div>
      <h1>{user.name}</h1>
      {/* user.teamId は親のクエリ結果に依存 */}
      <TeamInfo teamId={user.teamId} />
    </div>
  );
}

function TeamInfo({ teamId }: { teamId: number }) {
  const { data: team } = useSuspenseQuery({
    queryKey: ['team', teamId],
    queryFn: () => fetchTeam(teamId),
  });

  return <div>Team: {team.name}</div>;
}

この場合、TeamInfoのクエリはuser.teamIdに依存しているため、親のクエリが完了するまで子のクエリを開始できません。これは単純にクエリをまとめるだけでは解決できません。

解決策: APIの改善

SPAにおいてこのようなDependent Waterfallを解消する最も効果的な方法は、バックエンドのAPIを改善することです。

例えば、上記の例ではfetchUserのレスポンスにチーム情報も含めるようAPIを拡張します。

// Before: 2回のAPIリクエストが必要
// GET /api/users/:id -> { id, name, teamId }
// GET /api/teams/:id -> { id, name }

// After: 1回のAPIリクエストで完結
// GET /api/users/:id?include=team -> { id, name, team: { id, name } }

function UserProfile({ userId }: { userId: number }) {
  const { data: user } = useSuspenseQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUserWithTeam(userId), // チーム情報も含めて取得
  });

  return (
    <div>
      <h1>{user.name}</h1>
      <div>Team: {user.team.name}</div>
    </div>
  );
}

おわりに

この記事では、Reactでのデータフェッチ方法を3つのアプローチで比較しました。

アプローチ loading/error管理 dataの型 キャッシュ
useEffect 手動 T | null なし
useQuery 自動(コンポーネント内で分岐) T | undefined あり
useSuspenseQuery 自動(Suspense/ErrorBoundary) T あり

useSuspenseQuery + Suspense の組み合わせにより、コンポーネントがシンプルになり、型安全性も向上します。ただし、複数のクエリを同じコンポーネントで使う場合は、Request Waterfallsに注意して useSuspenseQueries を使いましょう。

参考