GraphQLは、RESTful APIと並んで人気のあるAPIです。RESTful APIと異なり、クライアントが必要なデータを指定して取得できるため、過不足のないデータ取得が可能です。そして、多くの場合GraphQLクライアントと呼ばれるライブラリを通して利用されます。

Apolloが有名ですが、今回はRelayというGraphQLクライアントを紹介します。Relayは、Meta(旧Facebook)が開発したGraphQLクライアントで、特に大規模なアプリケーションでのデータ管理に強みを持っています。

Relayについて

RelayのWebサイトでは、その特徴を以下のように挙げています。

  1. 宣言的なデータ取得とFragment Colocation
    各Reactコンポーネントが自身で必要なデータをGraphQLフラグメントとして宣言します。そうすることで、再利用性が高まり、不要なデータの取得を防げます。
  2. 静的解析と型安全性
    ビルド時にGraphQLクエリを静的解析し、TypeScriptの型定義を生成
  3. 効率的なデータフェッチとキャッシュ管理
    アプリ全体のデータ要求を最適化し、重複するフィールドの除去やキャッシュを管理
  4. 高度なUIパターンのサポート
    ページネーション、データの再取得、UI更新、ロールバックなど、複雑なUIパターンを標準でサポート
  5. スケーラビリティとパフォーマンス
    大規模なシステムでもスケーラブルに動作するよう設計

Apollo Clientとの違い

Apollo Clientとの違いとして、RelayはReactに依存している点が挙げられます。対するApollo Clientは、プラットフォーム非依存です。また、RelayはキャッシュやReactコンポーネントとの相性は良いですが、学習コストが高いという難点があります。

では、ここから実際に作ってみたものと、その手順を紹介します。

GitHub GraphQLによるリポジトリ検索デモ

今回は、GitHubのGraphQL APIを使って、リポジトリ検索アプリを作成します。利用している技術は以下の通りです。

セットアップ

依存関係

ライブラリをインストールします。

npm i react react-dom react-relay relay-runtime
npm i -D typescript vite @vitejs/plugin-react vite-plugin-relay \
  graphql relay-compiler babel-plugin-relay \
  @types/react @types/react-dom

package.json には Relay の設定を含めます。この設定は、RelayコンパイラがGraphQLスキーマを取得し、型定義を生成するために必要です。

{
  // : 他の設定
  "relay": {
    "src": "./src",
    "schema": "./schema/github.graphql",
    "exclude": ["**/node_modules/**", "**/__mocks__/**", "**/__generated__/**"],
    "language": "typescript",
    "artifactDirectory": "./src/__generated__"
  }
}

Vite 設定

次に、Viteの設定を行います。vite.config.ts を作成し、ReactとRelayのプラグインを追加します。

// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import relay from 'vite-plugin-relay' // Relayプラグインを追加

export default defineConfig({
  plugins: [react(), relay], // Relayプラグインを追加
})

GitHubトークンの設定

GitHubにて、パーソナルアクセストークンを作成します。そして、取得したトークンを環境変数として設定します。.env ファイルをプロジェクトルートに作成し、以下のように記述します。

VITE_GITHUB_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
VITE_GITHUB_API_URL=https://api.github.com/graphql

スキーマの取得と Relay コンパイル

スキーマのダウンロード

GitHub GraphQL APIのスキーマを取得し、Relayが利用できる形式に変換します。以下のコマンドでスキーマをダウンロードします。

npm run relay:download-schema

package.json に以下のスクリプトを追加します。

{
  // : 他の設定
  "scripts": {
    "relay:download-schema": "node scripts/downloadSchema.js",
    "relay": "relay-compiler --watch"
  }
}

scripts/downloadSchema.js の内容です。詳細はコメントを参照してください。

async function downloadSchema() {
  // 環境設定からトークンを取得
  const token = process.env.VITE_GITHUB_TOKEN;

  try {
    // GraphQLのスキーマを取得
    const response = await fetch('https://api.github.com/graphql', {
      method: 'POST',
      headers: {
        'Authorization': Bearer ${token},
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        query: getIntrospectionQuery()
      }),
    });
    if (!response.ok) throw new Error(HTTP error! status: ${response.status});
    // レスポンスをJSONとしてパース
    const result = await response.json();

    if (result.errors) throw new Error(GraphQL errors: ${JSON.stringify(result.errors)});
    // スキーマをSDL形式に変換
    const clientSchema = buildClientSchema(result.data);
    const sdl = printSchema(clientSchema);
    // スキーマをファイルに保存
    const schemaPath = path.join(__dirname, '..', 'schema', 'github.graphql');
    fs.writeFileSync(schemaPath, sdl);
    // 成功メッセージ
    console.log(Schema downloaded successfully to ${schemaPath});
  } catch (error) {
    console.error('Error downloading schema:', error);
    process.exit(1);
  }
}

Relayのコンパイル

GraphQLスキーマを取得したら、Relayコンパイラを実行して型定義を生成します。以下のコマンドで実行します。型定義の生成は relay-compiler というツールを使います。

# relay-compilerを実行
npm run relay

# ウォッチの場合
npm run relay:watch

src/generated 以下に型付きのアーティファクトが生成されます。

以下はその一例です。

/**
 * @generated SignedSource<<d3df9e189fdede15649a5527f171d5bc>>
 * @lightSyntaxTransform
 * @nogrep
 */

/* tslint:disable */
/* eslint-disable */
// @ts-nocheck

import { ReaderFragment } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type RepositoryFragment$data = {
  readonly description: string | null | undefined;
  readonly forkCount: number;
  readonly id: string;
  readonly name: string;
  readonly owner: {
    readonly avatarUrl: any;
    readonly login: string;
  };
  readonly primaryLanguage: {
    readonly color: string | null | undefined;
    readonly name: string;
  } | null | undefined;
  readonly stargazerCount: number;
  readonly updatedAt: any;
  readonly url: any;
  readonly " $fragmentType": "RepositoryFragment";
};

ネットワーク周りの処理

GraphQL周りの処理

GraphQL周りのリクエストは src/relay/fetchGraphQL.ts にまとめます。このファイルでは、GitHub GraphQL APIにリクエストを送信し、レスポンスを処理します。

import type { RequestParameters, Variables, GraphQLResponse } from 'relay-runtime'
// 環境変数からGitHub APIのURLとトークンを取得
const GITHUB_API_URL = import.meta.env.VITE_GITHUB_API_URL || 'https://api.github.com/graphql'
const GITHUB_TOKEN = import.meta.env.VITE_GITHUB_TOKEN

if (!GITHUB_TOKEN) throw new Error('VITE_GITHUB_TOKEN environment variable is required')

// リクエストを送信する関数
export async function fetchGraphQL(request: RequestParameters, variables: Variables): Promise<GraphQLResponse> {
  const { text } = request
  if (!text) throw new Error('GraphQL query text is required')
  const response = await fetch(GITHUB_API_URL, {
    method: 'POST',
    headers: {
      Authorization: Bearer ${GITHUB_TOKEN},
      'Content-Type': 'application/json',
      Accept: 'application/json',
    },
    body: JSON.stringify({ query: text, variables }),
  })

  if (!response.ok) {
    if (response.status === 401) throw new Error('GitHub API authentication failed.')
    if (response.status === 403) throw new Error('GitHub API rate limit exceeded.')
    throw new Error(HTTP ${response.status}: ${response.statusText})
  }

  // レスポンスをJSONとしてパース
  const result: GraphQLResponse = await response.json()
  // GraphQL errors は result.errors に入る(部分データの場合もある)
  return result
}

Relay Environment

Relayの環境設定を行います。src/relay/Environment.ts に以下のように記述します。

import { Environment, Network, RecordSource, Store } from 'relay-runtime'
import { fetchGraphQL } from './fetchGraphQL'

const network = Network.create(async (request, variables) => {
  return await fetchGraphQL(request, variables)
})

// Relay Environmentの作成
function createRelayEnvironment(): Environment {
  return new Environment({
    network,
    store: new Store(new RecordSource(), { gcReleaseBufferSize: 10 }),
    log: process.env.NODE_ENV === 'development' ? console.log : undefined,
  })
}

export const RelayEnvironment = createRelayEnvironment()
export default RelayEnvironment

Providerの設定

RelayをReactアプリケーション全体に提供するため、src/App.tsxRelayEnvironmentProvider を追加します。 RelayEnvironment は、先ほど作成したRelayの環境設定です。

// src/App.tsx(抜粋)
import { RelayEnvironmentProvider } from 'react-relay'
import RelayEnvironment from './relay/Environment'

<RelayEnvironmentProvider environment={RelayEnvironment}>
  {/* アプリ本体 */}
</RelayEnvironmentProvider>

フラグメント/クエリ設計

では、GraphQLのフラグメントとクエリを設計します。Relayでは、フラグメントを使ってコンポーネントごとに必要なデータを定義します。

表示用のフラグメント

src/queries/RepositoryFragment.ts にリポジトリのフラグメントを定義します。このフラグメントは、リポジトリの基本情報を取得するために使用されます。

idやnameなどは、リポジトリの一意の識別子や表示名を表します。

// src/queries/RepositoryFragment.ts
import { graphql } from 'react-relay'

export const RepositoryFragment = graphql`
  fragment RepositoryFragment on Repository {
    id
    name
    description
    url
    stargazerCount
    forkCount
    updatedAt
    primaryLanguage { name color }
    owner { login avatarUrl }
  }
`

検索クエリとページネーション

リポジトリの検索クエリを定義します。src/queries/SearchRepositoriesQuery.ts に以下のように記述します。

// src/queries/SearchRepositoriesPaginationFragment.ts
import { graphql } from 'react-relay'

export const SearchRepositoriesPaginationFragment = graphql`
  fragment SearchRepositoriesPaginationFragment on Query
  @refetchable(queryName: "SearchRepositoriesPaginationFragmentRefetchQuery")
  @argumentDefinitions(
    query: { type: "String!" }
    first: { type: "Int", defaultValue: 10 }
    after: { type: "String" }
  ) {
    search(query: $query, type: REPOSITORY, first: $first, after: $after)
      @connection(key: "RepositoryList_search", filters: ["query"]) {
      repositoryCount
      pageInfo { hasNextPage endCursor }
      edges {
        cursor
        node { __typename ... on Repository { id ...RepositoryFragment } }
      }
    }
  }
`

このクエリは、リポジトリを検索し、ページネーションをサポートします。

// src/queries/SearchRepositoriesQuery.ts
import { graphql } from 'react-relay'

export const SearchRepositoriesQuery = graphql`
  query SearchRepositoriesQuery($query: String!, $first: Int!, $after: String) {
    ...SearchRepositoriesPaginationFragment @arguments(query: $query, first: $first, after: $after)
  }
`

UI コンポーネント

これまでに設定したフラグメントとクエリを使って、UIコンポーネントを作成します。

検索フォーム

まずは、検索フォームを作成します。ユーザーがキーワードを入力してリポジトリを検索できるようにします。500msのデバウンス(待機)をかけて、入力が変わるたびに検索を実行します。

// src/components/SearchForm.tsx(抜粋)
import { useState, useEffect } from 'react'

export function SearchForm({ onSearch, isLoading }: { onSearch: (q: string)=>void; isLoading?: boolean }) {
  const [query, setQuery] = useState('') // 検索キーワードの状態管理
  const [debouncedQuery, setDebouncedQuery] = useState('') // デバウンスされた検索キーワード

  // 入力のデバウンス処理
  useEffect(() => {
    const t = setTimeout(() => setDebouncedQuery(query.trim()), 500)
    return () => clearTimeout(t)
  }, [query])

  // デバウンスされたクエリが変わったら検索を実行
  useEffect(() => {
    if (debouncedQuery) onSearch(debouncedQuery)
  }, [debouncedQuery, onSearch])

  // フォームの送信処理
  return (
    <form onSubmit={e => { e.preventDefault(); if (query.trim()) onSearch(query.trim()) }}>
      <input value={query} onChange={e => setQuery(e.target.value)} disabled={isLoading} />
      <button type="submit" disabled={isLoading || !query.trim()}>検索</button>
    </form>
  )
}

検索結果

検索を実行した結果(件数など)を表示するコンポーネントを作成します。リポジトリの情報をリスト形式で表示し、ページネーションもサポートします。

// src/components/RepositoryList.tsx(抜粋)
import { Suspense } from 'react'
import { useLazyLoadQuery, usePaginationFragment } from 'react-relay'
import { SearchRepositoriesQuery } from '../queries/SearchRepositoriesQuery'
import { SearchRepositoriesPaginationFragment } from '../queries/SearchRepositoriesPaginationFragment'

function RepositoryListInner({ searchQuery }: { searchQuery: string }) {
  // 初回ロード
  const queryData = useLazyLoadQuery(
    SearchRepositoriesQuery,
    { query: searchQuery, first: 10 },
    { fetchPolicy: 'store-and-network' },
  )

  // ページネーション(connection 経由)
  const { data, loadNext, hasNext, isLoadingNext } = usePaginationFragment(
    SearchRepositoriesPaginationFragment,
    queryData,
  )

  // 検索結果がない場合の処理
  if (!data.search.edges?.length) {
    return <div>リポジトリが見つかりません</div>
  }

  // 検索結果のリポジトリをリスト表示
  return (
    <div>
      <div>{data.search.repositoryCount} リポジトリ見つかりました</div>
      <ul>
        {data.search.edges.map(edge => edge?.node?.__typename === 'Repository' && (
          <li key={edge.node.id}>{edge.node.name}</li>
        ))}
      </ul>

      {hasNext && (
        <button onClick={() => loadNext(10)} disabled={isLoadingNext}>
          {isLoadingNext ? '読込中...' : 'さらに読み込む'}
        </button>
      )}
    </div>
  )
}

リポジトリアイテム

リポジトリの詳細情報を表示するコンポーネントを作成します。リポジトリ名、説明、スター数、フォーク数、プログラミング言語、最終更新日、オーナー情報などを表示します。

// src/components/RepositoryItem.tsx(抜粋)
import { useFragment } from 'react-relay'
import { RepositoryFragment } from '../queries/RepositoryFragment'

export function RepositoryItem({ repository }: { repository: any }) {
  const data = useFragment(RepositoryFragment, repository)
  return (
    <a href={data.url} target="_blank" rel="noreferrer">
      {data.owner.login} / {data.name} ⭐️ {data.stargazerCount}
    </a>
  )
}

画面に組み込む

作成したコンポーネントをアプリケーションのメインコンポーネントに組み込みます。src/App.tsx に以下のように記述します。

// src/App.tsx(抜粋)
import { useState } from 'react'
import { RelayEnvironmentProvider } from 'react-relay'
import RelayEnvironment from './relay/Environment'
import { SearchForm } from './components/SearchForm'
import { RepositoryList } from './components/RepositoryList'

function App() {
  const [searchQuery, setSearchQuery] = useState('')
  return (
    <RelayEnvironmentProvider environment={RelayEnvironment}>
      {/* 検索フォームとリポジトリリストを組み合わせ */}
      <SearchForm onSearch={setSearchQuery} />
      {/* 検索クエリが設定されたらリポジトリリストを表示 */}
      <RepositoryList searchQuery={searchQuery} />
    </RelayEnvironmentProvider>
  )
}
export default App

実行

ではアプリを実行します。まず最初に、スキーマとアーティファクトの生成を行います。

npm run relay:download-schema
npm run relay

次に、Viteで開発サーバーを起動します。

npm run dev

ブラウザで http://localhost:3000 を開き、検索してみましょう。さらに読み込む で追加ページを取得できます。Relayの機能で取得したデータのキャッシュも自動で行われるため、同じ検索を繰り返してもAPIリクエストは最小限に抑えられます。

まとめ

Relayは、GraphQLの取得とスキーマの生成などを行うことで、型安全にデータを扱えます。特に大規模なアプリケーションでは、データの管理やページネーション、キャッシュの最適化などが重要になるので、Relayのようなクライアントを使うと便利です。

GraphQLを使ったアプリ開発の際に、ぜひRelayを試してみてください。

Relay