T-CREATOR

5 分で理解する Apollo Client - React 開発者のための GraphQL 入門

5 分で理解する Apollo Client - React 開発者のための GraphQL 入門

React開発者の皆さん、API連携で悩んだことはありませんか?

「複数のエンドポイントを叩く必要がある」「必要のないデータまで取得してしまう」「型安全性が保てない」など、REST APIを使った開発では様々な課題に直面することが多いですね。

そんな課題を一気に解決してくれるのが、GraphQLとApollo Clientの組み合わせです。今回は、React開発者の視点から、5分でApollo Clientの基本を理解できるよう、実践的な内容をお届けします。

背景

REST API の課題とGraphQLの登場

従来のREST APIでは、リソースごとにエンドポイントが分かれているため、複雑なデータを取得する際に複数のリクエストが必要でした。

例えば、ユーザー情報と投稿一覧、そしてコメント数を表示したい場合を考えてみましょう。

typescript// REST APIの場合:複数リクエストが必要
const fetchUserData = async (userId: string) => {
  const user = await fetch(`/api/users/${userId}`);
  const posts = await fetch(`/api/users/${userId}/posts`);
  const comments = await fetch(`/api/posts/${postId}/comments`);
  
  return { user, posts, comments };
};

この方法では、ネットワークリクエストが多くなり、パフォーマンスの問題が発生します。

GraphQLは、このような課題を解決するために2012年にFacebookで開発されました。GraphQLでは、必要なデータを1回のリクエストで取得できるため、効率的なデータ取得が可能です。

graphql# GraphQLの場合:1回のリクエストで完結
query GetUserData($userId: ID!) {
  user(id: $userId) {
    name
    email
    posts {
      title
      content
      commentsCount
    }
  }
}

React開発におけるデータ取得の現状

React開発では、状態管理とAPI連携が開発者の大きな悩みの種となっています。

多くの開発者が以下のような課題を抱えているのではないでしょうか。

typescript// 従来のReact開発でよく見るパターン
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [data, setData] = useState(null);

useEffect(() => {
  const fetchData = async () => {
    setLoading(true);
    try {
      const response = await fetch('/api/data');
      const result = await response.json();
      setData(result);
    } catch (err) {
      setError(err);
    } finally {
      setLoading(false);
    }
  };
  
  fetchData();
}, []);

このコードを見ると、データ取得のたびに同じようなボイラープレートコードを書く必要があることがわかります。また、キャッシュやエラーハンドリング、リアルタイム更新なども自分で実装する必要があります。

課題

REST APIでの複数リクエスト問題

REST APIの最大の問題は、関連するデータを取得するために複数のリクエストが必要になることです。

以下の図で、従来のREST APIでのデータ取得フローを確認してみましょう。

mermaidsequenceDiagram
    participant C as React Component
    participant A as REST API
    participant D as Database
    
    C->>A: GET /api/users/1
    A->>D: SELECT * FROM users WHERE id = 1
    D-->>A: User data
    A-->>C: User response
    
    C->>A: GET /api/users/1/posts
    A->>D: SELECT * FROM posts WHERE user_id = 1
    D-->>A: Posts data
    A-->>C: Posts response
    
    C->>A: GET /api/posts/1/comments
    A->>D: SELECT * FROM comments WHERE post_id = 1
    D-->>A: Comments data
    A-->>C: Comments response

この図からもわかるように、3つの関連するデータを取得するために3回のリクエストが必要です。

データの過剰取得・不足取得

REST APIでは、エンドポイントが返すデータ構造が固定されているため、以下のような問題が発生します。

過剰取得の例

typescript// ユーザー名だけ欲しいのに、すべての情報が返ってくる
interface User {
  id: string;
  name: string;
  email: string;
  bio: string;
  avatar: string;
  createdAt: string;
  updatedAt: string;
  preferences: object;
  // ... 他にも多数のフィールド
}

const response = await fetch('/api/users/1');
const user: User = await response.json();

// 実際に使うのは name だけ
console.log(user.name);

不足取得の例

typescript// 投稿一覧は取得できるが、作成者情報は別途取得が必要
interface Post {
  id: string;
  title: string;
  content: string;
  userId: string; // IDだけで、実際のユーザー情報は含まれない
}

const posts: Post[] = await fetch('/api/posts').then(r => r.json());

// 各投稿の作成者情報を取得するために追加のリクエストが必要
const postsWithUsers = await Promise.all(
  posts.map(async (post) => {
    const user = await fetch(`/api/users/${post.userId}`).then(r => r.json());
    return { ...post, user };
  })
);

型安全性の欠如

TypeScriptを使用していても、API の response が実際に期待する型と一致するかどうかは実行時までわかりません。

typescriptinterface User {
  id: string;
  name: string;
  email: string;
}

// 型注釈はあるが、実際のレスポンスが型と一致する保証はない
const user: User = await fetch('/api/users/1').then(r => r.json());

// 実行時エラーの可能性
console.log(user.name.toUpperCase()); // name が null の場合エラー

これらの課題により、開発効率の低下やランタイムエラーのリスクが増大してしまいます。

解決策

Apollo Clientとは

Apollo Clientは、GraphQLを使用してデータを管理するための包括的なJavaScriptライブラリです。

Reactアプリケーションにおいて、以下の機能を提供してくれます。

機能説明メリット
データ取得GraphQLクエリの実行1回のリクエストで必要なデータをすべて取得
キャッシュインメモリキャッシュ不要なリクエストを削減し、パフォーマンス向上
状態管理ローカル状態とリモート状態の統合複雑な状態管理ロジックが不要
リアルタイムSubscriptionサポートリアルタイムデータ更新
エラーハンドリング一元的なエラー管理一貫したエラー処理

以下の図で、Apollo Clientがアプリケーションでどのような役割を果たすかを確認してみましょう。

mermaidflowchart LR
    RC[React Components] -->|GraphQL Query| AC[Apollo Client]
    AC -->|HTTP Request| GS[GraphQL Server]
    GS -->|Response| AC
    AC -->|Cached Data| RC
    
    AC -->|Cache| Cache[(In-Memory Cache)]
    AC -->|Local State| LS[Local State Management]
    
    subgraph "Apollo Client Core"
        Cache
        LS
        EH[Error Handling]
        RT[Real-time Updates]
    end

Apollo Clientを使用することで、複雑なデータ管理ロジックを自分で書く必要がなくなります。

GraphQLクエリの威力

GraphQLクエリを使用することで、必要なデータだけを効率的に取得できます。

graphql# 必要なフィールドだけを指定
query GetUserPosts($userId: ID!) {
  user(id: $userId) {
    name
    avatar
    posts {
      title
      content
      createdAt
      comments {
        count
      }
    }
  }
}

このクエリの特徴は以下の通りです。

  • 選択的なデータ取得: 必要なフィールドだけを指定
  • ネストした関係: userからpostsまで一度に取得
  • 型安全性: スキーマに基づく厳密な型チェック
  • 単一エンドポイント: 1つのリクエストですべて完結

React Hooksとの連携

Apollo Clientは、React Hooksと自然に統合されており、直感的なAPIを提供します。

useQueryの基本的な使用方法

typescriptimport { useQuery, gql } from '@apollo/client';

const GET_USERS = gql`
  query GetUsers {
    users {
      id
      name
      email
    }
  }
`;

function UsersList() {
  const { loading, error, data } = useQuery(GET_USERS);

  if (loading) return <div>読み込み中...</div>;
  if (error) return <div>エラー: {error.message}</div>;

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

このコードを見ると、従来のfetch APIと比較して以下の点で優れていることがわかります。

  • ボイラープレートの削減: loading、error、dataが自動で管理される
  • 自動リフェッチ: コンポーネントがマウントされると自動でデータを取得
  • キャッシュ: 同じクエリは自動的にキャッシュされる
  • 型安全性: TypeScriptとの完全な統合

具体例

セットアップから実装まで

まずは、React プロジェクトにApollo Clientをセットアップしましょう。

パッケージのインストール

bashyarn add @apollo/client graphql

Apollo Clientの初期設定

typescript// src/apollo/client.ts
import { ApolloClient, InMemoryCache, createHttpLink } from '@apollo/client';

// GraphQLサーバーへの接続を設定
const httpLink = createHttpLink({
  uri: 'http://localhost:4000/graphql', // GraphQLサーバーのURL
});

// Apollo Clientのインスタンスを作成
const client = new ApolloClient({
  link: httpLink,
  cache: new InMemoryCache(), // インメモリキャッシュを設定
  defaultOptions: {
    watchQuery: {
      errorPolicy: 'all', // エラーが発生してもキャッシュデータを返す
    },
  },
});

export default client;

Providerの設定

typescript// src/App.tsx
import React from 'react';
import { ApolloProvider } from '@apollo/client';
import client from './apollo/client';
import UsersList from './components/UsersList';

function App() {
  return (
    <ApolloProvider client={client}>
      <div className="App">
        <h1>Apollo Client サンプル</h1>
        <UsersList />
      </div>
    </ApolloProvider>
  );
}

export default App;

ApolloProviderによって、アプリケーション全体でApollo Clientを使用できるようになります。

useQueryで始める簡単データ取得

実際にデータを取得するコンポーネントを作成してみましょう。

GraphQLクエリの定義

typescript// src/graphql/queries.ts
import { gql } from '@apollo/client';

export const GET_USERS = gql`
  query GetUsers {
    users {
      id
      name
      email
      avatar
      postsCount
    }
  }
`;

export const GET_USER_DETAIL = gql`
  query GetUserDetail($userId: ID!) {
    user(id: $userId) {
      id
      name
      email
      bio
      avatar
      posts {
        id
        title
        content
        createdAt
        commentsCount
      }
    }
  }
`;

TypeScriptの型定義

typescript// src/types/user.ts
export interface User {
  id: string;
  name: string;
  email: string;
  avatar?: string;
  postsCount: number;
}

export interface Post {
  id: string;
  title: string;
  content: string;
  createdAt: string;
  commentsCount: number;
}

export interface UserDetail extends User {
  bio?: string;
  posts: Post[];
}

ユーザー一覧コンポーネント

typescript// src/components/UsersList.tsx
import React from 'react';
import { useQuery } from '@apollo/client';
import { GET_USERS } from '../graphql/queries';
import { User } from '../types/user';

interface UsersData {
  users: User[];
}

function UsersList() {
  const { loading, error, data, refetch } = useQuery<UsersData>(GET_USERS, {
    pollInterval: 30000, // 30秒ごとに自動更新
  });

  if (loading) return <div className="loading">データを読み込み中...</div>;
  
  if (error) {
    return (
      <div className="error">
        <p>エラーが発生しました: {error.message}</p>
        <button onClick={() => refetch()}>
          再試行
        </button>
      </div>
    );
  }

  return (
    <div className="users-list">
      <h2>ユーザー一覧</h2>
      <button onClick={() => refetch()}>
        最新データを取得
      </button>
      
      <div className="users-grid">
        {data?.users.map(user => (
          <div key={user.id} className="user-card">
            <img src={user.avatar || '/default-avatar.png'} alt={user.name} />
            <h3>{user.name}</h3>
            <p>{user.email}</p>
            <span>投稿数: {user.postsCount}</span>
          </div>
        ))}
      </div>
    </div>
  );
}

export default UsersList;

このコンポーネントでは、以下の機能が自動的に提供されます。

  • 自動ローディング状態: loading フラグでUIを制御
  • エラーハンドリング: error オブジェクトで一元管理
  • 自動更新: pollInterval で定期的なデータ更新
  • 手動リフェッチ: refetch 関数でオンデマンド更新

useMutationでデータ更新

次に、データの作成・更新・削除を行うミューテーションを実装してみましょう。

ミューテーションの定義

typescript// src/graphql/mutations.ts
import { gql } from '@apollo/client';

export const CREATE_USER = gql`
  mutation CreateUser($input: CreateUserInput!) {
    createUser(input: $input) {
      id
      name
      email
      avatar
    }
  }
`;

export const UPDATE_USER = gql`
  mutation UpdateUser($id: ID!, $input: UpdateUserInput!) {
    updateUser(id: $id, input: $input) {
      id
      name
      email
      bio
      avatar
    }
  }
`;

export const DELETE_USER = gql`
  mutation DeleteUser($id: ID!) {
    deleteUser(id: $id) {
      success
      message
    }
  }
`;

ユーザー作成フォーム

typescript// src/components/CreateUserForm.tsx
import React, { useState } from 'react';
import { useMutation } from '@apollo/client';
import { CREATE_USER, GET_USERS } from '../graphql/queries';

interface CreateUserInput {
  name: string;
  email: string;
  bio?: string;
}

function CreateUserForm() {
  const [formData, setFormData] = useState<CreateUserInput>({
    name: '',
    email: '',
    bio: '',
  });

  const [createUser, { loading, error }] = useMutation(CREATE_USER, {
    // ミューテーション完了後にユーザー一覧を再取得
    refetchQueries: [{ query: GET_USERS }],
    onCompleted: (data) => {
      console.log('ユーザーが作成されました:', data.createUser);
      // フォームをリセット
      setFormData({ name: '', email: '', bio: '' });
    },
    onError: (error) => {
      console.error('ユーザー作成エラー:', error);
    },
  });

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    
    try {
      await createUser({
        variables: {
          input: formData,
        },
      });
    } catch (err) {
      // エラーは onError で処理される
    }
  };

  const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
    const { name, value } = e.target;
    setFormData(prev => ({
      ...prev,
      [name]: value,
    }));
  };

  return (
    <form onSubmit={handleSubmit} className="create-user-form">
      <h2>新しいユーザーを作成</h2>
      
      {error && (
        <div className="error-message">
          エラー: {error.message}
        </div>
      )}
      
      <div className="form-group">
        <label htmlFor="name">名前</label>
        <input
          id="name"
          name="name"
          type="text"
          value={formData.name}
          onChange={handleChange}
          required
        />
      </div>
      
      <div className="form-group">
        <label htmlFor="email">メールアドレス</label>
        <input
          id="email"
          name="email"
          type="email"
          value={formData.email}
          onChange={handleChange}
          required
        />
      </div>
      
      <div className="form-group">
        <label htmlFor="bio">自己紹介</label>
        <textarea
          id="bio"
          name="bio"
          value={formData.bio}
          onChange={handleChange}
          rows={3}
        />
      </div>
      
      <button 
        type="submit" 
        disabled={loading}
        className="submit-button"
      >
        {loading ? '作成中...' : 'ユーザーを作成'}
      </button>
    </form>
  );
}

export default CreateUserForm;

キャッシュ更新の最適化

より効率的なキャッシュ更新のため、update関数を使用することもできます。

typescript// より効率的なキャッシュ更新
const [createUser] = useMutation(CREATE_USER, {
  update: (cache, { data }) => {
    // 既存のキャッシュデータを読み込み
    const existingUsers = cache.readQuery<{ users: User[] }>({
      query: GET_USERS,
    });

    if (existingUsers && data?.createUser) {
      // 新しいユーザーを既存のリストに追加
      cache.writeQuery({
        query: GET_USERS,
        data: {
          users: [...existingUsers.users, data.createUser],
        },
      });
    }
  },
});

以下の図で、ミューテーション実行時のキャッシュ更新フローを確認してみましょう。

mermaidflowchart TD
    A[useMutation実行] --> B[GraphQLサーバーにリクエスト]
    B --> C[レスポンス受信]
    C --> D{update関数は定義済み?}
    D -->|Yes| E[update関数でキャッシュを手動更新]
    D -->|No| F[refetchQueriesで関連クエリを再実行]
    E --> G[UIが自動更新]
    F --> G
    G --> H[完了コールバック実行]

まとめ

Apollo Clientを使用することで、React開発における多くの課題を解決できることがわかりました。

主なメリットをまとめると以下のようになります。

従来の方法Apollo Client
複数のREST APIリクエスト1つのGraphQLクエリで完結
手動でのローディング状態管理useQueryで自動管理
複雑なキャッシュロジック自動キャッシュとインテリジェントな更新
型安全性の不足GraphQLスキーマベースの完全な型サポート
散在するエラーハンドリング一元的なエラー管理

開発効率の向上という観点では、以下の点で大きな改善が期待できます。

  • ボイラープレートコードの大幅削減
  • 自動的なキャッシュ管理によるパフォーマンス最適化
  • リアルタイム機能の簡単な実装
  • 一貫性のあるデータ管理パターン

学習コストについてですが、GraphQLの基本概念を理解する必要はありますが、Apollo Clientが提供するReact Hooksは直感的で、既存のReact知識があればすぐに使い始めることができます。

今回ご紹介した内容は、Apollo Clientの基本的な機能のほんの一部です。実際のプロジェクトでは、Subscriptionによるリアルタイム更新、楽観的UI更新、オフライン対応など、さらに高度な機能も活用できます。

ぜひ一度、実際のプロジェクトでApollo Clientを試してみて、その開発体験の向上を実感してみてください。

関連リンク