T-CREATOR

Apollo のキャッシュ思想を俯瞰する:正規化・型ポリシー・部分データの取り扱い

Apollo のキャッシュ思想を俯瞰する:正規化・型ポリシー・部分データの取り扱い

GraphQL クライアントライブラリである Apollo Client は、その強力なキャッシュ機構によって開発者から高い評価を得ています。単にデータを保存するだけでなく、正規化によってデータの一貫性を保ち、型ポリシーで柔軟なデータ管理を実現し、部分データを効率的に扱うことができるのです。

本記事では、Apollo Client のキャッシュ思想の核心部分である「正規化」「型ポリシー」「部分データの取り扱い」について、初心者の方にもわかりやすく解説していきますね。これらの仕組みを理解することで、より効率的で保守性の高い GraphQL アプリケーションを構築できるようになるでしょう。

背景

GraphQL とキャッシュの関係性

GraphQL は REST API とは異なり、クライアントが必要なデータを自由に指定できる柔軟性を持っています。しかし、この柔軟性がキャッシュの複雑さを生み出します。

REST API では URL がキャッシュのキーとなりますが、GraphQL では同じエンドポイントに対して異なるクエリを送信するため、単純な URL ベースのキャッシュ戦略は使えません。この課題を解決するために、Apollo Client は独自のキャッシュ思想を確立しました。

以下の図は、GraphQL クエリがどのようにキャッシュと連携するかを示しています。

mermaidflowchart TB
  client["クライアント<br/>アプリケーション"] -->|GraphQLクエリ| apollo["Apollo Client"]
  apollo -->|キャッシュ確認| cache["InMemoryCache"]
  cache -->|キャッシュヒット| apollo
  cache -->|キャッシュミス| server["GraphQLサーバー"]
  server -->|レスポンス| cache
  cache -->|正規化して保存| store["正規化<br/>ストレージ"]
  apollo -->|結果を返却| client

この図から、Apollo Client がキャッシュを第一にチェックし、必要な場合のみサーバーにリクエストを送る仕組みがわかりますね。

InMemoryCache の誕生

Apollo Client の中核となる InMemoryCache は、メモリ上にデータを保持する高速なキャッシュストレージです。単なるキーバリューストアではなく、以下の特徴を持っています。

#特徴説明
1正規化データを ID ベースで一元管理
2型安全性TypeScript との親和性が高い
3リアクティブデータ変更時に自動的に UI を更新
4柔軟性型ポリシーによるカスタマイズが可能

これらの特徴により、大規模なアプリケーションでも効率的にデータを管理できるようになりました。

課題

データの重複と一貫性の問題

GraphQL では、異なるクエリで同じデータを取得する場合があります。例えば、ユーザー一覧とユーザー詳細で同じユーザー情報を取得する際、キャッシュに重複してデータが保存されてしまう可能性があるのです。

以下のような状況を考えてみましょう。

typescript// ユーザー一覧を取得するクエリ
const GET_USERS = gql`
  query GetUsers {
    users {
      id
      name
      email
    }
  }
`;
typescript// 特定のユーザー詳細を取得するクエリ
const GET_USER_DETAIL = gql`
  query GetUserDetail($id: ID!) {
    user(id: $id) {
      id
      name
      email
      profile
      createdAt
    }
  }
`;

この 2 つのクエリは、同じユーザーの idnameemail を取得しています。もしキャッシュが単純にクエリごとにデータを保存すると、以下の問題が発生します。

#問題点影響
1データの重複メモリの無駄遣い
2更新の不整合片方だけ古いデータが残る
3予測困難な挙動デバッグが難しくなる

以下の図は、正規化されていないキャッシュでの問題を示しています。

mermaidflowchart LR
  query1["getUsersクエリ"] -->|結果を保存| cache1["キャッシュ領域1<br/>{`id:1, name:太郎`}"]
  query2["getUserDetailクエリ"] -->|結果を保存| cache2["キャッシュ領域2<br/>{`id:1, name:太郎`}"]
  update["nameを花子に更新"] -->|更新| cache1
  cache1 -.->|不整合発生| inconsistent["領域1:花子<br/>領域2:太郎"]

同じユーザーのデータが別々の場所に保存されているため、片方を更新してももう片方には反映されず、データの不整合が発生してしまうのです。

部分的なデータの扱い

GraphQL の強みは、必要なフィールドだけを取得できることですが、これがキャッシュにとっては難題となります。

あるクエリでは nameemail だけを取得し、別のクエリでは nameemailprofile を取得した場合、キャッシュはこれらをどう扱うべきでしょうか。

typescript// 最小限のフィールドを取得
const MINIMAL_USER = gql`
  query MinimalUser($id: ID!) {
    user(id: $id) {
      id
      name
    }
  }
`;
typescript// より多くのフィールドを取得
const FULL_USER = gql`
  query FullUser($id: ID!) {
    user(id: $id) {
      id
      name
      email
      profile
      avatar
    }
  }
`;

この場合、以下のような課題が生じます。

  • キャッシュに部分的なデータしかない時、クエリを満たせるか判断できるか
  • 新しいデータで既存のキャッシュをどう更新するか
  • 欠けているフィールドをどう表現するか

型ごとの異なる要件

アプリケーションでは、データの型によって異なるキャッシュ戦略が必要になることがあります。

typescript// ユーザー型:頻繁に更新される
type User = {
  id: string;
  name: string;
  lastLogin: Date;
};
typescript// 設定型:ほとんど変更されない
type Settings = {
  theme: string;
  language: string;
};

型によって、以下のような要件が異なるのです。

#データ型キャッシュ要件
1ユーザー情報ID で識別、頻繁に更新
2記事一覧ページネーション対応
3設定情報シングルトン、永続化
4一時データ短期間のみ保持

これらの多様な要件に対応するため、柔軟なキャッシュ設定が必要になりました。

解決策

正規化によるデータの一元管理

Apollo Client の最も重要な機能が「正規化」です。正規化とは、各オブジェクトを一意な ID で識別し、キャッシュ内で一箇所にのみ保存する仕組みですね。

正規化の基本概念

InMemoryCache は、デフォルトで以下のルールでオブジェクトを正規化します。

typescript// Apollo Client の初期化
import {
  ApolloClient,
  InMemoryCache,
} from '@apollo/client';
typescript// InMemoryCache のインスタンス作成
const cache = new InMemoryCache({
  // 型ポリシーの設定(後述)
});
typescript// Apollo Client の設定
const client = new ApolloClient({
  uri: 'https://api.example.com/graphql',
  cache: cache,
});

Apollo Client は、取得したオブジェクトから __typenameid(または _id)を組み合わせてキャッシュキーを生成します。

以下の図は、正規化によってデータがどのように保存されるかを示しています。

mermaidflowchart TB
  query["GraphQLクエリ結果"] -->|正規化処理| normalizer["正規化エンジン"]
  normalizer -->|型名+IDで識別| cache["正規化キャッシュ"]

  subgraph cache["正規化キャッシュストア"]
    user1["User:1<br/>{`name:太郎,email:...`}"]
    user2["User:2<br/>{`name:花子,email:...`}"]
    post1["Post:101<br/>{`title:記事1,author:User:1`}"]
  end

  post1 -.->|参照| user1

この図から、各オブジェクトが一箇所に保存され、他のオブジェクトから参照される構造がわかります。

正規化の実装例

実際のコードで正規化がどのように機能するか見てみましょう。

typescript// GraphQL クエリの定義
const GET_POST_WITH_AUTHOR = gql`
  query GetPostWithAuthor($postId: ID!) {
    post(id: $postId) {
      id
      title
      content
      author {
        id
        name
        email
      }
    }
  }
`;

このクエリを実行すると、サーバーから以下のようなレスポンスが返ってきます。

json{
  "data": {
    "post": {
      "__typename": "Post",
      "id": "101",
      "title": "Apollo Client 入門",
      "content": "...",
      "author": {
        "__typename": "User",
        "id": "1",
        "name": "太郎",
        "email": "taro@example.com"
      }
    }
  }
}

Apollo Client は、このレスポンスを以下のように正規化してキャッシュに保存します。

typescript// 内部的なキャッシュの状態(イメージ)
{
  "Post:101": {
    "__typename": "Post",
    "id": "101",
    "title": "Apollo Client 入門",
    "content": "...",
    "author": {
      "__ref": "User:1"  // 参照として保存
    }
  },
  "User:1": {
    "__typename": "User",
    "id": "1",
    "name": "太郎",
    "email": "taro@example.com"
  }
}

author フィールドは、実際のデータではなく User:1 への参照として保存されることに注目してください。

正規化のメリット

正規化によって、以下のメリットが得られます。

typescript// 別のクエリでユーザー情報を更新
const UPDATE_USER_NAME = gql`
  mutation UpdateUserName($id: ID!, $name: String!) {
    updateUser(id: $id, name: $name) {
      id
      name
    }
  }
`;
typescript// ミューテーション実行後、キャッシュが自動更新される
const [updateUserName] = useMutation(UPDATE_USER_NAME);

// ユーザー名を更新
await updateUserName({
  variables: { id: '1', name: '花子' },
});
typescript// この更新により、User:1 を参照しているすべての箇所が自動的に更新される
// - ユーザー一覧
// - 投稿の著者情報
// - コメントの投稿者情報
// など、User:1 を参照しているすべてのコンポーネントが再レンダリングされる

一箇所の更新が、そのデータを参照しているすべての場所に自動的に反映されるため、データの一貫性が保たれるのです。

型ポリシーによる柔軟なカスタマイズ

型ポリシーは、型ごとにキャッシュの振る舞いをカスタマイズできる強力な機能です。

keyFields のカスタマイズ

デフォルトの id_id 以外のフィールドを識別子として使いたい場合、keyFields を設定します。

typescript// 複合キーを使用する型ポリシー
const cache = new InMemoryCache({
  typePolicies: {
    // Book 型のキャッシュキーを isbn で識別
    Book: {
      keyFields: ['isbn'],
    },
  },
});
typescript// 複数のフィールドを組み合わせた複合キー
const cache = new InMemoryCache({
  typePolicies: {
    // UserSession 型を userId と sessionId の組み合わせで識別
    UserSession: {
      keyFields: ['userId', 'sessionId'],
    },
  },
});

これにより、Book:978-4-12345-678-9 のようなキャッシュキーが生成されます。

フィールドポリシーによる計算フィールド

フィールドポリシーを使用すると、サーバーから取得していないフィールドをクライアント側で計算できます。

typescript// 計算フィールドの定義
const cache = new InMemoryCache({
  typePolicies: {
    Product: {
      fields: {
        // price と tax から totalPrice を計算
        totalPrice: {
          read(_, { readField }) {
            const price = readField('price');
            const tax = readField('tax');
            return price + tax;
          },
        },
      },
    },
  },
});
typescript// クエリでは totalPrice を取得していないが、
// キャッシュから読み取る際に自動計算される
const PRODUCT_QUERY = gql`
  query GetProduct($id: ID!) {
    product(id: $id) {
      id
      price
      tax
      # totalPrice は含まれていない
    }
  }
`;
typescript// コンポーネント内で使用
function ProductDisplay({ productId }) {
  const { data } = useQuery(PRODUCT_QUERY, {
    variables: { id: productId },
  });

  // totalPrice が自動的に計算される
  return <div>合計: {data.product.totalPrice}円</div>;
}

サーバーから取得していないデータでも、既存のフィールドから自動計算できるため、クエリを簡潔に保てますね。

マージ戦略のカスタマイズ

配列フィールドの更新方法も、型ポリシーでカスタマイズできます。

typescript// ページネーション対応のマージ戦略
const cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        posts: {
          // 既存の配列と新しい配列をマージ
          keyArgs: ['category'], // category ごとに別のキャッシュ
          merge(existing = [], incoming, { args }) {
            // offset ベースのページネーション
            const merged = existing.slice(0);
            const offset = args?.offset || 0;

            for (let i = 0; i < incoming.length; i++) {
              merged[offset + i] = incoming[i];
            }

            return merged;
          },
        },
      },
    },
  },
});
typescript// ページ追加時の動作
// 1ページ目取得: posts(offset: 0, limit: 10)
// → キャッシュ: [post1, post2, ..., post10]

// 2ページ目取得: posts(offset: 10, limit: 10)
// → キャッシュ: [post1, ..., post10, post11, ..., post20]

この設定により、ページネーション時に新しいデータが既存のデータに正しく追加されます。

以下の図は、型ポリシーによるカスタマイズの全体像を示しています。

mermaidflowchart TB
  typepolicy["型ポリシー設定"]

  typepolicy --> keyfields["keyFields<br/>識別子のカスタマイズ"]
  typepolicy --> fieldpolicy["フィールドポリシー<br/>計算・マージ戦略"]
  typepolicy --> cacheoption["その他オプション<br/>TTL・永続化など"]

  keyfields --> example1["例:ISBN,複合キー"]
  fieldpolicy --> example2["例:合計金額計算<br/>配列マージ"]
  cacheoption --> example3["例:キャッシュ有効期限"]

型ポリシーを活用することで、アプリケーション固有の要件に柔軟に対応できるようになります。

部分データの取り扱い戦略

Apollo Client は、部分的なデータも効率的に扱えるよう設計されています。

フィールドの存在チェック

キャッシュから読み取る際、Apollo Client は要求されたすべてのフィールドが存在するかチェックします。

typescript// 最初に最小限のデータを取得
const { data: minimalData } = useQuery(
  gql`
    query MinimalUser($id: ID!) {
      user(id: $id) {
        id
        name
      }
    }
  `,
  { variables: { id: '1' } }
);
typescript// キャッシュの状態
// User:1 { id: "1", name: "太郎" }
typescript// 後から追加のフィールドを要求
const { data: fullData } = useQuery(
  gql`
  query FullUser($id: ID!) {
    user(id: $id) {
      id
      name
      email  // キャッシュに存在しない
    }
  }
`,
  { variables: { id: '1' } }
);

email フィールドがキャッシュに存在しないため、Apollo Client は自動的にサーバーにリクエストを送信します。

fetchPolicy によるキャッシュ戦略

fetchPolicy オプションで、キャッシュとネットワークのバランスを調整できます。

typescript// キャッシュ優先(デフォルト)
const { data } = useQuery(QUERY, {
  fetchPolicy: 'cache-first',
  // キャッシュにデータがあればそれを返す
  // なければネットワークリクエスト
});
typescript// ネットワーク優先
const { data } = useQuery(QUERY, {
  fetchPolicy: 'network-only',
  // 常にサーバーから最新データを取得
  // キャッシュは更新されるが読み取りには使わない
});
typescript// キャッシュのみ
const { data } = useQuery(QUERY, {
  fetchPolicy: 'cache-only',
  // ネットワークリクエストを送らない
  // キャッシュになければエラー
});
typescript// キャッシュ and ネットワーク
const { data } = useQuery(QUERY, {
  fetchPolicy: 'cache-and-network',
  // キャッシュの結果を即座に返し、
  // 同時にネットワークリクエストも送る
});

それぞれの fetchPolicy の特徴を表にまとめました。

#fetchPolicyキャッシュ読取ネットワーク要求用途
1cache-firstキャッシュミス時のみ通常のデータ取得
2cache-only×オフライン対応
3network-only×常に最新データが必要
4cache-and-network即座に表示+更新
5no-cache×キャッシュ不要

部分データの警告制御

部分的なデータしかない場合でも、アプリケーションを動作させたいケースがあります。

typescript// returnPartialData で部分データを許可
const { data, loading } = useQuery(QUERY, {
  returnPartialData: true,
  // キャッシュに一部のフィールドしかなくても
  // 取得可能なデータを返す
});
typescript// コンポーネント内での使用例
function UserProfile({ userId }) {
  const { data, loading } = useQuery(GET_USER_QUERY, {
    variables: { id: userId },
    returnPartialData: true,
  });

  // データが部分的でも表示できる
  return (
    <div>
      <h2>{data?.user?.name || '読み込み中...'}</h2>
      {data?.user?.email && <p>{data.user.email}</p>}
      {loading && <Spinner />}
    </div>
  );
}

この設定により、ユーザー体験を向上させながら、段階的にデータを表示できます。

フラグメントによる再利用

GraphQL のフラグメントを使用すると、共通のフィールドセットを定義して再利用できます。

typescript// ユーザー情報の基本フラグメント
const USER_BASIC_FRAGMENT = gql`
  fragment UserBasic on User {
    id
    name
    avatar
  }
`;
typescript// ユーザー情報の詳細フラグメント
const USER_DETAIL_FRAGMENT = gql`
  fragment UserDetail on User {
    ...UserBasic
    email
    profile
    createdAt
  }
  ${USER_BASIC_FRAGMENT}
`;
typescript// クエリでフラグメントを使用
const GET_USER_BASIC = gql`
  query GetUserBasic($id: ID!) {
    user(id: $id) {
      ...UserBasic
    }
  }
  ${USER_BASIC_FRAGMENT}
`;

const GET_USER_DETAIL = gql`
  query GetUserDetail($id: ID!) {
    user(id: $id) {
      ...UserDetail
    }
  }
  ${USER_DETAIL_FRAGMENT}
`;

フラグメントを使用することで、キャッシュの構造が統一され、部分データの管理が容易になります。

具体例

実践的なブログアプリケーション

Apollo Client のキャッシュ機能を活用したブログアプリケーションの実装例を見ていきましょう。

プロジェクトのセットアップ

まず、必要なパッケージをインストールします。

bash# プロジェクトの作成と必要パッケージのインストール
yarn create next-app blog-app --typescript
cd blog-app
yarn add @apollo/client graphql

Apollo Client の初期化と型ポリシー設定

アプリケーション全体で使用する Apollo Client を設定します。

typescript// lib/apolloClient.ts
import {
  ApolloClient,
  InMemoryCache,
  HttpLink,
} from '@apollo/client';
typescript// HTTP リンクの作成
const httpLink = new HttpLink({
  uri:
    process.env.NEXT_PUBLIC_GRAPHQL_ENDPOINT ||
    'http://localhost:4000/graphql',
  credentials: 'include', // Cookie を含める
});
typescript// 型ポリシーを含むキャッシュの設定
const cache = new InMemoryCache({
  typePolicies: {
    // Post 型のポリシー
    Post: {
      keyFields: ['id'],
      fields: {
        // いいね数とコメント数から人気度を計算
        popularity: {
          read(_, { readField }) {
            const likes = readField('likesCount') || 0;
            const comments =
              readField('commentsCount') || 0;
            return (
              (likes as number) * 2 +
              (comments as number) * 3
            );
          },
        },
      },
    },

    // User 型のポリシー
    User: {
      keyFields: ['id'],
    },

    // Query 型のポリシー(ルートクエリ)
    Query: {
      fields: {
        // 投稿一覧のページネーション対応
        posts: {
          keyArgs: ['category', 'sortBy'],
          merge(existing = [], incoming, { args }) {
            const offset = args?.offset || 0;
            const merged = existing.slice(0);

            for (let i = 0; i < incoming.length; i++) {
              merged[offset + i] = incoming[i];
            }

            return merged;
          },
        },
      },
    },
  },
});
typescript// Apollo Client のインスタンス作成
export const apolloClient = new ApolloClient({
  link: httpLink,
  cache: cache,
  defaultOptions: {
    watchQuery: {
      fetchPolicy: 'cache-and-network',
      errorPolicy: 'all',
    },
    query: {
      fetchPolicy: 'cache-first',
      errorPolicy: 'all',
    },
  },
});

この設定により、投稿のページネーションやカスタム計算フィールドが適切に動作するようになります。

GraphQL フラグメントの定義

再利用可能なフラグメントを定義します。

typescript// lib/fragments.ts
import { gql } from '@apollo/client';
typescript// ユーザー情報の基本フラグメント
export const USER_BASIC_FRAGMENT = gql`
  fragment UserBasic on User {
    id
    name
    avatar
  }
`;
typescript// 投稿情報の基本フラグメント
export const POST_BASIC_FRAGMENT = gql`
  fragment PostBasic on Post {
    id
    title
    excerpt
    publishedAt
    likesCount
    commentsCount
    author {
      ...UserBasic
    }
  }
  ${USER_BASIC_FRAGMENT}
`;
typescript// 投稿情報の詳細フラグメント
export const POST_DETAIL_FRAGMENT = gql`
  fragment PostDetail on Post {
    ...PostBasic
    content
    tags
    updatedAt
  }
  ${POST_BASIC_FRAGMENT}
`;

フラグメントを使用することで、クエリ間でフィールドセットが統一され、キャッシュの効率が向上します。

投稿一覧コンポーネント

投稿一覧を表示し、ページネーションに対応したコンポーネントを実装します。

typescript// components/PostList.tsx
import { useQuery } from '@apollo/client';
import { gql } from '@apollo/client';
import { POST_BASIC_FRAGMENT } from '../lib/fragments';
typescript// 投稿一覧取得クエリ
const GET_POSTS = gql`
  query GetPosts(
    $offset: Int
    $limit: Int
    $category: String
  ) {
    posts(
      offset: $offset
      limit: $limit
      category: $category
    ) {
      ...PostBasic
    }
  }
  ${POST_BASIC_FRAGMENT}
`;
typescript// 投稿一覧コンポーネント
export function PostList({ category }: { category?: string }) {
  const [offset, setOffset] = React.useState(0);
  const limit = 10;

  const { data, loading, fetchMore } = useQuery(GET_POSTS, {
    variables: { offset, limit, category },
    notifyOnNetworkStatusChange: true,
  });
typescript// さらに読み込むハンドラー
const handleLoadMore = () => {
  fetchMore({
    variables: {
      offset: offset + limit,
    },
  });
  setOffset(offset + limit);
};
typescript  if (loading && !data) {
    return <div>読み込み中...</div>;
  }

  return (
    <div>
      {data?.posts.map((post) => (
        <PostCard key={post.id} post={post} />
      ))}
      <button onClick={handleLoadMore} disabled={loading}>
        {loading ? '読み込み中...' : 'さらに読み込む'}
      </button>
    </div>
  );
}

このコンポーネントでは、fetchMore を使用してページネーションを実装しています。型ポリシーの merge 関数により、新しいデータが既存のリストに正しく追加されるのです。

投稿詳細コンポーネント

個別の投稿を表示するコンポーネントを実装します。

typescript// components/PostDetail.tsx
import { useQuery } from '@apollo/client';
import { gql } from '@apollo/client';
import { POST_DETAIL_FRAGMENT } from '../lib/fragments';
typescript// 投稿詳細取得クエリ
const GET_POST = gql`
  query GetPost($id: ID!) {
    post(id: $id) {
      ...PostDetail
      # popularity はキャッシュで計算されるため、
      # サーバーに要求する必要はない
    }
  }
  ${POST_DETAIL_FRAGMENT}
`;
typescript// 投稿詳細コンポーネント
export function PostDetail({ postId }: { postId: string }) {
  const { data, loading, error } = useQuery(GET_POST, {
    variables: { id: postId },
  });

  if (loading) return <div>読み込み中...</div>;
  if (error) return <div>エラーが発生しました</div>;

  const { post } = data;
typescript  return (
    <article>
      <h1>{post.title}</h1>
      <div>
        <img src={post.author.avatar} alt={post.author.name} />
        <span>{post.author.name}</span>
        <time>{new Date(post.publishedAt).toLocaleDateString()}</time>
      </div>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
      <div>
        <span>いいね: {post.likesCount}</span>
        <span>コメント: {post.commentsCount}</span>
      </div>
    </article>
  );
}

投稿一覧で既にキャッシュされているデータがあれば、詳細ページは即座に表示されます。

いいね機能の実装

ミューテーションとキャッシュ更新の実装例です。

typescript// components/LikeButton.tsx
import { useMutation } from '@apollo/client';
import { gql } from '@apollo/client';
typescript// いいねミューテーション
const LIKE_POST = gql`
  mutation LikePost($postId: ID!) {
    likePost(id: $postId) {
      id
      likesCount
      isLikedByMe
    }
  }
`;
typescript// いいねボタンコンポーネント
export function LikeButton({ postId, initialLikes, initialIsLiked }: {
  postId: string;
  initialLikes: number;
  initialIsLiked: boolean;
}) {
  const [likePost, { loading }] = useMutation(LIKE_POST, {
    variables: { postId },
    // 楽観的 UI 更新
    optimisticResponse: {
      likePost: {
        __typename: 'Post',
        id: postId,
        likesCount: initialIsLiked ? initialLikes - 1 : initialLikes + 1,
        isLikedByMe: !initialIsLiked,
      },
    },
  });
typescript  const handleClick = async () => {
    try {
      await likePost();
    } catch (error) {
      console.error('いいねに失敗しました', error);
    }
  };

  return (
    <button onClick={handleClick} disabled={loading}>
      {initialIsLiked ? 'いいね済み' : 'いいね'} ({initialLikes})
    </button>
  );
}

optimisticResponse を使用することで、サーバーのレスポンスを待たずに UI を更新し、ユーザー体験を向上させています。

以下の図は、ミューテーション実行時のキャッシュ更新フローを示しています。

mermaidsequenceDiagram
  participant UI as UI コンポーネント
  participant Apollo as Apollo Client
  participant Cache as InMemoryCache
  participant Server as GraphQL サーバー

  UI->>Apollo: likePost ミューテーション実行
  Apollo->>Cache: 楽観的レスポンスで即座に更新
  Cache->>UI: 更新通知(likesCount増加)
  UI->>UI: 即座に再レンダリング

  Apollo->>Server: ミューテーションリクエスト送信
  Server->>Apollo: 実際のレスポンス返却
  Apollo->>Cache: 実際のデータでキャッシュ更新
  Cache->>UI: 更新通知(必要なら再レンダリング)

楽観的更新により、ユーザーは待ち時間なくアクションの結果を確認できるのです。

キャッシュの読み書き

キャッシュを直接操作する高度な例です。

typescript// utils/cacheHelpers.ts
import { apolloClient } from '../lib/apolloClient';
import { gql } from '@apollo/client';
typescript// キャッシュから投稿を読み取る関数
export function readPostFromCache(postId: string) {
  return apolloClient.cache.readFragment({
    id: `Post:${postId}`,
    fragment: gql`
      fragment ReadPost on Post {
        id
        title
        likesCount
        commentsCount
      }
    `,
  });
}
typescript// キャッシュの投稿を更新する関数
export function updatePostInCache(
  postId: string,
  updates: Partial<Post>
) {
  apolloClient.cache.writeFragment({
    id: `Post:${postId}`,
    fragment: gql`
      fragment UpdatePost on Post {
        id
        title
        likesCount
        commentsCount
      }
    `,
    data: {
      __typename: 'Post',
      id: postId,
      ...updates,
    },
  });
}
typescript// 使用例:コメント追加時にコメント数を増やす
export function incrementCommentsCount(postId: string) {
  const post = readPostFromCache(postId);
  if (post) {
    updatePostInCache(postId, {
      commentsCount: post.commentsCount + 1,
    });
  }
}

キャッシュを直接操作することで、サーバーへのリクエストなしでローカル状態を更新できます。

パフォーマンス最適化のポイント

実際のアプリケーションでキャッシュを効果的に活用するためのポイントをまとめます。

クエリの最適化

typescript// 悪い例:必要以上のデータを取得
const BAD_QUERY = gql`
  query GetAllUserData {
    users {
      id
      name
      email
      profile
      posts {
        id
        title
        content
        comments {
          id
          text
          author { ... }
        }
      }
    }
  }
`;
typescript// 良い例:必要なデータのみを取得
const GOOD_QUERY = gql`
  query GetUserList {
    users {
      id
      name
      avatar
    }
  }
`;

必要なフィールドのみを取得することで、ネットワーク転送量とキャッシュサイズを削減できます。

バッチリクエストの活用

typescript// Apollo Link Batch HTTP を使用してリクエストをバッチ化
import { BatchHttpLink } from '@apollo/client/link/batch-http';

const batchLink = new BatchHttpLink({
  uri: 'http://localhost:4000/graphql',
  batchMax: 10, // 最大10リクエストをバッチ化
  batchInterval: 20, // 20ms以内のリクエストをまとめる
});

複数のクエリを 1 つの HTTP リクエストにまとめることで、ネットワークのオーバーヘッドを削減できます。

キャッシュの永続化

typescript// Apollo Client の永続化(apollo3-cache-persist を使用)
import { persistCache } from 'apollo3-cache-persist';
typescriptasync function setupApollo() {
  const cache = new InMemoryCache();

  // キャッシュを LocalStorage に永続化
  await persistCache({
    cache,
    storage: window.localStorage,
    maxSize: 1048576, // 1MB
  });

  return new ApolloClient({
    cache,
    // ... その他の設定
  });
}
typescript// アプリケーション起動時に永続化されたキャッシュを復元
setupApollo().then((client) => {
  // Apollo Provider で使用
});

キャッシュを永続化することで、ページリロード後も即座にデータを表示できます。

以下の表は、各最適化手法の効果をまとめたものです。

#最適化手法効果実装難易度
1必要最小限のフィールド取得転送量削減
2フラグメントの活用コード再利用性向上
3バッチリクエストリクエスト数削減★★
4楽観的更新UX 向上★★
5キャッシュ永続化初期表示高速化★★★
6型ポリシーによる計算フィールドサーバー負荷軽減★★★

まとめ

Apollo Client のキャッシュ思想について、正規化・型ポリシー・部分データの取り扱いという 3 つの核心的な要素を詳しく見てきました。

正規化によって、データを一箇所で一元管理し、更新の一貫性を保証できます。型ポリシーを使えば、アプリケーション固有の要件に合わせてキャッシュの動作を柔軟にカスタマイズできるのです。そして、部分データを適切に扱うことで、効率的なデータ取得と快適なユーザー体験を両立できますね。

これらの仕組みを理解し、適切に活用することで、以下のようなメリットが得られるでしょう。

  • サーバーへのリクエスト数を削減し、パフォーマンスを向上
  • データの一貫性を保ち、バグを減らす
  • ユーザー体験を向上させる楽観的更新の実装
  • メンテナンス性の高いコードベースの構築

Apollo Client のキャッシュは単なるデータストアではなく、GraphQL アプリケーションの中核となる設計思想なのです。この記事で紹介した概念と実装例を参考に、ぜひ実際のプロジェクトでキャッシュ機能を活用してみてください。

関連リンク