T-CREATOR

Apollo Client の状態管理完全攻略 - Cache とローカル状態の使い分け

Apollo Client の状態管理完全攻略 - Cache とローカル状態の使い分け

Apollo Client を使った開発では、単にデータを取得するだけでなく、効率的な状態管理が成功の鍵を握ります。サーバーから取得したデータをどう管理し、UI の状態をどう扱うかで、アプリケーションのパフォーマンスと保守性が大きく変わってくるのです。

本記事では、Apollo Client の Cache 機能とローカル状態管理の違いを明確にし、それぞれの特性を活かした実装方法をご紹介いたします。基礎概念から実践的な活用法まで、段階的に理解を深めていただけるよう構成いたしました。

Apollo Client の状態管理基礎

Apollo Client における状態管理は、従来の Redux や Context API とは異なる独特なアプローチを採用しています。その核心を理解することで、より効率的な開発が可能になるでしょう。

Cache の仕組み

Apollo Client の Cache は、GraphQL のクエリ結果を自動的に正規化して保存する仕組みです。これにより、同じデータに対する重複リクエストを避け、アプリケーション全体で一貫したデータ状態を保持できます。

以下の図は、Apollo Client の Cache がどのように動作するかを示しています。

mermaidflowchart LR
    component[コンポーネント] -->|useQuery| apollo[Apollo Client]
    apollo -->|初回リクエスト| server[GraphQL Server]
    server -->|レスポンス| apollo
    apollo -->|正規化して保存| cache[(InMemoryCache)]
    apollo -->|データ返却| component

    component2[別のコンポーネント] -->|同じクエリ| apollo
    apollo -->|キャッシュから取得| cache
    apollo -->|即座に返却| component2

Cache の正規化により、関連するデータが自動的に同期され、データの整合性が保たれます。

Cache の基本的な設定方法を見てみましょう。

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

// Cache の基本設定
const cache = new InMemoryCache({
  // 型ポリシーの定義
  typePolicies: {
    Product: {
      fields: {
        // reviews フィールドの結合ポリシー
        reviews: {
          merge(existing = [], incoming) {
            return [...existing, ...incoming];
          },
        },
      },
    },
  },
});

このように設定することで、GraphQL の型ごとに Cache の動作をカスタマイズできます。

ローカル状態管理の概要

Apollo Client のローカル状態管理は、サーバーから取得したデータ以外の UI 状態や、一時的な状態を管理するための機能です。従来の状態管理ライブラリの役割を担いながら、GraphQL のクエリと統合的に扱えるのが特徴となっています。

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

// リアクティブ変数の定義
const cartItemsVar = makeVar([]);
const isLoggedInVar = makeVar(false);
const currentUserVar = makeVar(null);

リアクティブ変数(Reactive Variables)を使用することで、コンポーネント間で簡単に状態を共有できます。

ローカル状態の管理フローを図で確認してみましょう。

mermaidstateDiagram-v2
    [*] --> Initial
    Initial --> Updated : setState
    Updated --> Reactive : notify
    Reactive --> Components : re-render
    Components --> Updated : setState

状態が更新されると、それを購読している全てのコンポーネントが自動的に再レンダリングされます。

両者の関係性

Cache とローカル状態は相補的な関係にあります。Cache はサーバーデータの効率的な管理を担当し、ローカル状態は UI 特有の状態やユーザーの操作状態を管理します。

重要なのは、両者を適切に使い分けることです。以下の表で基本的な使い分けを整理いたします。

#データの種類推奨手法理由
1サーバーから取得したデータCache正規化と同期が自動的に行われる
2UI の表示状態ローカル状態サーバーに保存する必要がない
3フォームの入力値ローカル状態一時的で揮発的な情報
4ユーザー認証情報両方の組み合わせサーバーデータ + UI 状態の複合

Cache 管理の詳細

Apollo Client の Cache 管理を深く理解することで、アプリケーションのパフォーマンスを大幅に向上させることができます。ここでは、InMemoryCache の詳細設定から実用的な運用テクニックまでを解説いたします。

InMemoryCache の設定

InMemoryCache は Apollo Client の中核となるキャッシュ実装です。デフォルトの設定でも十分に機能しますが、アプリケーションの要件に応じてカスタマイズすることで、より効率的な動作を実現できます。

基本的な InMemoryCache の設定から見てみましょう。

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

const cache = new InMemoryCache({
  // データの正規化ID生成ルール
  dataIdFromObject: (object) => {
    switch (object.__typename) {
      case 'User':
        return `User:${object.id}`;
      case 'Product':
        return `Product:${object.id}`;
      default:
        return null;
    }
  },

  // 追加のID生成ルール
  possibleTypes: {
    SearchResult: ['User', 'Product', 'Category'],
  },
});

型ポリシーを使用した、より詳細な Cache 制御の設定例です。

typescriptconst cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        // products フィールドのページネーション制御
        products: {
          keyArgs: ['category', 'sortBy'],
          merge(existing = [], incoming, { args }) {
            const { offset = 0 } = args;
            const merged = existing ? existing.slice() : [];

            // 指定されたオフセット位置から新しいデータを挿入
            for (let i = 0; i < incoming.length; ++i) {
              merged[offset + i] = incoming[i];
            }

            return merged;
          },
        },
      },
    },

    Product: {
      fields: {
        // 評価の合計計算をローカルで実行
        averageRating: {
          read(_, { readField }) {
            const reviews = readField('reviews');
            if (!reviews || reviews.length === 0) return 0;

            const sum = reviews.reduce((total, review) => {
              return total + readField('rating', review);
            }, 0);

            return sum / reviews.length;
          },
        },
      },
    },
  },
});

キャッシュポリシーの選択

Apollo Client では、クエリごとにキャッシュポリシーを指定できます。適切なポリシーを選択することで、ユーザー体験とパフォーマンスの最適な バランスを実現できるでしょう。

主要なキャッシュポリシーとその特徴を説明いたします。

typescriptimport { useQuery } from '@apollo/client';
import { GET_PRODUCTS } from './queries';

// cache-first: デフォルト、キャッシュ優先
const { data: productsData } = useQuery(GET_PRODUCTS, {
  fetchPolicy: 'cache-first',
});

// network-only: 常にサーバーから最新データを取得
const { data: realTimeData } = useQuery(
  GET_REAL_TIME_DATA,
  {
    fetchPolicy: 'network-only',
  }
);

// cache-and-network: キャッシュとネットワークの両方から取得
const { data: hybridData } = useQuery(GET_HYBRID_DATA, {
  fetchPolicy: 'cache-and-network',
});

ポリシー選択の判断基準を表で整理いたします。

#ポリシー名適用場面メリット注意点
1cache-first一般的なデータ表示高速な初期表示データが古い可能性
2network-onlyリアルタイム性が重要常に最新データネットワーク負荷大
3cache-and-networkUX と鮮度の両立段階的データ更新実装が複雑
4no-cache機密性の高いデータセキュリティ確保パフォーマンス低下

キャッシュの更新タイミング

Cache の更新タイミングを適切に制御することで、データの整合性を保ちながらユーザー体験を向上させることができます。

Mutation 後の Cache 更新方法を見てみましょう。

typescriptimport { useMutation } from '@apollo/client';
import { ADD_PRODUCT, GET_PRODUCTS } from './queries';

const AddProduct = () => {
  const [addProduct] = useMutation(ADD_PRODUCT, {
    // Cache の自動更新
    update: (cache, { data: { addProduct } }) => {
      const existingProducts = cache.readQuery({
        query: GET_PRODUCTS,
      });

      cache.writeQuery({
        query: GET_PRODUCTS,
        data: {
          products: [
            ...existingProducts.products,
            addProduct,
          ],
        },
      });
    },

    // 楽観的な更新
    optimisticResponse: {
      addProduct: {
        id: 'temp-id',
        name: '新しい商品',
        price: 0,
        __typename: 'Product',
      },
    },
  });

  return (
    <button onClick={() => addProduct()}>商品を追加</button>
  );
};

Fragment を使用した効率的な Cache 更新の例です。

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

// Fragment の定義
const PRODUCT_FRAGMENT = gql`
  fragment ProductInfo on Product {
    id
    name
    price
    category
    inStock
  }
`;

// Fragment を使用した更新
const updateProductStock = (cache, productId, newStock) => {
  const fragment = cache.readFragment({
    id: `Product:${productId}`,
    fragment: PRODUCT_FRAGMENT,
  });

  if (fragment) {
    cache.writeFragment({
      id: `Product:${productId}`,
      fragment: PRODUCT_FRAGMENT,
      data: {
        ...fragment,
        inStock: newStock,
      },
    });
  }
};

ローカル状態管理の実装

Apollo Client のローカル状態管理は、従来の状態管理ライブラリと GraphQL の世界を橋渡しする重要な機能です。コンポーネント間の状態共有から複雑な UI ロジックまで、柔軟に対応できる仕組みを提供しています。

Reactive Variables の活用

Reactive Variables は、Apollo Client でローカル状態を管理する最もシンプルで効果的な方法です。React の useState と似ていますが、グローバルスコープで動作し、複数のコンポーネントから同じ状態を参照できるのが特徴となっています。

基本的な Reactive Variables の定義と使用方法をご紹介いたします。

typescriptimport { makeVar, useReactiveVar } from '@apollo/client';

// 各種状態変数の定義
const currentThemeVar = makeVar('light');
const cartItemsVar = makeVar([]);
const userPreferencesVar = makeVar({
  language: 'ja',
  currency: 'JPY',
  timezone: 'Asia/Tokyo',
});

コンポーネントでの Reactive Variables の活用例です。

typescriptimport React from 'react';
import { useReactiveVar } from '@apollo/client';

const ThemeToggle = () => {
  const currentTheme = useReactiveVar(currentThemeVar);

  const toggleTheme = () => {
    const newTheme =
      currentTheme === 'light' ? 'dark' : 'light';
    currentThemeVar(newTheme);
  };

  return (
    <button onClick={toggleTheme}>
      現在のテーマ: {currentTheme}
      {currentTheme === 'light' ? '🌞' : '🌙'}
    </button>
  );
};

// ショッピングカートの管理
const CartManager = () => {
  const cartItems = useReactiveVar(cartItemsVar);

  const addItem = (product) => {
    const currentItems = cartItemsVar();
    const existingItem = currentItems.find(
      (item) => item.id === product.id
    );

    if (existingItem) {
      // 既存商品の数量更新
      const updatedItems = currentItems.map((item) =>
        item.id === product.id
          ? { ...item, quantity: item.quantity + 1 }
          : item
      );
      cartItemsVar(updatedItems);
    } else {
      // 新商品の追加
      cartItemsVar([
        ...currentItems,
        { ...product, quantity: 1 },
      ]);
    }
  };

  return (
    <div>
      <p>カート内商品数: {cartItems.length}</p>
      {/* カート内容の表示 */}
    </div>
  );
};

複雑な状態オブジェクトの管理パターンも見てみましょう。

typescript// ユーザー設定の状態管理
interface UserSettings {
  notifications: {
    email: boolean;
    push: boolean;
    sms: boolean;
  };
  privacy: {
    profileVisible: boolean;
    activityTracking: boolean;
  };
  display: {
    theme: 'light' | 'dark' | 'auto';
    language: string;
    fontSize: 'small' | 'medium' | 'large';
  };
}

const userSettingsVar = makeVar<UserSettings>({
  notifications: {
    email: true,
    push: true,
    sms: false,
  },
  privacy: {
    profileVisible: true,
    activityTracking: false,
  },
  display: {
    theme: 'auto',
    language: 'ja',
    fontSize: 'medium',
  },
});

// 設定を更新するヘルパー関数
export const updateUserSettings = (
  category: keyof UserSettings,
  updates: Partial<UserSettings[keyof UserSettings]>
) => {
  const current = userSettingsVar();
  userSettingsVar({
    ...current,
    [category]: {
      ...current[category],
      ...updates,
    },
  });
};

Local-only Fields の使い方

Local-only Fields は、GraphQL のスキーマにローカル専用のフィールドを追加する機能です。サーバーから取得したデータと、クライアント側で管理する状態を統一的に扱うことができるようになります。

まずは、クライアント側のスキーマ拡張の定義方法です。

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

// ローカル専用フィールドを含むクエリ
const GET_PRODUCTS_WITH_LOCAL_STATE = gql`
  query GetProductsWithLocalState {
    products {
      id
      name
      price
      category
      # ローカル専用フィールド
      isInCart @client
      isFavorite @client
      viewCount @client
    }
  }
`;

// ローカル状態の初期値設定
const cache = new InMemoryCache({
  typePolicies: {
    Product: {
      fields: {
        isInCart: {
          read() {
            return false; // デフォルト値
          },
        },
        isFavorite: {
          read() {
            return false; // デフォルト値
          },
        },
        viewCount: {
          read() {
            return 0; // デフォルト値
          },
        },
      },
    },
  },
});

Local-only Fields の更新と読み取りを行うコンポーネントの例です。

typescriptimport React from 'react';
import { useQuery, useMutation, gql } from '@apollo/client';

// ローカル状態更新のMutation
const UPDATE_PRODUCT_LOCAL_STATE = gql`
  mutation UpdateProductLocalState(
    $id: ID!
    $isInCart: Boolean!
    $isFavorite: Boolean!
  ) {
    updateProductLocalState(
      id: $id
      isInCart: $isInCart
      isFavorite: $isFavorite
    ) @client
  }
`;

const ProductCard = ({ productId }) => {
  const { data, loading } = useQuery(
    GET_PRODUCTS_WITH_LOCAL_STATE,
    {
      variables: { id: productId },
    }
  );

  const [updateLocalState] = useMutation(
    UPDATE_PRODUCT_LOCAL_STATE
  );

  if (loading) return <div>読み込み中...</div>;

  const product = data.products.find(
    (p) => p.id === productId
  );

  const toggleCart = () => {
    updateLocalState({
      variables: {
        id: productId,
        isInCart: !product.isInCart,
        isFavorite: product.isFavorite,
      },
    });
  };

  const toggleFavorite = () => {
    updateLocalState({
      variables: {
        id: productId,
        isInCart: product.isInCart,
        isFavorite: !product.isFavorite,
      },
    });
  };

  return (
    <div className='product-card'>
      <h3>{product.name}</h3>
      <p>価格: ¥{product.price}</p>
      <div className='actions'>
        <button
          onClick={toggleCart}
          className={product.isInCart ? 'in-cart' : ''}
        >
          {product.isInCart
            ? 'カートから削除'
            : 'カートに追加'}
        </button>
        <button
          onClick={toggleFavorite}
          className={product.isFavorite ? 'favorite' : ''}
        >
          {product.isFavorite ? '❤️' : '🤍'}
        </button>
      </div>
      <small>閲覧回数: {product.viewCount}</small>
    </div>
  );
};

クライアント側 Schema 拡張

より複雑なローカル状態管理には、クライアント側でのスキーマ拡張とリゾルバーの定義が有効です。これにより、ローカル状態に対しても GraphQL のクエリと同じインターフェースでアクセスできるようになります。

クライアントスキーマとリゾルバーの定義例をご紹介いたします。

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

// クライアント側のスキーマ拡張
const typeDefs = gql`
  extend type Query {
    currentUser: User
    cartSummary: CartSummary
    uiState: UIState
  }

  extend type Mutation {
    setCurrentUser(user: UserInput): User
    updateUIState(state: UIStateInput): UIState
    clearCart: Boolean
  }

  type User {
    id: ID!
    name: String!
    email: String!
    isLoggedIn: Boolean!
  }

  type CartSummary {
    itemCount: Int!
    totalPrice: Float!
    items: [CartItem!]!
  }

  type CartItem {
    id: ID!
    name: String!
    price: Float!
    quantity: Int!
  }

  type UIState {
    sidebarOpen: Boolean!
    currentPage: String!
    loading: Boolean!
  }

  input UserInput {
    id: ID!
    name: String!
    email: String!
    isLoggedIn: Boolean!
  }

  input UIStateInput {
    sidebarOpen: Boolean
    currentPage: String
    loading: Boolean
  }
`;

対応するリゾルバーの実装です。

typescript// ローカル状態変数の定義
const currentUserVar = makeVar(null);
const uiStateVar = makeVar({
  sidebarOpen: false,
  currentPage: 'home',
  loading: false,
});

// リゾルバーの定義
const resolvers = {
  Query: {
    currentUser: () => currentUserVar(),

    cartSummary: () => {
      const items = cartItemsVar();
      return {
        itemCount: items.reduce(
          (sum, item) => sum + item.quantity,
          0
        ),
        totalPrice: items.reduce(
          (sum, item) => sum + item.price * item.quantity,
          0
        ),
        items: items,
        __typename: 'CartSummary',
      };
    },

    uiState: () => ({
      ...uiStateVar(),
      __typename: 'UIState',
    }),
  },

  Mutation: {
    setCurrentUser: (_, { user }) => {
      currentUserVar(user);
      return user;
    },

    updateUIState: (_, { state }) => {
      const current = uiStateVar();
      const updated = { ...current, ...state };
      uiStateVar(updated);
      return updated;
    },

    clearCart: () => {
      cartItemsVar([]);
      return true;
    },
  },
};

// Apollo Client の設定
const client = new ApolloClient({
  cache,
  typeDefs,
  resolvers,
  // その他の設定...
});

使い分けの判断基準

Cache とローカル状態の適切な使い分けは、Apollo Client を効果的に活用するための重要なスキルです。データの性質や要件を正確に分析し、最適な手法を選択することで、保守性の高いアプリケーションを構築できるでしょう。

Cache を使うべき場面

Apollo Client の Cache は、サーバーから取得したデータを効率的に管理する強力な仕組みです。正規化されたストアにより、関連するデータの同期が自動的に行われ、アプリケーション全体で一貫性のあるデータ状態を保持できます。

Cache の使用が適している具体的な場面を図で整理いたします。

mermaidflowchart TD
    server_data[サーバーデータ] --> cache_decision{Cacheを使用すべき?}
    cache_decision -->|Yes| cache_scenarios[Cache適用場面]
    cache_decision -->|No| local_scenarios[ローカル状態適用場面]

    cache_scenarios --> user_info[ユーザー情報]
    cache_scenarios --> product_list[商品リスト]
    cache_scenarios --> article_data[記事データ]
    cache_scenarios --> api_response[API レスポンス]

    local_scenarios --> ui_state[UI 状態]
    local_scenarios --> form_data[フォームデータ]
    local_scenarios --> temp_state[一時的な状態]

Cache を使用することで効果が最大化される場面の詳細です。

typescript// ユーザー情報の管理例
const GET_USER_PROFILE = gql`
  query GetUserProfile($userId: ID!) {
    user(id: $userId) {
      id
      name
      email
      avatar
      posts {
        id
        title
        createdAt
      }
      followers {
        id
        name
      }
    }
  }
`;

// 関連データの自動同期を活用
const UserProfile = ({ userId }) => {
  const { data: userData } = useQuery(GET_USER_PROFILE, {
    variables: { userId },
    fetchPolicy: 'cache-first', // Cache 優先で高速表示
  });

  // ユーザーの投稿を更新した際、Cache 内の関連データが自動更新
  const [updatePost] = useMutation(UPDATE_POST);

  return (
    <div>
      <h2>{userData?.user.name}</h2>
      <img src={userData?.user.avatar} alt='アバター' />
      <PostList posts={userData?.user.posts} />
    </div>
  );
};

Cache が適している場面を表で整理いたします。

#データの特徴Cache 使用の利点具体例
1サーバーから取得自動正規化と同期ユーザープロフィール
2複数箇所で参照一元的なデータ管理商品情報
3関連データあり関係性の自動保持ブログ記事とコメント
4更新頻度が低い効率的なキャッシュ設定情報
5データサイズが大きいネットワーク負荷軽減画像メタデータ

ローカル状態を使うべき場面

ローカル状態管理は、UI の表示状態やユーザーの操作に関連する一時的なデータに最適です。サーバーに保存する必要がなく、コンポーネント間で状態を共有したい場合に威力を発揮します。

ローカル状態が適している具体的な実装例をご紹介いたします。

typescriptimport { makeVar, useReactiveVar } from '@apollo/client';

// モーダル表示状態の管理
const modalStateVar = makeVar({
  isOpen: false,
  type: null,
  data: null,
});

// フィルター条件の管理
const searchFiltersVar = makeVar({
  category: 'all',
  priceRange: [0, 10000],
  sortBy: 'popularity',
  inStockOnly: false,
});

// フォームの入力状態管理
const formStateVar = makeVar({
  step: 1,
  data: {},
  errors: {},
  isSubmitting: false,
});

// 各状態を管理するコンポーネント
const SearchFilters = () => {
  const filters = useReactiveVar(searchFiltersVar);

  const updateFilter = (key, value) => {
    const currentFilters = searchFiltersVar();
    searchFiltersVar({
      ...currentFilters,
      [key]: value,
    });
  };

  return (
    <div className='search-filters'>
      <select
        value={filters.category}
        onChange={(e) =>
          updateFilter('category', e.target.value)
        }
      >
        <option value='all'>すべてのカテゴリ</option>
        <option value='electronics'>電化製品</option>
        <option value='clothing'>衣料品</option>
      </select>

      <div className='price-range'>
        <input
          type='range'
          min={0}
          max={50000}
          value={filters.priceRange[1]}
          onChange={(e) =>
            updateFilter('priceRange', [
              0,
              parseInt(e.target.value),
            ])
          }
        />
        <span>
          価格上限: ¥
          {filters.priceRange[1].toLocaleString()}
        </span>
      </div>

      <label>
        <input
          type='checkbox'
          checked={filters.inStockOnly}
          onChange={(e) =>
            updateFilter('inStockOnly', e.target.checked)
          }
        />
        在庫ありのみ表示
      </label>
    </div>
  );
};

複雑なフォーム状態の管理例です。

typescript// 多段階フォームの状態管理
interface FormData {
  personalInfo: {
    name: string;
    email: string;
    phone: string;
  };
  shippingInfo: {
    address: string;
    city: string;
    zipCode: string;
  };
  paymentInfo: {
    method: 'card' | 'bank' | 'cash';
    cardNumber?: string;
    expiryDate?: string;
  };
}

const multiStepFormVar = makeVar<{
  currentStep: number;
  data: Partial<FormData>;
  errors: Record<string, string>;
  isValid: boolean;
}>({
  currentStep: 1,
  data: {},
  errors: {},
  isValid: false,
});

// フォーム状態管理のヘルパー関数
export const FormStateManager = {
  updateData: (step: keyof FormData, data: any) => {
    const current = multiStepFormVar();
    multiStepFormVar({
      ...current,
      data: {
        ...current.data,
        [step]: {
          ...current.data[step],
          ...data,
        },
      },
    });
  },

  nextStep: () => {
    const current = multiStepFormVar();
    if (current.currentStep < 3) {
      multiStepFormVar({
        ...current,
        currentStep: current.currentStep + 1,
      });
    }
  },

  previousStep: () => {
    const current = multiStepFormVar();
    if (current.currentStep > 1) {
      multiStepFormVar({
        ...current,
        currentStep: current.currentStep - 1,
      });
    }
  },

  reset: () => {
    multiStepFormVar({
      currentStep: 1,
      data: {},
      errors: {},
      isValid: false,
    });
  },
};

複合パターンの設計

実際のアプリケーション開発では、Cache とローカル状態を組み合わせた複合パターンが必要になることが多々あります。ユーザー認証システムやショッピングカート機能など、サーバーデータと UI 状態の両方を扱う場面で威力を発揮します。

認証状態を管理する複合パターンの実装例です。

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

// サーバーから取得するユーザーデータのクエリ
const GET_CURRENT_USER = gql`
  query GetCurrentUser {
    currentUser {
      id
      name
      email
      role
      preferences {
        theme
        language
      }
    }
  }
`;

// ログイン用のMutation
const LOGIN_MUTATION = gql`
  mutation Login($email: String!, $password: String!) {
    login(email: $email, password: $password) {
      token
      user {
        id
        name
        email
        role
      }
    }
  }
`;

// ローカル状態: 認証UI の状態管理
const authUIStateVar = makeVar({
  isLoginModalOpen: false,
  isLoading: false,
  loginError: null,
  rememberMe: false,
});

// 認証状態を統合管理するカスタムフック
export const useAuth = () => {
  const authUIState = useReactiveVar(authUIStateVar);

  // サーバーからユーザー情報を取得(Cache を活用)
  const {
    data: userData,
    loading: userLoading,
    refetch,
  } = useQuery(GET_CURRENT_USER, {
    errorPolicy: 'ignore', // 認証エラーを無視
  });

  // ログインMutation
  const [loginMutation] = useMutation(LOGIN_MUTATION, {
    onCompleted: (data) => {
      // トークンをローカルストレージに保存
      localStorage.setItem('authToken', data.login.token);

      // UI 状態をリセット
      authUIStateVar({
        ...authUIStateVar(),
        isLoginModalOpen: false,
        isLoading: false,
        loginError: null,
      });

      // ユーザーデータを再取得
      refetch();
    },

    onError: (error) => {
      authUIStateVar({
        ...authUIStateVar(),
        isLoading: false,
        loginError: error.message,
      });
    },
  });

  const login = async (email: string, password: string) => {
    authUIStateVar({
      ...authUIStateVar(),
      isLoading: true,
      loginError: null,
    });

    try {
      await loginMutation({
        variables: { email, password },
      });
    } catch (error) {
      // エラーは onError で処理される
    }
  };

  const logout = () => {
    localStorage.removeItem('authToken');

    // Cache をクリア
    client.resetStore();

    // UI 状態をリセット
    authUIStateVar({
      isLoginModalOpen: false,
      isLoading: false,
      loginError: null,
      rememberMe: false,
    });
  };

  const openLoginModal = () => {
    authUIStateVar({
      ...authUIStateVar(),
      isLoginModalOpen: true,
      loginError: null,
    });
  };

  const closeLoginModal = () => {
    authUIStateVar({
      ...authUIStateVar(),
      isLoginModalOpen: false,
      loginError: null,
    });
  };

  return {
    // サーバーデータ(Cache)
    user: userData?.currentUser,
    isAuthenticated: !!userData?.currentUser,
    userLoading,

    // ローカル状態
    authUIState,

    // アクション
    login,
    logout,
    openLoginModal,
    closeLoginModal,
  };
};

複合パターンの使用例を図で表現いたします。

mermaidsequenceDiagram
    participant UI as UI Component
    participant Hook as useAuth Hook
    participant Local as Local State
    participant Cache as Apollo Cache
    participant Server as GraphQL Server

    UI->>Hook: login(email, password)
    Hook->>Local: set loading = true
    Hook->>Server: LOGIN_MUTATION
    Server->>Cache: store user data
    Cache->>Hook: user data response
    Hook->>Local: set loading = false, close modal
    Hook->>UI: return updated state
    UI->>UI: re-render with user info

このように、サーバーから取得したユーザーデータは Cache で管理し、ログインモーダルの表示状態やローディング状態はローカル状態で管理することで、効率的で使いやすい認証システムを構築できます。

実践的な実装例

理論を実際のコードに落とし込むことで、Apollo Client の状態管理の真価が発揮されます。ここでは、よくある実装パターンを通じて、Cache とローカル状態を効果的に組み合わせた実用的なソリューションをご紹介いたします。

ユーザー認証状態の管理

認証システムは、多くの Web アプリケーションの核となる機能です。ユーザー情報はサーバーから取得してキャッシュし、ログイン状態や UI の制御はローカル状態で管理することで、堅牢で使いやすいシステムを構築できます。

完全な認証システムの実装例をご紹介いたします。

typescriptimport {
  makeVar,
  useReactiveVar,
  useMutation,
  useQuery,
  gql,
  ApolloError,
} from '@apollo/client';
import { useEffect } from 'react';

// GraphQL クエリとMutation の定義
const GET_CURRENT_USER = gql`
  query GetCurrentUser {
    currentUser {
      id
      name
      email
      avatar
      role
      lastLoginAt
      preferences {
        theme
        language
        emailNotifications
      }
    }
  }
`;

const LOGIN_MUTATION = gql`
  mutation Login(
    $email: String!
    $password: String!
    $rememberMe: Boolean
  ) {
    login(
      email: $email
      password: $password
      rememberMe: $rememberMe
    ) {
      success
      token
      refreshToken
      user {
        id
        name
        email
        avatar
        role
      }
      message
    }
  }
`;

const REFRESH_TOKEN_MUTATION = gql`
  mutation RefreshToken($refreshToken: String!) {
    refreshToken(refreshToken: $refreshToken) {
      success
      token
      refreshToken
    }
  }
`;

認証状態のローカル管理システムです。

typescript// 認証関連のローカル状態
const authStateVar = makeVar({
  isInitialized: false,
  isLoading: false,
  loginError: null,
  showLoginModal: false,
  rememberMe: false,
  sessionExpired: false,
});

// トークン管理のヘルパー関数
const TokenManager = {
  setTokens: (token: string, refreshToken?: string) => {
    localStorage.setItem('accessToken', token);
    if (refreshToken) {
      localStorage.setItem('refreshToken', refreshToken);
    }
  },

  getTokens: () => ({
    accessToken: localStorage.getItem('accessToken'),
    refreshToken: localStorage.getItem('refreshToken'),
  }),

  clearTokens: () => {
    localStorage.removeItem('accessToken');
    localStorage.removeItem('refreshToken');
  },

  hasValidTokens: () => {
    const tokens = TokenManager.getTokens();
    return !!(tokens.accessToken && tokens.refreshToken);
  },
};

// 統合認証フック
export const useAuth = () => {
  const authState = useReactiveVar(authStateVar);

  // ユーザーデータの取得(Cache 活用)
  const {
    data: userData,
    loading: userLoading,
    refetch,
    error,
  } = useQuery(GET_CURRENT_USER, {
    skip: !TokenManager.hasValidTokens(),
    errorPolicy: 'all',
    onCompleted: () => {
      authStateVar({
        ...authStateVar(),
        isInitialized: true,
      });
    },
    onError: (error: ApolloError) => {
      if (
        error.networkError &&
        error.networkError.statusCode === 401
      ) {
        // トークンが無効の場合
        authStateVar({
          ...authStateVar(),
          sessionExpired: true,
          isInitialized: true,
        });
      }
    },
  });

  // ログインMutation
  const [loginMutation] = useMutation(LOGIN_MUTATION, {
    onCompleted: (data) => {
      if (data.login.success) {
        TokenManager.setTokens(
          data.login.token,
          data.login.refreshToken
        );

        authStateVar({
          ...authStateVar(),
          isLoading: false,
          showLoginModal: false,
          loginError: null,
          sessionExpired: false,
        });

        refetch();
      } else {
        authStateVar({
          ...authStateVar(),
          isLoading: false,
          loginError: data.login.message,
        });
      }
    },

    onError: (error) => {
      authStateVar({
        ...authStateVar(),
        isLoading: false,
        loginError: error.message,
      });
    },
  });

  // トークン更新Mutation
  const [refreshTokenMutation] = useMutation(
    REFRESH_TOKEN_MUTATION
  );

  // 初期化処理
  useEffect(() => {
    if (
      !authState.isInitialized &&
      TokenManager.hasValidTokens()
    ) {
      // アプリ起動時にユーザー情報を取得
      refetch();
    }
  }, [authState.isInitialized, refetch]);

  // 認証アクション
  const login = async (email: string, password: string) => {
    authStateVar({
      ...authStateVar(),
      isLoading: true,
      loginError: null,
    });

    await loginMutation({
      variables: {
        email,
        password,
        rememberMe: authState.rememberMe,
      },
    });
  };

  const logout = async () => {
    TokenManager.clearTokens();

    // Apollo Cache を完全にリセット
    await client.resetStore();

    authStateVar({
      isInitialized: true,
      isLoading: false,
      loginError: null,
      showLoginModal: false,
      rememberMe: false,
      sessionExpired: false,
    });
  };

  const refreshAccessToken = async () => {
    const { refreshToken } = TokenManager.getTokens();

    if (!refreshToken) {
      logout();
      return false;
    }

    try {
      const { data } = await refreshTokenMutation({
        variables: { refreshToken },
      });

      if (data.refreshToken.success) {
        TokenManager.setTokens(
          data.refreshToken.token,
          data.refreshToken.refreshToken
        );
        return true;
      }
    } catch (error) {
      console.error('Token refresh failed:', error);
    }

    logout();
    return false;
  };

  return {
    // サーバーデータ(Cache)
    user: userData?.currentUser,
    isAuthenticated: !!userData?.currentUser,
    userLoading,

    // ローカル状態
    authState,

    // 計算されたプロパティ
    isReady: authState.isInitialized && !userLoading,

    // アクション
    login,
    logout,
    refreshAccessToken,

    // UI 制御
    showLoginModal: () =>
      authStateVar({
        ...authStateVar(),
        showLoginModal: true,
        loginError: null,
      }),

    hideLoginModal: () =>
      authStateVar({
        ...authStateVar(),
        showLoginModal: false,
        loginError: null,
      }),

    setRememberMe: (value: boolean) =>
      authStateVar({
        ...authStateVar(),
        rememberMe: value,
      }),
  };
};

ショッピングカート機能

E コマースアプリケーションの心臓部であるショッピングカート機能も、Cache とローカル状態の組み合わせで効率的に実装できます。商品情報は Cache で管理し、カート状態はローカルで管理することで、スムーズなユーザー体験を実現できるでしょう。

ショッピングカートの完全な実装例です。

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

// 商品データ取得のクエリ
const GET_PRODUCTS = gql`
  query GetProducts($ids: [ID!]) {
    products(ids: $ids) {
      id
      name
      price
      image
      inStock
      maxQuantity
      category
      discountPrice
    }
  }
`;

// カート更新のMutation(サーバー同期用)
const SYNC_CART_MUTATION = gql`
  mutation SyncCart($items: [CartItemInput!]!) {
    syncCart(items: $items) {
      success
      message
    }
  }
`;

// カートアイテムの型定義
interface CartItem {
  productId: string;
  quantity: number;
  addedAt: number;
  selectedOptions?: Record<string, string>;
}

interface CartSummary {
  itemCount: number;
  totalAmount: number;
  discountAmount: number;
  shippingCost: number;
  finalAmount: number;
}

// カート状態の管理
const cartItemsVar = makeVar<CartItem[]>([]);
const cartUIStateVar = makeVar({
  isOpen: false,
  isLoading: false,
  lastSyncAt: 0,
  showCheckoutModal: false,
});

// ローカルストレージとの同期
const CartStorage = {
  save: (items: CartItem[]) => {
    localStorage.setItem(
      'cart_items',
      JSON.stringify(items)
    );
    localStorage.setItem(
      'cart_updated_at',
      Date.now().toString()
    );
  },

  load: (): CartItem[] => {
    try {
      const stored = localStorage.getItem('cart_items');
      return stored ? JSON.parse(stored) : [];
    } catch {
      return [];
    }
  },

  clear: () => {
    localStorage.removeItem('cart_items');
    localStorage.removeItem('cart_updated_at');
  },
};

// ショッピングカート管理フック
export const useShoppingCart = () => {
  const cartItems = useReactiveVar(cartItemsVar);
  const cartUIState = useReactiveVar(cartUIStateVar);

  // カート内商品の詳細データを取得
  const productIds = cartItems.map(
    (item) => item.productId
  );
  const { data: productsData, loading: productsLoading } =
    useQuery(GET_PRODUCTS, {
      variables: { ids: productIds },
      skip: productIds.length === 0,
      fetchPolicy: 'cache-first',
    });

  // サーバーとの同期
  const [syncCartMutation] = useMutation(
    SYNC_CART_MUTATION
  );

  // 初期化(ローカルストレージから復元)
  useEffect(() => {
    const storedItems = CartStorage.load();
    if (storedItems.length > 0) {
      cartItemsVar(storedItems);
    }
  }, []);

  // カート内容の計算
  const cartSummary: CartSummary = useMemo(() => {
    if (!productsData) {
      return {
        itemCount: 0,
        totalAmount: 0,
        discountAmount: 0,
        shippingCost: 0,
        finalAmount: 0,
      };
    }

    const items = cartItems
      .map((cartItem) => {
        const product = productsData.products.find(
          (p) => p.id === cartItem.productId
        );
        return { cartItem, product };
      })
      .filter(({ product }) => product);

    const itemCount = items.reduce(
      (sum, { cartItem }) => sum + cartItem.quantity,
      0
    );
    const totalAmount = items.reduce(
      (sum, { cartItem, product }) => {
        const price =
          product.discountPrice || product.price;
        return sum + price * cartItem.quantity;
      },
      0
    );

    const discountAmount = items.reduce(
      (sum, { cartItem, product }) => {
        if (product.discountPrice) {
          const discount =
            (product.price - product.discountPrice) *
            cartItem.quantity;
          return sum + discount;
        }
        return sum;
      },
      0
    );

    const shippingCost = totalAmount >= 5000 ? 0 : 500; // 5000円以上で送料無料
    const finalAmount = totalAmount + shippingCost;

    return {
      itemCount,
      totalAmount,
      discountAmount,
      shippingCost,
      finalAmount,
    };
  }, [cartItems, productsData]);

  // カート操作関数
  const addToCart = (
    productId: string,
    quantity: number = 1,
    options?: Record<string, string>
  ) => {
    const currentItems = cartItemsVar();
    const existingItemIndex = currentItems.findIndex(
      (item) =>
        item.productId === productId &&
        JSON.stringify(item.selectedOptions) ===
          JSON.stringify(options)
    );

    let updatedItems: CartItem[];

    if (existingItemIndex >= 0) {
      // 既存アイテムの数量更新
      updatedItems = currentItems.map((item, index) =>
        index === existingItemIndex
          ? { ...item, quantity: item.quantity + quantity }
          : item
      );
    } else {
      // 新しいアイテムを追加
      const newItem: CartItem = {
        productId,
        quantity,
        addedAt: Date.now(),
        selectedOptions: options,
      };
      updatedItems = [...currentItems, newItem];
    }

    cartItemsVar(updatedItems);
    CartStorage.save(updatedItems);
  };

  const removeFromCart = (
    productId: string,
    options?: Record<string, string>
  ) => {
    const currentItems = cartItemsVar();
    const updatedItems = currentItems.filter(
      (item) =>
        !(
          item.productId === productId &&
          JSON.stringify(item.selectedOptions) ===
            JSON.stringify(options)
        )
    );

    cartItemsVar(updatedItems);
    CartStorage.save(updatedItems);
  };

  const updateQuantity = (
    productId: string,
    newQuantity: number,
    options?: Record<string, string>
  ) => {
    if (newQuantity <= 0) {
      removeFromCart(productId, options);
      return;
    }

    const currentItems = cartItemsVar();
    const updatedItems = currentItems.map((item) => {
      if (
        item.productId === productId &&
        JSON.stringify(item.selectedOptions) ===
          JSON.stringify(options)
      ) {
        return { ...item, quantity: newQuantity };
      }
      return item;
    });

    cartItemsVar(updatedItems);
    CartStorage.save(updatedItems);
  };

  const clearCart = () => {
    cartItemsVar([]);
    CartStorage.clear();
  };

  // サーバーとの同期
  const syncWithServer = async () => {
    try {
      cartUIStateVar({
        ...cartUIStateVar(),
        isLoading: true,
      });

      const items = cartItems.map((item) => ({
        productId: item.productId,
        quantity: item.quantity,
        selectedOptions: item.selectedOptions,
      }));

      await syncCartMutation({ variables: { items } });

      cartUIStateVar({
        ...cartUIStateVar(),
        isLoading: false,
        lastSyncAt: Date.now(),
      });
    } catch (error) {
      cartUIStateVar({
        ...cartUIStateVar(),
        isLoading: false,
      });

      console.error('Cart sync failed:', error);
    }
  };

  // UI 制御関数
  const toggleCart = () => {
    cartUIStateVar({
      ...cartUIStateVar(),
      isOpen: !cartUIState.isOpen,
    });
  };

  const openCheckout = () => {
    cartUIStateVar({
      ...cartUIStateVar(),
      showCheckoutModal: true,
    });
  };

  const closeCheckout = () => {
    cartUIStateVar({
      ...cartUIStateVar(),
      showCheckoutModal: false,
    });
  };

  return {
    // カートデータ
    cartItems,
    cartSummary,
    productsLoading,

    // UI 状態
    cartUIState,

    // カート操作
    addToCart,
    removeFromCart,
    updateQuantity,
    clearCart,

    // サーバー同期
    syncWithServer,

    // UI 制御
    toggleCart,
    openCheckout,
    closeCheckout,

    // ヘルパー関数
    getCartItemCount: () => cartSummary.itemCount,
    getTotalAmount: () => cartSummary.finalAmount,
    isInCart: (
      productId: string,
      options?: Record<string, string>
    ) =>
      cartItems.some(
        (item) =>
          item.productId === productId &&
          JSON.stringify(item.selectedOptions) ===
            JSON.stringify(options)
      ),
  };
};

フィルタリング・検索状態

検索機能とフィルタリング機能は、多くのアプリケーションで必須の機能です。検索結果はサーバーから取得してキャッシュし、フィルター条件や UI の状態はローカルで管理することで、レスポンシブで使いやすい検索体験を提供できます。

高度な検索・フィルタリングシステムの実装例をご紹介いたします。

typescriptimport {
  makeVar,
  useReactiveVar,
  useQuery,
  useLazyQuery,
  gql,
} from '@apollo/client';
import { useMemo, useEffect, useCallback } from 'react';
import { debounce } from 'lodash';

// 検索とフィルタリングのクエリ
const SEARCH_PRODUCTS = gql`
  query SearchProducts(
    $query: String
    $filters: ProductFiltersInput
    $sort: ProductSortInput
    $pagination: PaginationInput
  ) {
    searchProducts(
      query: $query
      filters: $filters
      sort: $sort
      pagination: $pagination
    ) {
      totalCount
      hasMore
      products {
        id
        name
        price
        discountPrice
        image
        category
        brand
        rating
        reviewCount
        inStock
        tags
      }
      facets {
        categories {
          name
          count
        }
        brands {
          name
          count
        }
        priceRanges {
          min
          max
          count
        }
      }
    }
  }
`;

// 検索候補の取得
const GET_SEARCH_SUGGESTIONS = gql`
  query GetSearchSuggestions($query: String!) {
    searchSuggestions(query: $query) {
      term
      type
      count
    }
  }
`;

// フィルター・検索状態の型定義
interface SearchFilters {
  categories: string[];
  brands: string[];
  priceRange: [number, number];
  inStockOnly: boolean;
  minRating: number;
  tags: string[];
}

interface SortOptions {
  field:
    | 'relevance'
    | 'price'
    | 'rating'
    | 'name'
    | 'newest';
  direction: 'asc' | 'desc';
}

interface SearchUIState {
  query: string;
  isSearching: false;
  showFilters: boolean;
  showSuggestions: boolean;
  viewMode: 'grid' | 'list';
  currentPage: number;
  itemsPerPage: number;
}

// 検索・フィルター状態の管理
const searchFiltersVar = makeVar<SearchFilters>({
  categories: [],
  brands: [],
  priceRange: [0, 100000],
  inStockOnly: false,
  minRating: 0,
  tags: [],
});

const sortOptionsVar = makeVar<SortOptions>({
  field: 'relevance',
  direction: 'desc',
});

const searchUIStateVar = makeVar<SearchUIState>({
  query: '',
  isSearching: false,
  showFilters: false,
  showSuggestions: false,
  viewMode: 'grid',
  currentPage: 1,
  itemsPerPage: 20,
});

// 検索・フィルタリング管理フック
export const useProductSearch = () => {
  const searchFilters = useReactiveVar(searchFiltersVar);
  const sortOptions = useReactiveVar(sortOptionsVar);
  const searchUIState = useReactiveVar(searchUIStateVar);

  // 検索の実行
  const [
    executeSearch,
    { data: searchData, loading: searchLoading, error },
  ] = useLazyQuery(SEARCH_PRODUCTS, {
    fetchPolicy: 'cache-and-network',
    errorPolicy: 'all',
  });

  // 検索候補の取得
  const [
    getSuggestions,
    { data: suggestionsData, loading: suggestionsLoading },
  ] = useLazyQuery(GET_SEARCH_SUGGESTIONS, {
    fetchPolicy: 'cache-first',
  });

  // デバウンスされた検索実行
  const debouncedExecuteSearch = useCallback(
    debounce(() => {
      if (searchUIState.query.trim()) {
        executeSearch({
          variables: {
            query: searchUIState.query,
            filters: {
              categories: searchFilters.categories,
              brands: searchFilters.brands,
              priceMin: searchFilters.priceRange[0],
              priceMax: searchFilters.priceRange[1],
              inStockOnly: searchFilters.inStockOnly,
              minRating: searchFilters.minRating,
              tags: searchFilters.tags,
            },
            sort: {
              field: sortOptions.field,
              direction: sortOptions.direction,
            },
            pagination: {
              page: searchUIState.currentPage,
              limit: searchUIState.itemsPerPage,
            },
          },
        });
      }
    }, 300),
    [
      searchUIState.query,
      searchFilters,
      sortOptions,
      searchUIState.currentPage,
    ]
  );

  // デバウンスされた検索候補取得
  const debouncedGetSuggestions = useCallback(
    debounce((query: string) => {
      if (query.trim().length >= 2) {
        getSuggestions({ variables: { query } });
      }
    }, 150),
    []
  );

  // クエリ変更時の検索実行
  useEffect(() => {
    if (searchUIState.query.trim()) {
      debouncedExecuteSearch();
    }

    return () => {
      debouncedExecuteSearch.cancel();
    };
  }, [
    searchUIState.query,
    searchFilters,
    sortOptions,
    searchUIState.currentPage,
    debouncedExecuteSearch,
  ]);

  // 検索候補の取得
  useEffect(() => {
    if (
      searchUIState.query.trim() &&
      searchUIState.showSuggestions
    ) {
      debouncedGetSuggestions(searchUIState.query);
    }

    return () => {
      debouncedGetSuggestions.cancel();
    };
  }, [
    searchUIState.query,
    searchUIState.showSuggestions,
    debouncedGetSuggestions,
  ]);

  // 検索・フィルター操作関数
  const updateQuery = (newQuery: string) => {
    searchUIStateVar({
      ...searchUIState,
      query: newQuery,
      currentPage: 1, // クエリ変更時はページをリセット
    });
  };

  const updateFilters = (
    newFilters: Partial<SearchFilters>
  ) => {
    searchFiltersVar({
      ...searchFilters,
      ...newFilters,
    });

    searchUIStateVar({
      ...searchUIState,
      currentPage: 1, // フィルター変更時はページをリセット
    });
  };

  const updateSort = (newSort: Partial<SortOptions>) => {
    sortOptionsVar({
      ...sortOptions,
      ...newSort,
    });

    searchUIStateVar({
      ...searchUIState,
      currentPage: 1, // ソート変更時はページをリセット
    });
  };

  const clearFilters = () => {
    searchFiltersVar({
      categories: [],
      brands: [],
      priceRange: [0, 100000],
      inStockOnly: false,
      minRating: 0,
      tags: [],
    });
  };

  const clearSearch = () => {
    searchUIStateVar({
      ...searchUIState,
      query: '',
      currentPage: 1,
    });
  };

  // ページネーション
  const goToPage = (page: number) => {
    searchUIStateVar({
      ...searchUIState,
      currentPage: page,
    });
  };

  const nextPage = () => {
    if (searchData?.searchProducts.hasMore) {
      goToPage(searchUIState.currentPage + 1);
    }
  };

  const prevPage = () => {
    if (searchUIState.currentPage > 1) {
      goToPage(searchUIState.currentPage - 1);
    }
  };

  // UI 制御
  const toggleFilters = () => {
    searchUIStateVar({
      ...searchUIState,
      showFilters: !searchUIState.showFilters,
    });
  };

  const toggleViewMode = () => {
    const newMode =
      searchUIState.viewMode === 'grid' ? 'list' : 'grid';
    searchUIStateVar({
      ...searchUIState,
      viewMode: newMode,
    });
  };

  const showSuggestions = () => {
    searchUIStateVar({
      ...searchUIState,
      showSuggestions: true,
    });
  };

  const hideSuggestions = () => {
    searchUIStateVar({
      ...searchUIState,
      showSuggestions: false,
    });
  };

  // 計算されたプロパティ
  const hasActiveFilters = useMemo(() => {
    return (
      searchFilters.categories.length > 0 ||
      searchFilters.brands.length > 0 ||
      searchFilters.priceRange[0] > 0 ||
      searchFilters.priceRange[1] < 100000 ||
      searchFilters.inStockOnly ||
      searchFilters.minRating > 0 ||
      searchFilters.tags.length > 0
    );
  }, [searchFilters]);

  const searchResults =
    searchData?.searchProducts.products || [];
  const totalCount =
    searchData?.searchProducts.totalCount || 0;
  const hasMore =
    searchData?.searchProducts.hasMore || false;
  const facets = searchData?.searchProducts.facets;
  const suggestions =
    suggestionsData?.searchSuggestions || [];

  return {
    // 検索結果(Cache)
    searchResults,
    totalCount,
    hasMore,
    facets,
    suggestions,
    searchLoading,
    suggestionsLoading,
    error,

    // ローカル状態
    searchFilters,
    sortOptions,
    searchUIState,

    // 計算されたプロパティ
    hasActiveFilters,

    // 検索・フィルター操作
    updateQuery,
    updateFilters,
    updateSort,
    clearFilters,
    clearSearch,

    // ページネーション
    goToPage,
    nextPage,
    prevPage,

    // UI 制御
    toggleFilters,
    toggleViewMode,
    showSuggestions,
    hideSuggestions,

    // ヘルパー関数
    getFilteredResultCount: () => totalCount,
    getCurrentPageInfo: () => ({
      current: searchUIState.currentPage,
      total: Math.ceil(
        totalCount / searchUIState.itemsPerPage
      ),
      hasNext: hasMore,
      hasPrev: searchUIState.currentPage > 1,
    }),
  };
};

パフォーマンス最適化

Apollo Client のパフォーマンスを最大化するためには、Cache の効率的な運用とメモリ管理が欠かせません。適切な最適化により、アプリケーションの応答性を向上させ、ユーザー体験を大幅に改善することができるでしょう。

Cache の効率化

Cache の効率化は、Apollo Client のパフォーマンス向上の要となります。適切な設定と運用により、ネットワークリクエストを削減し、データアクセスを高速化できます。

効率的な Cache 設定の実装例をご紹介いたします。

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

// 最適化された Cache 設定
const optimizedCache = new InMemoryCache({
  // カスタムデータID生成で正規化を最適化
  dataIdFromObject: (object) => {
    // 標準的な ID フィールドを優先
    if (object.id && object.__typename) {
      return `${object.__typename}:${object.id}`;
    }

    // 複合キーを使用する場合
    if (
      object.__typename === 'UserPreference' &&
      object.userId &&
      object.key
    ) {
      return `UserPreference:${object.userId}:${object.key}`;
    }

    // デフォルトの ID 生成に委譲
    return defaultDataIdFromObject(object);
  },

  // 型ポリシーによる詳細制御
  typePolicies: {
    Query: {
      fields: {
        // 無限スクロール対応の最適化
        posts: {
          keyArgs: ['category', 'sortBy'],
          merge(
            existing = [],
            incoming,
            { args, readField }
          ) {
            const { offset = 0, limit = 10 } = args;

            // 既存データのコピーを作成
            const merged = existing.slice();

            // 新しいデータを適切な位置に挿入
            for (let i = 0; i < incoming.length; i++) {
              const index = offset + i;
              merged[index] = incoming[i];
            }

            return merged;
          },
        },

        // 検索結果の効率的なマージ
        searchResults: {
          keyArgs: ['query', 'filters'],
          merge(existing, incoming, { args }) {
            const { page = 1 } = args;

            if (page === 1) {
              // 新しい検索の場合は置き換え
              return incoming;
            }

            // ページネーションの場合は追加
            return {
              ...incoming,
              items: [
                ...(existing?.items || []),
                ...(incoming?.items || []),
              ],
            };
          },
        },
      },
    },

    Product: {
      fields: {
        // リアクティブな価格計算
        discountedPrice: {
          read(_, { readField }) {
            const price = readField('price');
            const discountRate =
              readField('discountRate') || 0;

            return price
              ? price * (1 - discountRate / 100)
              : null;
          },
        },

        // レビューの統計計算
        reviewStats: {
          read(_, { readField }) {
            const reviews = readField('reviews') || [];

            if (reviews.length === 0) {
              return { average: 0, count: 0 };
            }

            const sum = reviews.reduce((total, review) => {
              return total + readField('rating', review);
            }, 0);

            return {
              average: sum / reviews.length,
              count: reviews.length,
            };
          },
        },
      },
    },

    User: {
      fields: {
        // キャッシュされた友達リストの管理
        friends: {
          merge(existing = [], incoming = []) {
            // 重複を除去しながらマージ
            const existingIds = new Set(
              existing.map((friend) => friend.__ref)
            );
            const newFriends = incoming.filter(
              (friend) => !existingIds.has(friend.__ref)
            );

            return [...existing, ...newFriends];
          },
        },
      },
    },
  },

  // 可能な型の定義(Union/Interface 型の最適化)
  possibleTypes: {
    SearchResult: ['Product', 'Article', 'User'],
    Media: ['Image', 'Video'],
    Notification: [
      'CommentNotification',
      'LikeNotification',
      'MessageNotification',
    ],
  },
});

Fragment を使用したデータ取得の最適化例です。

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

// 再利用可能なFragment定義
const PRODUCT_CORE_FIELDS = gql`
  fragment ProductCoreFields on Product {
    id
    name
    price
    discountPrice
    image
    inStock
  }
`;

const PRODUCT_DETAIL_FIELDS = gql`
  fragment ProductDetailFields on Product {
    ...ProductCoreFields
    description
    category
    brand
    specifications
    images
    reviews {
      id
      rating
      comment
      user {
        name
        avatar
      }
    }
  }
`;

// 効率的なクエリ構成
const GET_PRODUCT_LIST = gql`
  ${PRODUCT_CORE_FIELDS}
  query GetProductList($category: String, $limit: Int) {
    products(category: $category, limit: $limit) {
      ...ProductCoreFields
    }
  }
`;

const GET_PRODUCT_DETAIL = gql`
  ${PRODUCT_DETAIL_FIELDS}
  query GetProductDetail($id: ID!) {
    product(id: $id) {
      ...ProductDetailFields
    }
  }
`;

// Fragment を使用したローカル読み取り
const ProductQuickView = ({ productId }) => {
  // Cache から Fragment データを直接読み取り
  const productData = useFragment({
    fragment: PRODUCT_CORE_FIELDS,
    from: {
      __typename: 'Product',
      id: productId,
    },
  });

  if (!productData) {
    return <div>商品データが見つかりません</div>;
  }

  return (
    <div className='product-quick-view'>
      <img src={productData.image} alt={productData.name} />
      <h3>{productData.name}</h3>
      <p>
        ¥{productData.discountPrice || productData.price}
      </p>
      <span
        className={
          productData.inStock ? 'in-stock' : 'out-of-stock'
        }
      >
        {productData.inStock ? '在庫あり' : '在庫なし'}
      </span>
    </div>
  );
};

メモリ使用量の削減

長時間稼働するアプリケーションでは、Cache のメモリ使用量を適切に管理することが重要です。不要なデータを定期的にクリーンアップし、メモリリークを防ぐことで、安定したパフォーマンスを維持できます。

メモリ管理の実装例をご紹介いたします。

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

// Cache サイズ管理クラス
class CacheManager {
  private client: ApolloClient<any>;
  private maxCacheSize: number;
  private cleanupInterval: number;
  private intervalId: NodeJS.Timeout | null = null;

  constructor(
    client: ApolloClient<any>,
    maxCacheSize = 50 * 1024 * 1024
  ) {
    this.client = client;
    this.maxCacheSize = maxCacheSize; // 50MB
    this.cleanupInterval = 5 * 60 * 1000; // 5分間隔
  }

  // Cache サイズの測定
  getCacheSize(): number {
    const cacheData = this.client.cache.extract();
    return JSON.stringify(cacheData).length * 2; // 概算バイト数
  }

  // 古いデータのクリーンアップ
  cleanupOldData(): void {
    const cache = this.client.cache as InMemoryCache;
    const now = Date.now();
    const maxAge = 30 * 60 * 1000; // 30分

    // 古いクエリ結果を削除
    cache.modify({
      fields: {
        products(existing, { readField }) {
          return existing.filter((productRef: any) => {
            const lastAccessed = readField(
              '__lastAccessed',
              productRef
            );
            return (
              !lastAccessed || now - lastAccessed < maxAge
            );
          });
        },

        searchResults(existing, { args }) {
          // 古い検索結果を削除
          const timestamp = args?.timestamp;
          return timestamp && now - timestamp < maxAge
            ? existing
            : undefined;
        },
      },
    });
  }

  // 使用頻度の低いデータの削除
  cleanupLowUsageData(): void {
    const cache = this.client.cache as InMemoryCache;

    cache.modify({
      fields: {
        // アクセス回数が少ないデータを削除
        articles(existing, { readField }) {
          return existing.filter((articleRef: any) => {
            const accessCount =
              readField('__accessCount', articleRef) || 0;
            return accessCount >= 2; // 2回以上アクセスされたもののみ保持
          });
        },
      },
    });
  }

  // 定期的なクリーンアップの開始
  startPeriodicCleanup(): void {
    this.intervalId = setInterval(() => {
      const currentSize = this.getCacheSize();

      console.log(
        `Cache size: ${(currentSize / 1024 / 1024).toFixed(
          2
        )}MB`
      );

      if (currentSize > this.maxCacheSize) {
        console.log(
          'Cache size exceeded limit, starting cleanup...'
        );

        this.cleanupOldData();
        this.cleanupLowUsageData();

        // 必要に応じて Cache を部分的にリセット
        if (this.getCacheSize() > this.maxCacheSize * 0.8) {
          this.client.cache.gc();
        }

        console.log(
          `Cache cleaned, new size: ${(
            this.getCacheSize() /
            1024 /
            1024
          ).toFixed(2)}MB`
        );
      }
    }, this.cleanupInterval);
  }

  // クリーンアップの停止
  stopPeriodicCleanup(): void {
    if (this.intervalId) {
      clearInterval(this.intervalId);
      this.intervalId = null;
    }
  }

  // 手動でのガベージコレクション実行
  forceGarbageCollection(): void {
    this.client.cache.gc();
  }

  // 特定の型のデータのみを削除
  evictDataByType(typename: string): void {
    const cache = this.client.cache as InMemoryCache;

    cache.modify({
      fields: {
        [typename.toLowerCase() + 's']: (existing) => {
          // 該当する型のデータを全て削除
          return [];
        },
      },
    });

    // 該当するオブジェクトを Cache から削除
    cache.gc();
  }
}

// アクセス追跡機能付きのクエリフック
export const useTrackedQuery = (
  query: DocumentNode,
  options: any
) => {
  const result = useQuery(query, options);

  useEffect(() => {
    if (result.data) {
      // アクセス時刻と回数を記録
      const cache = client.cache as InMemoryCache;

      // データにメタデータを追加
      Object.keys(result.data).forEach((key) => {
        const items = result.data[key];

        if (Array.isArray(items)) {
          items.forEach((item) => {
            if (item.id) {
              cache.writeFragment({
                id: `${item.__typename}:${item.id}`,
                fragment: gql`
                  fragment MetaData on ${item.__typename} {
                    __lastAccessed
                    __accessCount
                  }
                `,
                data: {
                  __lastAccessed: Date.now(),
                  __accessCount:
                    (item.__accessCount || 0) + 1,
                },
              });
            }
          });
        }
      });
    }
  }, [result.data]);

  return result;
};

// 使用例
const cacheManager = new CacheManager(client);
cacheManager.startPeriodicCleanup();

// アプリケーション終了時
// cacheManager.stopPeriodicCleanup();

ネットワークリクエストの最適化

ネットワークリクエストの最適化により、アプリケーションの応答性を向上させ、サーバー負荷を軽減できます。適切なバッチング、キャッシュ戦略、そしてプリフェッチングの実装が鍵となるでしょう。

ネットワーク最適化の実装例です。

typescriptimport {
  ApolloClient,
  from,
  split,
  createHttpLink,
  InMemoryCache,
} from '@apollo/client';
import { BatchHttpLink } from '@apollo/client/link/batch-http';
import { RetryLink } from '@apollo/client/link/retry';
import { onError } from '@apollo/client/link/error';
import { getMainDefinition } from '@apollo/client/utilities';

// バッチング機能付きの HTTP リンク
const batchHttpLink = new BatchHttpLink({
  uri: process.env.REACT_APP_GRAPHQL_ENDPOINT,
  batchMax: 10, // 最大10クエリをバッチ
  batchInterval: 20, // 20ms 待ってからバッチ実行
  batchKey: (operation) => {
    // 同じコンテキストのクエリをバッチング
    return operation.getContext().batchKey || 'default';
  },
});

// 通常の HTTP リンク
const httpLink = createHttpLink({
  uri: process.env.REACT_APP_GRAPHQL_ENDPOINT,
});

// リトライ機能
const retryLink = new RetryLink({
  delay: {
    initial: 300,
    max: Infinity,
    jitter: true,
  },
  attempts: {
    max: 5,
    retryIf: (error) => !!error,
  },
});

// エラーハンドリング
const errorLink = onError(
  ({ graphQLErrors, networkError, operation, forward }) => {
    if (graphQLErrors) {
      graphQLErrors.forEach(
        ({ message, locations, path }) => {
          console.error(
            `GraphQL error: Message: ${message}, Location: ${locations}, Path: ${path}`
          );
        }
      );
    }

    if (networkError) {
      console.error(`Network error: ${networkError}`);

      // 認証エラーの場合はトークンをクリア
      if (networkError.statusCode === 401) {
        localStorage.removeItem('authToken');
        // リダイレクト処理など
      }
    }
  }
);

// バッチング対象の判定
const isBatchable = (operation) => {
  const definition = getMainDefinition(operation.query);
  return (
    definition.kind === 'OperationDefinition' &&
    definition.operation === 'query' &&
    !operation.getContext().forceSingle
  );
};

// リンクの分岐設定
const splitLink = split(
  isBatchable,
  from([retryLink, errorLink, batchHttpLink]),
  from([retryLink, errorLink, httpLink])
);

// プリフェッチング機能
class PrefetchManager {
  private client: ApolloClient<any>;
  private prefetchQueue: Map<string, Promise<any>> =
    new Map();

  constructor(client: ApolloClient<any>) {
    this.client = client;
  }

  // 関連データの先読み
  async prefetchRelated(productId: string): Promise<void> {
    const cacheKey = `related-${productId}`;

    if (this.prefetchQueue.has(cacheKey)) {
      return this.prefetchQueue.get(cacheKey);
    }

    const prefetchPromise = this.client
      .query({
        query: gql`
          query PrefetchRelated($productId: ID!) {
            product(id: $productId) {
              related {
                id
                name
                price
                image
              }
            }
            recommendations(basedOn: $productId) {
              id
              name
              price
              image
            }
          }
        `,
        variables: { productId },
        fetchPolicy: 'cache-first',
      })
      .then(() => {
        this.prefetchQueue.delete(cacheKey);
      })
      .catch((error) => {
        console.error('Prefetch failed:', error);
        this.prefetchQueue.delete(cacheKey);
      });

    this.prefetchQueue.set(cacheKey, prefetchPromise);
    return prefetchPromise;
  }

  // ユーザーの行動パターンに基づく予測プリフェッチ
  async predictivePrefetch(userBehavior: {
    currentCategory: string;
    viewHistory: string[];
    searchQueries: string[];
  }): Promise<void> {
    // 現在のカテゴリの人気商品をプリフェッチ
    this.client.query({
      query: gql`
        query PrefetchPopular($category: String!) {
          popularProducts(category: $category, limit: 5) {
            id
            name
            price
            image
          }
        }
      `,
      variables: { category: userBehavior.currentCategory },
      fetchPolicy: 'cache-first',
    });

    // 類似商品をプリフェッチ
    if (userBehavior.viewHistory.length > 0) {
      const recentViewed = userBehavior.viewHistory.slice(
        0,
        3
      );

      this.client.query({
        query: gql`
          query PrefetchSimilar($productIds: [ID!]!) {
            similarProducts(
              basedOn: $productIds
              limit: 10
            ) {
              id
              name
              price
              image
            }
          }
        `,
        variables: { productIds: recentViewed },
        fetchPolicy: 'cache-first',
      });
    }
  }

  // インターセクションオブザーバーを使用した画面内アイテムのプリフェッチ
  observeForPrefetch(
    element: Element,
    productId: string
  ): void {
    const observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting) {
            // 画面に入ったらプリフェッチ実行
            this.prefetchRelated(productId);
            observer.unobserve(entry.target);
          }
        });
      },
      {
        rootMargin: '100px', // 画面に入る100px前からプリフェッチ
      }
    );

    observer.observe(element);
  }
}

// 最適化されたクエリフック
export const useOptimizedQuery = (
  query: DocumentNode,
  options: any = {}
) => {
  const [prefetchManager] = useState(
    () => new PrefetchManager(client)
  );

  // バッチング用のコンテキスト設定
  const optimizedOptions = {
    ...options,
    context: {
      ...options.context,
      batchKey: options.batchKey || 'default',
    },
  };

  const result = useQuery(query, optimizedOptions);

  // 関連データのプリフェッチ
  useEffect(() => {
    if (result.data && options.prefetchRelated) {
      const items = Object.values(result.data)[0] as any[];

      if (Array.isArray(items)) {
        // 最初の数個のアイテムの関連データをプリフェッチ
        items.slice(0, 3).forEach((item) => {
          if (item.id) {
            prefetchManager.prefetchRelated(item.id);
          }
        });
      }
    }
  }, [
    result.data,
    options.prefetchRelated,
    prefetchManager,
  ]);

  return result;
};

// Apollo Client の設定
export const client = new ApolloClient({
  link: splitLink,
  cache: optimizedCache,
  defaultOptions: {
    watchQuery: {
      fetchPolicy: 'cache-and-network',
      errorPolicy: 'all',
    },
    query: {
      fetchPolicy: 'cache-first',
      errorPolicy: 'all',
    },
  },
});

まとめ

Apollo Client の状態管理は、Cache とローカル状態という 2 つの強力な仕組みを適切に使い分けることで、その真価を発揮いたします。本記事では、基礎概念から実践的な活用法まで、段階的に理解を深めていただきました。

Cache 管理では、InMemoryCache の詳細設定からキャッシュポリシーの選択、効率的な更新タイミングの制御まで、サーバーデータを効果的に管理する手法をご紹介いたしました。正規化されたストアによる自動同期と、Fragment を使った効率的なデータアクセスにより、一貫性のあるデータ状態を保持できることをご確認いただけたでしょう。

ローカル状態管理においては、Reactive Variables による簡潔な状態共有から、Local-only Fields を使った統合的なデータ管理、さらにはクライアント側スキーマ拡張による高度な実装まで、UI 状態とユーザー操作を効率的に扱う方法をお示しいたしました。

使い分けの判断基準では、データの性質や要件に応じた最適な手法の選択方法を明確にし、複合パターンの設計により、実際のアプリケーション開発で必要となる柔軟なアーキテクチャをご提案いたしました。認証システム、ショッピングカート機能、検索・フィルタリング機能の実装例を通じて、理論を実践に活かす具体的な方法をお伝えできたと思います。

パフォーマンス最適化の章では、Cache の効率化、メモリ使用量の削減、ネットワークリクエストの最適化という 3 つの重要な観点から、Apollo Client の性能を最大限に引き出すテクニックをご紹介いたしました。これらの最適化により、ユーザー体験の向上と開発効率の改善を同時に実現できるでしょう。

Apollo Client の状態管理をマスターすることで、従来の状態管理ライブラリでは難しかった、サーバーとクライアントの状態を統合的に扱う現代的なアプリケーションの開発が可能になります。本記事でご紹介した手法を参考に、より効率的で保守性の高いコードを書いていただければ幸いです。

関連リンク