なぜ GraphQL × Rork なのか
REST APIが長らくモバイル開発の標準でしたが、GraphQL はその課題を根本から解決する設計思想を持っています。「必要なフィールドだけ取得できる」「1回のリクエストで複数リソースを結合できる」「リアルタイムサブスクリプションが標準仕様」という三つの強みは、複雑なUIを持つRorkアプリと非常に相性が良いのです。
この実装で扱う範囲:
- Apollo Clientのセットアップとプロバイダー設定
- 型安全なクエリ・ミューテーションの実装パターン
- WebSocketを使ったリアルタイムサブスクリプション
- InMemoryCacheによるキャッシュ最適化テクニック
- エラーハンドリングとローディング状態の管理
対象読者: React NativeおよびRorkの基礎知識があり、より洗練されたAPIアーキテクチャを導入したい中〜上級の開発者
前提知識と環境準備
必要な環境
- Rork Max(推奨)またはRork Pro
- Node.js 18以上
- GraphQLバックエンド(ここではHasuraを例として使用)
パッケージのインストール
RorkプロジェクトのAIチャットで以下を指示し、必要なパッケージを追加します:
# Apollo Client関連パッケージ
npm install @apollo/client graphql
# WebSocketサポート(サブスクリプション用)
npm install graphql-ws
# 型生成ツール(推奨)
npm install --save-dev @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-operations @graphql-codegen/typescript-react-apolloGraphQLクライアントのセットアップ
Apollo Clientの初期化
まず、Apollo Clientのインスタンスを作成するファイルを用意します:
// lib/apolloClient.ts
import { ApolloClient, InMemoryCache, createHttpLink, split } from '@apollo/client';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
import { createClient } from 'graphql-ws';
import { getMainDefinition } from '@apollo/client/utilities';
// HTTP接続(クエリ・ミューテーション用)
const httpLink = createHttpLink({
uri: 'https://your-hasura-instance.hasura.app/v1/graphql',
headers: {
'x-hasura-admin-secret': process.env.EXPO_PUBLIC_HASURA_SECRET || '',
},
});
// WebSocket接続(サブスクリプション用)
const wsLink = new GraphQLWsLink(
createClient({
url: 'wss://your-hasura-instance.hasura.app/v1/graphql',
connectionParams: {
headers: {
'x-hasura-admin-secret': process.env.EXPO_PUBLIC_HASURA_SECRET || '',
},
},
})
);
// 操作の種類に応じてリンクを自動選択
const splitLink = split(
({ query }) => {
const definition = getMainDefinition(query);
return (
definition.kind === 'OperationDefinition' &&
definition.operation === 'subscription'
);
},
wsLink, // サブスクリプション → WebSocket
httpLink // クエリ・ミューテーション → HTTP
);
// キャッシュ設定
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
todos: {
// ページネーションのマージ設定
keyArgs: ['where'],
merge(existing = [], incoming) {
return [...existing, ...incoming];
},
},
},
},
},
});
export const client = new ApolloClient({
link: splitLink,
cache,
defaultOptions: {
watchQuery: {
fetchPolicy: 'cache-and-network', // キャッシュ優先、バックグラウンドで更新
},
},
});プロバイダーの設定
app/_layout.tsx(App Router使用の場合)でApolloProviderをラップします:
// app/_layout.tsx
import { ApolloProvider } from '@apollo/client';
import { client } from '../lib/apolloClient';
import { Stack } from 'expo-router';
export default function RootLayout() {
return (
<ApolloProvider client={client}>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
</Stack>
</ApolloProvider>
);
}クエリの実装 — データ取得パターン
基本的なクエリ
useQueryフックを使ったシンプルなデータ取得の例です:
// hooks/useTodos.ts
import { gql, useQuery } from '@apollo/client';
// GraphQLクエリの定義(型安全)
const GET_TODOS = gql`
query GetTodos($userId: uuid!) {
todos(where: { user_id: { _eq: $userId } }, order_by: { created_at: desc }) {
id
title
completed
created_at
priority
}
}
`;
export type Todo = {
id: string;
title: string;
completed: boolean;
created_at: string;
priority: 'low' | 'medium' | 'high';
};
export function useTodos(userId: string) {
const { data, loading, error, refetch } = useQuery<{ todos: Todo[] }>(
GET_TODOS,
{
variables: { userId },
skip: !userId, // userIdがない場合はスキップ
}
);
return {
todos: data?.todos ?? [],
loading,
error,
refetch,
};
}コンポーネントでの使用例
// components/TodoList.tsx
import React from 'react';
import { View, Text, FlatList, ActivityIndicator, StyleSheet } from 'react-native';
import { useTodos } from '../hooks/useTodos';
type Props = {
userId: string;
};
export function TodoList({ userId }: Props) {
const { todos, loading, error } = useTodos(userId);
if (loading) {
return (
<View style={styles.center}>
<ActivityIndicator size="large" color="#6366f1" />
<Text style={styles.loadingText}>データを取得中...</Text>
</View>
);
}
if (error) {
return (
<View style={styles.center}>
<Text style={styles.errorText}>
エラーが発生しました: {error.message}
</Text>
</View>
);
}
return (
<FlatList
data={todos}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<View style={styles.todoItem}>
<Text style={[styles.title, item.completed && styles.completed]}>
{item.title}
</Text>
<View style={[styles.priority, styles[`priority_${item.priority}`]]}>
<Text style={styles.priorityText}>{item.priority}</Text>
</View>
</View>
)}
/>
);
}
// スタイルは省略ミューテーションの実装 — データ更新パターン
楽観的更新(Optimistic UI)
ユーザー体験を向上させるために、サーバーのレスポンスを待たずにUIを先に更新する「楽観的更新」を実装します:
// hooks/useTodoMutations.ts
import { gql, useMutation } from '@apollo/client';
const ADD_TODO = gql`
mutation AddTodo($title: String!, $userId: uuid!, $priority: String!) {
insert_todos_one(
object: { title: $title, user_id: $userId, priority: $priority, completed: false }
) {
id
title
completed
created_at
priority
}
}
`;
const TOGGLE_TODO = gql`
mutation ToggleTodo($id: uuid!, $completed: Boolean!) {
update_todos_by_pk(
pk_columns: { id: $id }
_set: { completed: $completed }
) {
id
completed
}
}
`;
const DELETE_TODO = gql`
mutation DeleteTodo($id: uuid!) {
delete_todos_by_pk(id: $id) {
id
}
}
`;
export function useTodoMutations(userId: string) {
const [addTodo, { loading: addLoading }] = useMutation(ADD_TODO, {
// 楽観的更新: サーバーレスポンスを待たずにキャッシュを更新
optimisticResponse: ({ title, priority }) => ({
insert_todos_one: {
__typename: 'todos',
id: `temp-${Date.now()}`, // 一時的なID
title,
completed: false,
created_at: new Date().toISOString(),
priority,
},
}),
update(cache, { data }) {
// キャッシュに新しいTODOを追加
cache.modify({
fields: {
todos(existingTodos = []) {
const newTodoRef = cache.writeFragment({
data: data?.insert_todos_one,
fragment: gql`
fragment NewTodo on todos {
id
title
completed
created_at
priority
}
`,
});
return [newTodoRef, ...existingTodos];
},
},
});
},
});
const [toggleTodo] = useMutation(TOGGLE_TODO);
const [deleteTodo] = useMutation(DELETE_TODO, {
// 削除後にキャッシュからも除去
update(cache, { data }) {
const deletedId = data?.delete_todos_by_pk?.id;
cache.evict({ id: cache.identify({ __typename: 'todos', id: deletedId }) });
cache.gc(); // ガベージコレクション
},
});
return {
addTodo: (title: string, priority: string) =>
addTodo({ variables: { title, userId, priority } }),
toggleTodo: (id: string, completed: boolean) =>
toggleTodo({ variables: { id, completed } }),
deleteTodo: (id: string) => deleteTodo({ variables: { id } }),
addLoading,
};
}サブスクリプション — リアルタイム更新の実装
GraphQLの真価はリアルタイムサブスクリプションにあります。チャットアプリや共同編集ツールを作る際に特に威力を発揮します:
// hooks/useRealtimeTodos.ts
import { gql, useSubscription } from '@apollo/client';
const TODOS_SUBSCRIPTION = gql`
subscription WatchTodos($userId: uuid!) {
todos(
where: { user_id: { _eq: $userId } }
order_by: { created_at: desc }
) {
id
title
completed
created_at
priority
}
}
`;
export function useRealtimeTodos(userId: string) {
const { data, loading, error } = useSubscription(
TODOS_SUBSCRIPTION,
{
variables: { userId },
skip: !userId,
// 接続状態の監視
onSubscriptionData: ({ subscriptionData }) => {
console.log('リアルタイム更新受信:', subscriptionData.data?.todos?.length, '件');
},
onSubscriptionComplete: () => {
console.log('サブスクリプション終了');
},
}
);
return {
todos: data?.todos ?? [],
loading,
error,
};
}実行結果の例: サーバー側でTODOが追加・更新・削除されると、接続中のすべてのクライアントに即座に反映されます。WebSocket接続が維持されている限り、
pollingや手動refetch不要でUIが自動更新されます。
キャッシュ最適化 — 高パフォーマンスのための設計
Apollo Clientの InMemoryCache を適切に設定することで、ネットワークリクエストを削減し、UXを向上させることができます:
// lib/apolloClient.ts(キャッシュ設定の詳細版)
const cache = new InMemoryCache({
typePolicies: {
// Todoエンティティのキャッシュポリシー
todos: {
keyFields: ['id'], // ユニークキー(デフォルトはid)
},
Query: {
fields: {
// オフセットベースのページネーション
todos: {
keyArgs: ['where', 'order_by'],
merge(existing, incoming, { args }) {
const merged = existing ? existing.slice(0) : [];
if (args?.offset !== undefined) {
for (let i = 0; i < incoming.length; ++i) {
merged[args.offset + i] = incoming[i];
}
} else {
merged.push.apply(merged, incoming);
}
return merged;
},
read(existing, { args }) {
if (!existing) return existing;
const { offset = 0, limit = existing.length } = args ?? {};
return existing.slice(offset, offset + limit);
},
},
},
},
},
});fetchPolicy の使い分け
| fetchPolicy | ユースケース | 説明 |
|---|---|---|
cache-first(デフォルト) | 静的データ | キャッシュにあればネットワーク不要 |
cache-and-network | ダッシュボード | キャッシュ表示後バックグラウンド更新 |
network-only | 決済・認証 | 常に最新データを取得 |
no-cache | センシティブデータ | キャッシュしない |
cache-only | オフラインモード | ネットワークを使わない |
よくあるエラーと対処法
「Network error: Failed to fetch」が発生する
→ エンドポイントURLとヘッダー(認証トークン)を確認してください。ExpoのAndroidエミュレーターからlocalhostに接続する場合は http://10.0.2.2:4000/graphql を使用します。
「Cache data may be lost when replacing the todos field」警告が出る
→ typePolicies で該当フィールドの merge 関数を定義することで解決します。上記のキャッシュ設定を参照してください。
サブスクリプションが接続されない
→ WebSocketのURLが wss://(TLS)になっているか確認してください。開発環境では ws:// でも動作しますが、本番環境では必ずTLSを使用してください。
「Cannot query field X on type Y」エラー → GraphQLスキーマと実際のクエリのフィールド名が不一致です。HasuraのGraphiQL UIでスキーマを確認してクエリを修正してください。
応用 — 認証との統合
JWTトークンを使った認証付きリクエストの実装例:
// lib/apolloClient.ts(認証統合版)
import { setContext } from '@apollo/client/link/context';
import * as SecureStore from 'expo-secure-store';
// 認証リンク(リクエストごとにトークンを付与)
const authLink = setContext(async (_, { headers }) => {
const token = await SecureStore.getItemAsync('auth_token');
return {
headers: {
...headers,
authorization: token ? `Bearer ${token}` : '',
},
};
});
// リンクの連結: authLink → httpLink
const authenticatedHttpLink = authLink.concat(httpLink);詳細な認証実装については、Rork × Firebase/Supabase認証ガイドを参照してください。
また、GraphQL × Supabaseの組み合わせでリアルタイムチャットを実装する具体例は、SupabaseチャットアプリをRorkで構築するで詳しく解説しています。
状態管理との統合パターンについては、Rorkアプリの状態管理パターン完全ガイドもあわせてご覧ください。
まとめ
ここではRorkアプリにApollo Clientを統合してGraphQL APIと連携するための実践的な手法を解説しました。
GraphQLの導入は初期コストがかかりますが、一度設定してしまえば、必要なデータだけを効率よく取得でき、リアルタイム機能の実装も大幅に簡単になります。特に複数の画面でデータを共有したり、リアルタイムコラボレーション機能を持つアプリには、今回紹介したApollo Clientのキャッシュ戦略と楽観的更新が非常に有効です。
ぜひ本記事のコードを参考に、Rorkアプリのデータ連携を次のレベルへと引き上げてみてください。