T-CREATOR

Apollo Client のフィールドポリシー入門:read/merge で高度なキャッシュ制御を実装

Apollo Client のフィールドポリシー入門:read/merge で高度なキャッシュ制御を実装

Apollo Client を使ったアプリケーション開発では、GraphQL のクエリ結果をどのようにキャッシュするかが、パフォーマンスとユーザー体験を大きく左右します。デフォルトのキャッシュ動作でも多くのケースに対応できますが、ページネーションや計算フィールド、複雑なデータ構造を扱う場合には、より細かい制御が必要になるでしょう。

そこで本記事では、Apollo Client の フィールドポリシー(Field Policies) に焦点を当て、特に read 関数と merge 関数を活用した高度なキャッシュ制御の実装方法を、初心者の方にもわかりやすく解説します。実際のコード例を交えながら、段階的に理解を深めていきましょう。

背景

Apollo Client のキャッシュシステム

Apollo Client は、GraphQL クエリの結果を InMemoryCache に保存することで、同じデータへのリクエストを削減し、アプリケーションのパフォーマンスを向上させています。

このキャッシュシステムは、各オブジェクトを一意の識別子(通常は __typenameid の組み合わせ)で正規化し、フラットな構造で保存するのが特徴です。

以下の図は、Apollo Client の基本的なキャッシュフローを示しています。

mermaidflowchart TB
    app["React アプリ"] -->|GraphQL クエリ| apollo["Apollo Client"]
    apollo -->|キャッシュ確認| cache["InMemoryCache"]
    cache -->|キャッシュヒット| apollo
    cache -->|キャッシュミス| network["ネットワークリクエスト"]
    network -->|レスポンス| apollo
    apollo -->|データ正規化| cache
    apollo -->|結果返却| app

Apollo Client は、クエリを実行する際にまずキャッシュを確認し、必要なデータがあればネットワークリクエストをスキップします。この仕組みにより、アプリケーションの応答速度が大幅に向上するのです。

フィールドポリシーとは

フィールドポリシー は、特定のフィールドに対してキャッシュの読み書き動作をカスタマイズできる仕組みです。Type Policies の一部として定義し、各フィールドごとに細かい制御が可能になります。

主な機能は以下の通りです。

#機能説明
1read 関数キャッシュからデータを読み取る際の処理をカスタマイズ
2merge 関数データをキャッシュに書き込む際の処理をカスタマイズ
3keyArgsフィールドのキャッシュキーを決定する引数を指定

これらの機能を組み合わせることで、ページネーション、ソート、フィルタリングなど、さまざまなユースケースに対応できるでしょう。

課題

デフォルトキャッシュ動作の限界

Apollo Client のデフォルトキャッシュは非常に優秀ですが、以下のようなケースでは期待通りに動作しないことがあります。

1. ページネーションデータの蓄積

無限スクロールやページネーション機能を実装する際、新しいページのデータを取得しても、既存のデータが上書きされてしまうという問題があります。

typescript// ページネーションのクエリ例
const GET_POSTS = gql`
  query GetPosts($offset: Int!, $limit: Int!) {
    posts(offset: $offset, limit: $limit) {
      id
      title
      content
    }
  }
`;

このクエリを offset: 0offset: 10 で実行すると、デフォルトでは後者が前者を上書きしてしまいます。本来は両方のデータを保持したいところですね。

2. 計算フィールドの実装

サーバーから送られてこないが、クライアント側で計算したい仮想フィールドを実装したい場合があります。例えば、商品の価格と税率から税込価格を算出するケースです。

typescript// サーバーから返却されるデータ
type Product = {
  id: string;
  name: string;
  price: number;
  // taxIncludedPrice はサーバーから返却されない
};

デフォルトのキャッシュでは、存在しないフィールドにアクセスすると undefined が返されてしまいます。

3. 異なる引数での同一フィールド

同じフィールドでも、引数が異なれば別のデータとして扱いたいケースがあります。

typescript// フィルター条件が異なるクエリ
const GET_USERS = gql`
  query GetUsers($role: String!) {
    users(role: $role) {
      id
      name
      role
    }
  }
`;

role: "admin"role: "user" では、異なる結果セットが返されるべきですが、デフォルトでは区別されません。

以下の図は、これらの課題が発生する状況を示しています。

mermaidflowchart LR
    query1["クエリ1<br />offset=0"] --|上書き|--> cache[(キャッシュ)]
    query2["クエリ2<br />offset=10"] --|上書き|--> cache
    cache --|最新データのみ<br />(データ損失)|--> result["結果"]

このような課題を解決するために、フィールドポリシーの read 関数と merge 関数が威力を発揮するのです。

解決策

フィールドポリシーの基本構造

フィールドポリシーは、Apollo Client の InMemoryCache 初期化時に Type Policies の一部として設定します。基本的な構造は以下の通りです。

typescriptimport { InMemoryCache } from '@apollo/client';

const cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        // フィールド名: フィールドポリシー
        posts: {
          // キャッシュキーを決定する引数
          keyArgs: ['filter'],
          // データをマージする際の処理
          merge(existing, incoming, { args }) {
            // マージロジック
          },
          // データを読み取る際の処理
          read(existing, { args }) {
            // 読み取りロジック
          },
        },
      },
    },
  },
});

この設定により、Query 型の posts フィールドに対して、カスタムのキャッシュ動作を定義できます。

merge 関数:データの書き込み制御

merge 関数は、新しいデータをキャッシュに保存する際に呼び出されます。既存データと新規データをどのように結合するかを制御できるのです。

関数シグネチャ

typescripttype FieldMergeFunction<TExisting, TIncoming> = (
  existing: TExisting | undefined,
  incoming: TIncoming,
  options: {
    args: Record<string, any> | null;
    field: FieldNode;
    fieldName: string;
    storeFieldName: string;
    mergeObjects: <T>(existing: T, incoming: T) => T;
    cache: InMemoryCache;
    readField: ReadFieldFunction;
  }
) => TExisting | TIncoming;

主要なパラメータは以下の通りです。

#パラメータ説明
1existingキャッシュに既に存在するデータ(初回は undefined
2incoming新しく取得したデータ
3argsクエリ実行時に渡された引数
4readFieldキャッシュから他のフィールドを読み取る関数

ページネーション実装例

無限スクロールのように、データを蓄積していく場合の merge 関数実装例です。

typescriptconst cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        posts: {
          keyArgs: false, // すべてのクエリで同じキャッシュキーを使用
          merge(existing = [], incoming, { args }) {
            const merged = existing
              ? existing.slice(0)
              : [];
            const offset = args?.offset || 0;

            // offset 位置から incoming データを挿入
            for (let i = 0; i < incoming.length; i++) {
              merged[offset + i] = incoming[i];
            }

            return merged;
          },
        },
      },
    },
  },
});

この実装により、offset: 0 で取得したデータと offset: 10 で取得したデータが、配列の適切な位置に保存されます。

以下の図は、merge 関数によるデータ蓄積の仕組みを示しています。

mermaidflowchart TB
    query1["クエリ1<br/>offset=0, limit=10"] -->|merge| cache[("キャッシュ<br/>[0-9]")]
    query2["クエリ2<br/>offset=10, limit=10"] -->|merge| cache2[("キャッシュ<br/>[0-19]")]
    cache -->|既存データ保持| cache2
    cache2 -->|完全なデータセット| result["結果"]

read 関数:データの読み取り制御

read 関数は、キャッシュからデータを読み取る際に呼び出されます。キャッシュに保存されているデータを加工したり、計算フィールドを実装したりできるのです。

関数シグネチャ

typescripttype FieldReadFunction<TExisting, TResult> = (
  existing: TExisting | undefined,
  options: {
    args: Record<string, any> | null;
    field: FieldNode;
    fieldName: string;
    storeFieldName: string;
    cache: InMemoryCache;
    readField: ReadFieldFunction;
    canRead: CanReadFunction;
    toReference: ToReferenceFunction;
  }
) => TResult | undefined;

read 関数が値を返すと、その値がキャッシュの値として使用されます。undefined を返すと、通常のキャッシュ読み取り動作が実行されるでしょう。

ページネーション読み取り例

merge 関数でデータを蓄積した後、read 関数で必要な範囲のみを取り出します。

typescriptconst cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        posts: {
          keyArgs: false,
          merge(existing = [], incoming, { args }) {
            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;
          },
          read(existing, { args }) {
            if (!existing) return undefined;

            const offset = args?.offset || 0;
            const limit = args?.limit || 10;

            // 指定範囲のデータを返却
            return existing.slice(offset, offset + limit);
          },
        },
      },
    },
  },
});

この実装により、クエリの引数に応じて適切なデータ範囲が返却されます。

計算フィールドの実装

サーバーから送られてこないフィールドを、クライアント側で計算して提供できます。

typescriptconst cache = new InMemoryCache({
  typePolicies: {
    Product: {
      fields: {
        taxIncludedPrice: {
          read(_, { readField }) {
            // 他のフィールドから値を読み取り
            const price = readField<number>('price');
            const taxRate =
              readField<number>('taxRate') || 0.1;

            // 税込価格を計算
            return price
              ? Math.round(price * (1 + taxRate))
              : undefined;
          },
        },
      },
    },
  },
});

これで、GraphQL クエリに taxIncludedPrice を含めると、自動的に計算された値が返されます。

keyArgs:キャッシュキーの制御

keyArgs は、どの引数をキャッシュキーに含めるかを指定します。これにより、異なる引数での呼び出しを別々にキャッシュできるのです。

typescriptconst cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        users: {
          // role 引数をキャッシュキーに含める
          keyArgs: ['role'],
          merge(existing, incoming) {
            // role が異なれば別々に保存される
            return incoming;
          },
        },
      },
    },
  },
});

keyArgs の設定パターンは以下の通りです。

#設定値説明
1['arg1', 'arg2']指定した引数のみをキャッシュキーに含める
2falseすべての引数を無視(すべて同じキャッシュキー)
3[]すべての引数をキャッシュキーに含める(デフォルト)

具体例

実践例 1:無限スクロールの実装

無限スクロール機能を実装する際の、完全な実装例を見てみましょう。

Apollo Client の設定

まず、フィールドポリシーを設定した Apollo Client を初期化します。

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

const client = new ApolloClient({
  uri: 'https://api.example.com/graphql',
  cache: new InMemoryCache({
    typePolicies: {
      Query: {
        fields: {
          posts: {
            // すべてのクエリで同じキャッシュを使用
            keyArgs: false,
typescript            merge(existing = [], incoming, { args }) {
              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            read(existing, { args }) {
              // キャッシュが空の場合は undefined を返す
              if (!existing) return undefined;

              const offset = args?.offset ?? 0;
              const limit = args?.limit ?? 10;
              const end = offset + limit;

              // 必要な範囲のデータが揃っているか確認
              const hasAllData = existing.slice(offset, end)
                .every(item => item !== undefined);

              // データが不足している場合は undefined を返してフェッチさせる
              if (!hasAllData) return undefined;

              return existing.slice(offset, end);
            },
          },
        },
      },
    },
  }),
});

GraphQL クエリの定義

次に、ページネーション用のクエリを定義します。

typescriptconst GET_POSTS = gql`
  query GetPosts($offset: Int!, $limit: Int!) {
    posts(offset: $offset, limit: $limit) {
      id
      title
      content
      createdAt
      author {
        id
        name
      }
    }
  }
`;

React コンポーネントでの利用

useQuery フックと fetchMore 関数を使って、無限スクロールを実装します。

typescriptimport { useQuery } from '@apollo/client';
import { useEffect, useRef } from 'react';

function InfiniteScrollPosts() {
  const LIMIT = 10;
  const { data, loading, error, fetchMore } = useQuery(GET_POSTS, {
    variables: { offset: 0, limit: LIMIT },
  });
typescriptconst loadMore = async () => {
  const currentLength = data?.posts?.length || 0;

  await fetchMore({
    variables: {
      offset: currentLength,
      limit: LIMIT,
    },
  });
};
typescript// スクロール監視の実装
const observerRef = useRef<IntersectionObserver>();
const lastItemRef = useRef<HTMLDivElement>(null);

useEffect(() => {
  if (loading) return;

  observerRef.current = new IntersectionObserver(
    (entries) => {
      if (entries[0].isIntersecting) {
        loadMore();
      }
    }
  );

  if (lastItemRef.current) {
    observerRef.current.observe(lastItemRef.current);
  }

  return () => observerRef.current?.disconnect();
}, [loading, data]);
typescript  if (error) return <p>Error: {error.message}</p>;

  return (
    <div>
      {data?.posts?.map((post, index) => (
        <div
          key={post.id}
          ref={index === data.posts.length - 1 ? lastItemRef : null}
        >
          <h2>{post.title}</h2>
          <p>{post.content}</p>
          <small>{post.author.name}</small>
        </div>
      ))}
      {loading && <p>読み込み中...</p>}
    </div>
  );
}

この実装により、ユーザーがスクロールするたびに新しいデータが読み込まれ、既存のデータは保持されます。

実践例 2:フィルター付きリストのキャッシュ管理

検索条件やフィルター条件ごとに、別々のキャッシュを保持する実装例です。

フィールドポリシーの設定

typescriptconst cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        products: {
          // category と sortBy をキャッシュキーに含める
          keyArgs: ['filter', ['category'], 'sortBy'],
typescript          merge(existing, incoming, { args }) {
            // フィルター条件が異なれば別々に保存される
            return incoming;
          },
typescript          read(existing) {
            // 既存データをそのまま返す
            return existing;
          },
        },
      },
    },
  },
});

クエリの定義

typescriptconst GET_PRODUCTS = gql`
  query GetProducts(
    $filter: ProductFilter
    $sortBy: SortOrder
  ) {
    products(filter: $filter, sortBy: $sortBy) {
      id
      name
      price
      category
      stock
    }
  }
`;

コンポーネントでの利用

typescriptimport { useQuery } from '@apollo/client';
import { useState } from 'react';

function ProductList() {
  const [category, setCategory] = useState('all');
  const [sortBy, setSortBy] = useState('price_asc');
typescriptconst { data, loading } = useQuery(GET_PRODUCTS, {
  variables: {
    filter: {
      category: category !== 'all' ? category : undefined,
    },
    sortBy,
  },
});
typescript  return (
    <div>
      <select value={category} onChange={(e) => setCategory(e.target.value)}>
        <option value="all">すべて</option>
        <option value="electronics">家電</option>
        <option value="books">書籍</option>
      </select>

      <select value={sortBy} onChange={(e) => setSortBy(e.target.value)}>
        <option value="price_asc">価格:安い順</option>
        <option value="price_desc">価格:高い順</option>
        <option value="name_asc">名前:昇順</option>
      </select>

      {loading ? (
        <p>読み込み中...</p>
      ) : (
        <ul>
          {data?.products?.map((product) => (
            <li key={product.id}>
              {product.name} - ¥{product.price.toLocaleString()}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

フィルター条件を変更すると、それぞれの条件ごとにキャッシュされたデータが使用されるため、再度同じ条件で表示する際はネットワークリクエストが発生しません。

実践例 3:リアルタイム計算フィールド

商品の価格に税率を適用した税込価格を、クライアント側で計算する例です。

型定義とフィールドポリシー

typescripttype Product = {
  __typename: 'Product';
  id: string;
  name: string;
  price: number;
  taxRate?: number;
  // taxIncludedPrice は計算フィールド
};
typescriptconst cache = new InMemoryCache({
  typePolicies: {
    Product: {
      fields: {
        taxIncludedPrice: {
          read(_, { readField }) {
            const price = readField<number>('price');
            const taxRate = readField<number>('taxRate') ?? 0.1;

            if (price === undefined) return undefined;

            // 税込価格を計算(小数点以下切り捨て)
            return Math.floor(price * (1 + taxRate));
          },
        },
typescript        displayPrice: {
          read(_, { readField }) {
            const taxIncludedPrice = readField<number>('taxIncludedPrice');

            if (taxIncludedPrice === undefined) return undefined;

            // 3桁区切りでフォーマット
            return ${taxIncludedPrice.toLocaleString('ja-JP')}`;
          },
        },
      },
    },
  },
});

クエリの定義

typescriptconst GET_PRODUCT = gql`
  query GetProduct($id: ID!) {
    product(id: $id) {
      id
      name
      price
      taxRate
      # サーバーには存在しないが、クライアント側で計算される
      taxIncludedPrice
      displayPrice
    }
  }
`;

コンポーネントでの表示

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

function ProductDetail({ productId }: { productId: string }) {
  const { data, loading, error } = useQuery(GET_PRODUCT, {
    variables: { id: productId },
  });
typescript  if (loading) return <p>読み込み中...</p>;
  if (error) return <p>Error: {error.message}</p>;

  const { product } = data;

  return (
    <div>
      <h1>{product.name}</h1>
      <p>本体価格: ¥{product.price.toLocaleString()}</p>
      <p>税率: {(product.taxRate * 100).toFixed(1)}%</p>
      <p>税込価格: {product.displayPrice}</p>
    </div>
  );
}

この実装により、サーバーから pricetaxRate のみが送られてきても、クライアント側で自動的に taxIncludedPricedisplayPrice が計算されます。

以下の図は、計算フィールドの動作フローを示しています。

mermaidflowchart LR
    server["サーバー"] --|price: 1000<br/>taxRate: 0.1|--> cache[(キャッシュ)]
    cache --|read 関数実行|--> calc["計算処理"]
    calc --|taxIncludedPrice: 1100<br/>displayPrice: ¥1,100|--> component["コンポーネント"]

デバッグとトラブルシューティング

フィールドポリシーの実装時によく遭遇するエラーと解決方法を紹介します。

エラー 1:Cannot read property of undefined

エラーコード: TypeError: Cannot read property 'slice' of undefined

typescript// エラーが発生するコード
merge(existing, incoming, { args }) {
  const merged = existing.slice(0); // existing が undefined の場合エラー
  // ...
}

発生条件: 初回のデータ取得時に existingundefined であるため

解決方法: デフォルト値を設定する

typescriptmerge(existing = [], incoming, { args }) {
  const merged = existing.slice(0); // 安全にスライス可能
  // ...
}

エラー 2:無限ループ

エラーコード: Maximum update depth exceeded

エラーメッセージ:

sqlError: Maximum update depth exceeded. This can happen when a component
repeatedly calls setState inside componentWillUpdate or componentDidUpdate.

発生条件: read 関数内で readField を使って同じフィールドを読み取ろうとした場合

typescript// エラーが発生するコード
read(existing, { readField }) {
  // 自分自身を読み取ろうとして無限ループ
  const self = readField('posts');
  return self;
}

解決方法: existing パラメータを使用する

typescriptread(existing) {
  // existing を直接使用
  return existing;
}

エラー 3:データが更新されない

発生条件: keyArgs の設定が不適切で、異なる引数でも同じキャッシュが使われてしまう

解決方法: 適切な keyArgs を設定する

typescript// 修正前:すべて同じキャッシュになる
fields: {
  users: {
    keyArgs: false,
  }
}

// 修正後:role ごとに別々のキャッシュ
fields: {
  users: {
    keyArgs: ['role'],
  }
}

まとめ

Apollo Client のフィールドポリシーは、read 関数と merge 関数を活用することで、キャッシュの読み書きを自由にカスタマイズできる強力な機能です。

本記事で解説した内容を振り返ってみましょう。

#項目ポイント
1merge 関数新しいデータをキャッシュに保存する際の処理を制御
2read 関数キャッシュからデータを読み取る際の処理を制御
3keyArgsどの引数をキャッシュキーに含めるかを指定
4ページネーションデータを蓄積しながら適切な範囲を返却
5計算フィールドサーバーにないフィールドをクライアント側で生成

無限スクロール、フィルター付きリスト、計算フィールドなど、さまざまなユースケースに対応できることがお分かりいただけたでしょう。

フィールドポリシーを適切に設定することで、ネットワークリクエストを最小限に抑え、ユーザー体験を大幅に向上させることができます。最初は複雑に感じるかもしれませんが、基本的なパターンを理解すれば、多くのケースに応用できるはずです。

ぜひ実際のプロジェクトでフィールドポリシーを活用し、より高度なキャッシュ制御を実現してみてください。

関連リンク