GraphQLは、RESTful APIと並んで人気のあるAPIです。RESTful APIと異なり、クライアントが必要なデータを指定して取得できるため、過不足のないデータ取得が可能です。そして、多くの場合GraphQLクライアントと呼ばれるライブラリを通して利用されます。
Apolloが有名ですが、今回はRelayというGraphQLクライアントを紹介します。Relayは、Meta(旧Facebook)が開発したGraphQLクライアントで、特に大規模なアプリケーションでのデータ管理に強みを持っています。
Relayについて
RelayのWebサイトでは、その特徴を以下のように挙げています。
- 宣言的なデータ取得とFragment Colocation
各Reactコンポーネントが自身で必要なデータをGraphQLフラグメントとして宣言します。そうすることで、再利用性が高まり、不要なデータの取得を防げます。 - 静的解析と型安全性
ビルド時にGraphQLクエリを静的解析し、TypeScriptの型定義を生成 - 効率的なデータフェッチとキャッシュ管理
アプリ全体のデータ要求を最適化し、重複するフィールドの除去やキャッシュを管理 - 高度なUIパターンのサポート
ページネーション、データの再取得、UI更新、ロールバックなど、複雑なUIパターンを標準でサポート - スケーラビリティとパフォーマンス
大規模なシステムでもスケーラブルに動作するよう設計
Apollo Clientとの違い
Apollo Clientとの違いとして、RelayはReactに依存している点が挙げられます。対するApollo Clientは、プラットフォーム非依存です。また、RelayはキャッシュやReactコンポーネントとの相性は良いですが、学習コストが高いという難点があります。
では、ここから実際に作ってみたものと、その手順を紹介します。
GitHub GraphQLによるリポジトリ検索デモ
今回は、GitHubのGraphQL APIを使って、リポジトリ検索アプリを作成します。利用している技術は以下の通りです。
- React
- Vite
- Relay
- 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.tsx に RelayEnvironmentProvider を追加します。 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を試してみてください。
