Skip to content
⚠️ This article was written in 2019. Some content may be outdated.

GraphQLクライアント Apollo Client 実践

複雑なビジネスシーンにおいて、REST APIはover-fetchingやunder-fetchingの問題に頻繁に直面する。GraphQLを使えば、フロントエンドが必要なデータ構造を正確に宣言でき、バックエンドは必要に応じてデータを返す。Apollo Clientは現在最も成熟したGraphQLクライアントであり、キャッシュ、状態管理、楽観的更新などの機能をすぐに使える形で提供している。本記事では実際のブログ管理プロジェクトを例に、Apollo Clientの完全な使い方を解説する。

Apollo Clientのインストールと初期化 ​

bash
npm install apollo-client apollo-cache-inmemory apollo-link-http graphql graphql-tag
npm install react-apollo

Apollo Clientインスタンスの作成 ​

js
// src/apollo/client.js
import ApolloClient from 'apollo-client';
import { InMemoryCache } from 'apollo-cache-inmemory';
import { HttpLink } from 'apollo-link-http';

const httpLink = new HttpLink({
  uri: 'https://your-api.com/graphql',
  headers: {
    // リクエストヘッダーにtokenを含める
    authorization: `Bearer ${localStorage.getItem('token') || ''}`,
  },
});

const cache = new InMemoryCache();

const client = new ApolloClient({
  link: httpLink,
  cache,
  // 開発環境でDevToolsサポートを有効化
  connectToDevTools: true,
});

export default client;

Reactアプリケーションへの導入 ​

jsx
// src/App.jsx
import React from 'react';
import { ApolloProvider } from 'react-apollo';
import client from './apollo/client';
import PostList from './components/PostList';

function App() {
  return (
    <ApolloProvider client={client}>
      <div className="app">
        <PostList />
      </div>
    </ApolloProvider>
  );
}

export default App;

Queryコンポーネントでデータ取得 ​

jsx
{% raw %}
import React from 'react';
import { Query } from 'react-apollo';
import gql from 'graphql-tag';

// GraphQLクエリの定義
const GET_POSTS = gql`
  query GetPosts($page: Int!, $pageSize: Int!) {
    posts(page: $page, pageSize: $pageSize) {
      id
      title
      excerpt
      author {
        name
        avatar
      }
      createdAt
      tags
    }
    totalPosts
  }
`;

function PostList() {
  return (
    <Query
      query={GET_POSTS}
      variables={{ page: 1, pageSize: 10 }}
    >
      {({ loading, error, data, fetchMore }) => {
        if (loading) return <div className="loading">読み込み中...</div>;
        if (error) return <div className="error">エラーが発生しました: {error.message}</div>;

        return (
          <div>
            <h2>記事一覧(全 {data.totalPosts} 件)</h2>
            {data.posts.map(post => (
              <article key={post.id} className="post-card">
                <h3>{post.title}</h3>
                <p>{post.excerpt}</p>
                <div className="meta">
                  <img src={post.author.avatar} alt={post.author.name} />
                  <span>{post.author.name}</span>
                  <span>{new Date(post.createdAt).toLocaleDateString()}</span>
                </div>
                <div className="tags">
                  {post.tags.map(tag => (
                    <span key={tag} className="tag">{tag}</span>
                  ))}
                </div>
              </article>
            ))}

            <button onClick={() => loadMore(fetchMore, data)}>
              もっと読み込む
            </button>
          </div>
        );
      }}
    </Query>
  );
}

function loadMore(fetchMore, data) {
  fetchMore({
    variables: {
      page: Math.ceil(data.posts.length / 10) + 1,
    },
    updateQuery: (prev, { fetchMoreResult }) => {
      if (!fetchMoreResult) return prev;
      return {
        ...prev,
        posts: [...prev.posts, ...fetchMoreResult.posts],
        totalPosts: fetchMoreResult.totalPosts,
      };
    },
  });
}

export default PostList;
{% endraw %}

Mutationでデータを変更する ​

jsx
{% raw %}
import React, { useState } from 'react';
import { Mutation } from 'react-apollo';
import gql from 'graphql-tag';
import { GET_POSTS } from './PostList';

const CREATE_POST = gql`
  mutation CreatePost($input: CreatePostInput!) {
    createPost(input: $input) {
      id
      title
      excerpt
      author {
        name
        avatar
      }
      createdAt
      tags
    }
  }
`;

function CreatePost() {
  const [title, setTitle] = useState('');
  const [content, setContent] = useState('');
  const [tags, setTags] = useState('');

  return (
    <Mutation
      mutation={CREATE_POST}
      // 楽観的更新:サーバー応答を待たずにUIを即時更新
      optimisticResponse={{
        createPost: {
          __typename: 'Post',
          id: 'temp-id',
          title,
          excerpt: content.slice(0, 100),
          author: {
            __typename: 'Author',
            name: '現在のユーザー',
            avatar: '/default-avatar.png',
          },
          createdAt: new Date().toISOString(),
          tags: tags.split(',').map(t => t.trim()),
        },
      }}
      // キャッシュ内の記事一覧を更新
      update={(cache, { data: { createPost } }) => {
        const data = cache.readQuery({
          query: GET_POSTS,
          variables: { page: 1, pageSize: 10 },
        });

        cache.writeQuery({
          query: GET_POSTS,
          variables: { page: 1, pageSize: 10 },
          data: {
            ...data,
            posts: [createPost, ...data.posts],
            totalPosts: data.totalPosts + 1,
          },
        });
      }}
    >
      {(createPost, { loading, error }) => (
        <form
          onSubmit={async (e) => {
            e.preventDefault();
            await createPost({
              variables: {
                input: { title, content, tags: tags.split(',').map(t => t.trim()) },
              },
            });
            setTitle('');
            setContent('');
            setTags('');
          }}
        >
          <input
            value={title}
            onChange={e => setTitle(e.target.value)}
            placeholder="記事タイトル"
            required
          />
          <textarea
            value={content}
            onChange={e => setContent(e.target.value)}
            placeholder="記事内容"
            required
          />
          <input
            value={tags}
            onChange={e => setTags(e.target.value)}
            placeholder="タグ(カンマ区切り)"
          />
          <button type="submit" disabled={loading}>
            {loading ? '公開中...' : '記事を公開'}
          </button>
          {error && <p className="error">{error.message}</p>}
        </form>
      )}
    </Mutation>
  );
}

export default CreatePost;
{% endraw %}

キャッシュ管理 ​

Apollo Clientには強力なNormalized Cacheが組み込まれており、__typename + id をキャッシュキーとして自動的に使用する。

dataIdFromObjectの設定 ​

js
import ApolloClient from 'apollo-client';
import { InMemoryCache } from 'apollo-cache-inmemory';

const cache = new InMemoryCache({
  dataIdFromObject: object => {
    switch (object.__typename) {
      case 'Post':
        return `Post:${object.id}`;
      case 'Comment':
        return `Comment:${object.id}`;
      default:
        return defaultDataIdFromObject(object);
    }
  },
});

キャッシュの手動操作 ​

js
import { useApolloClient } from 'react-apollo';

function useDeletePost() {
  const client = useApolloClient();

  const deletePost = (postId) => {
    // キャッシュから直接削除
    client.cache.evict(`Post:${postId}`);
    client.cache.gc();
  };

  return deletePost;
}

refetchQueriesで関連クエリを更新 ​

jsx
<Mutation
  mutation={DELETE_POST}
  refetchQueries={[
    { query: GET_POSTS, variables: { page: 1, pageSize: 10 } },
  ]}
>
  {(deletePost) => (
    <button onClick={() => deletePost({ variables: { id: post.id } })}>
      削除
    </button>
  )}
</Mutation>

Hooksスタイルの使い方(react-apollo 2.1+) ​

react-apollo 2.1以降では、Hooks APIの使用が推奨されている:

jsx
{% raw %}
import { useQuery, useMutation } from 'react-apollo';
import gql from 'graphql-tag';

const GET_POST = gql`
  query GetPost($id: ID!) {
    post(id: $id) {
      id
      title
      content
      author { name }
      comments {
        id
        content
        author { name }
        createdAt
      }
    }
  }
`;

const ADD_COMMENT = gql`
  mutation AddComment($postId: ID!, $content: String!) {
    addComment(postId: $postId, content: $content) {
      id
      content
      author { name }
      createdAt
    }
  }
`;

function PostDetail({ postId }) {
  const { loading, error, data } = useQuery(GET_POST, {
    variables: { id: postId },
  });

  const [addComment, { loading: submitting }] = useMutation(ADD_COMMENT, {
    // コメント投稿後に記事データを再取得
    refetchQueries: [{ query: GET_POST, variables: { id: postId } }],
  });

  const [commentText, setCommentText] = useState('');

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

  const { post } = data;

  return (
    <div>
      <h1>{post.title}</h1>
      <p>著者: {post.author.name}</p>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />

      <h3>コメント ({post.comments.length})</h3>
      {post.comments.map(comment => (
        <div key={comment.id} className="comment">
          <strong>{comment.author.name}</strong>
          <p>{comment.content}</p>
          <time>{new Date(comment.createdAt).toLocaleString()}</time>
        </div>
      ))}

      <form onSubmit={async (e) => {
        e.preventDefault();
        await addComment({
          variables: { postId, content: commentText },
        });
        setCommentText('');
      }}>
        <textarea
          value={commentText}
          onChange={e => setCommentText(e.target.value)}
          placeholder="コメントを入力..."
        />
        <button type="submit" disabled={submitting || !commentText.trim()}>
          {submitting ? '送信中...' : 'コメントを投稿'}
        </button>
      </form>
    </div>
  );
}
{% endraw %}

認証トークンの期限切れ処理 ​

js
// src/apollo/authLink.js
import { ApolloLink } from 'apollo-link';

const authLink = new ApolloLink((operation, forward) => {
  const token = localStorage.getItem('token');

  operation.setContext({
    headers: {
      authorization: token ? `Bearer ${token}` : '',
    },
  });

  return forward(operation).map(response => {
    // レスポンスヘッダーのtokenリフレッシュを確認
    const context = operation.getContext();
    const newToken = context.response?.headers?.get('x-refreshed-token');

    if (newToken) {
      localStorage.setItem('token', newToken);
    }

    return response;
  });
});

export default authLink;
js
// client設定の更新
import { from } from 'apollo-link';
import authLink from './authLink';

const client = new ApolloClient({
  link: from([authLink, httpLink]),
  cache,
});

まとめ ​

  • Apollo ClientはReactエコシステムで最も成熟したGraphQLクライアントであり、データ取得・キャッシュ・状態管理の完全なソリューションを提供する
  • Queryコンポーネント(または useQuery フック)で宣言的にデータを取得し、ローディングとエラー状態を自動処理する
  • Mutation と update・optimisticResponse を組み合わせることで楽観的更新を実現し、ユーザー体験を向上できる
  • ApolloのNormalized Cacheは __typename + id でキャッシュを自動管理し、データ更新時の一貫性を保つ
  • HooksスタイルのAPI(useQuery・useMutation)の使用を推奨する。コードがより簡潔になる
  • 認証tokenとtoken期限切れ時のリフレッシュロジックを設定し、APIリクエストの安全性を確保すること
  • refetchQueries は最もシンプルなデータ同期方法だが、パフォーマンスが重要な場面では手動キャッシュ操作を優先すべきである

MIT Licensed