Apollo Clientだけじゃない!GraphQLならTanStack Query(React Query)がおすすめな理由

2024-10-11

Apollo Clientだけじゃない!GraphQLならTanStack Query(React Query)がおすすめな理由

はじめに

フロントエンドでGraphQLを扱う際、Apollo Clientが第一の選択肢ですが、TanStack QueryはGraphQLだけでなく、一般的なPromiseを扱う場合にも非常に充実した機能セットと容易な統合を提供します。本記事では、GraphQLクライアントとの基本的な比較と、TanStack Queryでのサンプル実装を見ていきます。

TanStack Queryとは

https://github.com/TanStack/query

TanStack Query(旧React Query)は、Reactアプリケーションでサーバーからのデータ取得やキャッシュ、更新を簡単に管理できるライブラリです。従来、データフェッチやキャッシュ、エラー処理などは手動で実装する必要がありましたが、TanStack Queryを使うことでこれらの複雑な処理をシンプルに扱えます。

  • Promiseを返す任意の関数と組み合わせて、データの取得を効率的に行える
  • 取得したデータを自動的にキャッシュし、再利用することでパフォーマンスを向上
  • データの変更があった場合、自動的に最新の情報に更新
  • エラーハンドリングや再試行もサポート
  • Devtoolsを使用して、クエリの状態やキャッシュを視覚的にデバッグ可能

特に重要なのが、TanStack Queryはバックエンドの技術スタックに依存せず、GraphQL、REST API、その他のデータソースとも容易に統合できるという点です。Promiseを返す関数であれば何でも扱えるため、柔軟性が高く、既存のコードベースにも導入しやすいのが特徴です。

これにより、サーバー状態の管理とUIロジックを明確に分離でき、コードの可読性と保守性が向上します。TanStack Queryを使用すると、データ取得に関する複雑な実装を気にすることなく、ビジネスロジックに集中できます。

なぜApollo ClientよりTanStack Queryを使うのか?

なぜApollo ClientよりTanStack Queryを選ぶのか?それはApollo Clientより使いやすいからです。

Apollo Clientを使用する際の主な課題は、そのAPIを理解するための急な学習曲線です。APIフェッチ、リクエストの重複排除、状態管理、キャッシュといった概念はGraphQLとは独立しています。

TanStack Queryが異なるのは、GraphQLに特化せず、Promiseをサポートするものであれば何でも動作する点で、コードベースにおける関心の分離が明確になることです。GraphQLをRESTに切り替えても、TanStack Queryのコードはほとんど同じままです。これにより、必要な範囲で機能を個別に学習・実装することが容易になります。詳細な機能比較については、こちらをご覧ください:

https://tanstack.com/query/v4/docs/framework/react/comparison

Tanstack QueryでGraphQLを呼ぶ

お待たせしました、コードを見ていきましょう。

なお、TanStack Queryには独自のAPIクライアントがないため、GraphQLクエリを簡単に扱えるライブラリであるgraphql-requestを使用します。

Reactのテンプレートをセットアップしたら(Viteを使用することをお勧めします)、以下のパッケージをインストールする必要があります。

# 依存関係
npm i graphql @tanstack/react-query

# 開発用依存関係
npm i -D @graphql-codegen/cli @graphql-codegen/client-preset @graphql-codegen/typescript @graphql-codegen/typescript-graphql-request @graphql-codegen/typescript-operations

# オプション
npm i -D @tanstack/react-query-devtools

package.jsonに以下を追加します。

"graphql": {
    "schema": "path/to/your/graphql/backend/schemas/queries/*.graphql"
}

graphql-codegenを設定しましょう。

出典: https://the-guild.dev/graphql/codegen/docs/getting-started

codegen.ts

import { CodegenConfig } from "@graphql-codegen/cli";

// これはコードベース内の任意のパスで構いません
const rootGQLDirectory = "src/lib/gql/__gen__/";
const pluginsDirectory = `${rootGQLDirectory}/plugins`;

const config: CodegenConfig = {
  // ローカルまたはURL
  schema: "path/to/your/graphql/backend/schemas/queries/*.graphql",
  // フロントエンドでGraphQLクエリを書いている場所
  documents: ["src/lib/gql/**/queries.graphql"],
  ignoreNoDocuments: true, // ウォッチャーの体験を向上
  generates: {
    [rootGQLDirectory]: {
      preset: "client",
    },
    // codegenを使用してgraphql-requestを統合
    // 他のプラグインについてはソースを確認
    [`${pluginsDirectory}/graphql-request.ts`]: {
      plugins: ["typescript", "typescript-operations", "typescript-graphql-request"],
    },
  },
};

export default config;

以下のコマンドで実行します。

"scripts": {
    // 一度だけ生成する場合
    "gql": "graphql-codegen",
    // 自動的にウォッチして更新
    "gql:watch": "graphql-codegen --watch",
},

npm run gql

これにより、rootGQLDirectoryに以下のファイルが作成されます。

├── fragment-masking.ts
├── gql.ts
├── graphql.ts
├── index.ts
└── plugins
    └── graphql-request.ts

これらのファイルの中身を知る必要はありません。そのまま使用します。

すべての準備が整いました。実際の実装として、サンプルのTODOアプリを使ってみましょう。

Tanstack QueryとGraphQLの両方のコンテキストを設定する必要があります。これにより、基盤となるコンポーネントで対応するフックを使用できます。

GraphQLリクエストプロバイダー

GraphQLRequestProvider.tsx

リクエストに追加のパラメータを加える必要がある場合、例えばauthorizationヘッダーを追加する場合、ここで行うことができます。

import { GraphQLClient } from "graphql-request";
import { createContext, ReactNode, useContext } from "react";

import { Sdk, getSdk } from "/path/to/your/gen/plugins/graphql-request";

const GraphQLContext = createContext<Sdk | undefined>(undefined);

export const useGraphQLContext = () => useContext(GraphQLContext);

export const GraphQLRequestProvider = ({ children }: { children?: ReactNode }) => {
  const endpoint = `${YOUR_API_URL}`;
  const graphQLClient = new GraphQLClient(endpoint);
  const sdk = getSdk(graphQLClient);

  return <GraphQLContext.Provider value={sdk}>{children}</GraphQLContext.Provider>
};

TanStack Queryプロバイダー

TanStackQueryProvider.tsx

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

export { useGraphQLContext } from "path/to/your/GraphQLRequestProvider";

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      // 20秒
      staleTime: 20 * 1000,
    },
  },
});

const TanStackQueryProvider = ({ children }: { children?: React.ReactNode }) => {
  return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};

export { TanStackQueryProvider };

App.tsxにこれらを両方含めます。

const App: React.FC = () => {
  return (
    <TanStackQueryProvider>
      <GraphQLRequestProvider>{children}</GraphQLRequestProvider>
    </TanStackQueryProvider>
  );
};

フォルダ構成は以下のようになります。

├── index.ts -- 必要なコンポーネントをエクスポート
├── queries.graphql -- 実際のGraphQLクエリまたはミューテーション
├── useApi.ts -- 実際に使用される抽象化されたメソッド
└── useQueries.ts -- Tanstack Queryとgraphql-requestの実装

queries.graphql

fragment Todo on TODO {
  id
  text
  status
}

query GetTodo {
  ...Todo
}

mutation CreateTodo($input: CreateTodoInput!) {
  createTodo(input: $input) {
    todo {
      ...Todo
    }
  }
}

mutation DeleteTodo($input: DeleteTodoInput!) {
  deleteTodo(input: $input) {
    id
  }
}

useQueries.ts

import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useGraphQLContext } from "/path/to/your/GraphQLRequestProvider.tsx";

// Tanstack Queryのキャッシュで使用されるキー
enum TodoQueryKeys {
  GetTodo = "getTodo",
}

type Todo = {
  id: string;
  text: string;
  status: string;
};

export const useQueries = () => {
  const graphQLContext = useGraphQLContext();
  const queryClient = useQueryClient();

  const createTodoMutation = useMutation({
    // ミューテーション関数
    mutationFn: async (name: string) => {
      // graphql-requestプラグインからインポートされたメソッドに注目
      // これにより、入力の型インテリセンスも提供されます
      const data = await graphQLContext?.CreateTodo({ input: { text } });
      return data.createTodo.todo;
    },
    onSuccess: newTodo => {
      // 新しいTODOが作成された後、再度TODOをフェッチする必要はありません。既存のリストに追加します
      queryClient.setQueryData([TodoQueryKeys.GetTodo], (data: Todo[]) => [
        ...data,
        newTodo,
      ]);
    },
  });

  const useGetTodosQuery = () =>
    useQuery({
      queryKey: [TodoQueryKeys.GetTodo],
      queryFn: async () => {
        const data = await graphQLContext?.GetTodo();
        return data.getTodo.todo;
      },
      // TODOは無期限にキャッシュ可能
      staleTime: Infinity,
    });

  const deleteTodoMutation = useMutation({
    mutationFn: async (todoId: string) => {
      const data = await graphQLContext?.DeleteTodo({ input: { todoId } });
      return data?.deleteTodo.id;
    },
    // TODOが削除された後、再度フェッチする必要はありません。既存のリストから削除します
    onSuccess: todoId => {
      queryClient.setQueryData([TodoQueryKeys.GetTodo], (data: Todo[]) => {
        data.splice(
          data.findIndex(w => w.id === todoId),
          1,
        );
        return [...data];
      });
    },
  });

  return {
    createTodoMutation,
    useGetTodosQuery,
    deleteTodoMutation,
  };
};

useApi.ts

import { useQueries } from "./useQueries";

export const useTodo = () => {
  const {
    createTodoMutation,
    useGetTodosQuery,
    deleteTodoMutation,
  } = useQueries();

  const createTodo = async (name: string): Promise<NewTodoMutation> => {
    const { mutateAsync, ...rest } = createTodoMutation;
    try {
      const data = await mutateAsync(name);
      return { todo: data, ...rest };
    } catch (_err) {
      return { todo: undefined, ...rest };
    }
  };

  const useGetTodo = (): GetTodos => {
    const { data: todos, ...rest } = useGetTodosQuery();
    return {
      todos,
      ...rest,
    };
  };

  const deleteTodo = async (todoId: string): Promise<DeleteTodoMutation> => {
    const { mutateAsync, ...rest } = deleteTodoMutation;
    try {
      const data = await mutateAsync(todoId);
      return { todo: data, ...rest };
    } catch (_err) {
      return { todo: undefined, ...rest };
    }
  };

  return {
    createTodo,
    useGetTodo,
    deleteTodo,
  };
};

APIの使用例(Todo.tsx):

import React from 'react';
import { useTodo } from '/path/to/your/useApi.ts'; // これらの関数はAPIファイルからインポートされていると仮定します

const TodoList = () => {
  const { createTodo, useGetTodo, deleteTodo } = useTodo();

  const { todos, isLoading } = useGetTodo();

  const handleCreateTodo = async () => {
    const newTodo = {
      title: 'New Todo',
      status: false,
    };
    await createTodo(newTodo);
    // Tanstack QueryによりTODOは自動的に更新されます
  };

  const handleDeleteTodo = async (id) => {
    await deleteTodo(id);
    // Tanstack QueryによりTODOは自動的に更新されます
  };

  return (
    <>
      {isLoading ? (
        <div>
          <button onClick={handleCreateTodo}>Create Todo</button>
          <ul>
            {todos.map(todo => (
              <li key={todo.id}>
                {todo.title}
                <button onClick={() => handleDeleteTodo(todo.id)}>Delete</button>
              </li>
            ))}
          </ul>
        </div>
      ) : (
        <div>読み込み中...</div>
      )}
    </>
  );
};

export default TodoList;

まとめ

ご覧のとおり、TanStack Queryとgraphql-requestを使用してGraphQLを始めるのは非常に簡単です。これは基本的な実装であり、他にも多くの機能があり、それぞれ独自の記事が必要なほどです。詳細については、TanStack Queryのドキュメントを参照するのが最適です。

この実装は、キャッシュ機能、状態管理、TypeScriptのインテリセンス、ネイティブのGraphQLクエリサポートなどを提供します。

実装のための構造を定義し、基盤となるAPIクライアントと状態管理をUI層の最小限の変更で他のコンポーネントと交換できるよう、十分な抽象化を提供しました。

Happy Hacking!

Japanese

Eukaryaでは様々な職種で積極的にエンジニア採用を行っています!OSSにコントリビュートしていただける皆様からの応募をお待ちしております!

Eukarya 採用ページ

Eukarya is hiring for various positions! We are looking forward to your application from everyone who can contribute to OSS!

Eukarya Careers

Eukaryaは、Re:Earthと呼ばれるWebGISのSaaSの開発運営・研究開発を行っています。Web上で3Dを含むGIS(地図アプリの公開、データ管理、データ変換等)に関するあらゆる業務を完結できることを目指しています。ソースコードはほとんどOSSとしてGitHubで公開されています。

Eukarya Webサイト / ➔ note / ➔ GitHub

Eukarya is developing and operating a WebGIS SaaS called Re:Earth. We aim to complete all GIS-related tasks including 3D (such as publishing map applications, data management, and data conversion) on the web. Most of the source code is published on GitHub as OSS.

Eukarya Official Page / ➔ Medium / ➔ GitHub