T-CREATOR

Apollo Client の Reactive Variables - GraphQL でグローバル状態管理

Apollo Client の Reactive Variables - GraphQL でグローバル状態管理

モダンな Web 開発において、GraphQL を活用したアプリケーションが増える中で、クライアント側の状態管理は重要な課題となっています。Apollo Client が提供する Reactive Variables は、この課題に対する革新的な解決策です。

従来の Redux や Context API といった複雑な状態管理ライブラリに代わって、GraphQL エコシステム内で統一された状態管理を実現できます。Apollo Client とシームレスに統合された Reactive Variables を使うことで、開発者はより効率的で保守性の高いアプリケーションを構築できるでしょう。

背景

GraphQL とクライアント状態管理の課題

GraphQL を採用した開発プロジェクトでは、サーバーから取得したデータとクライアント固有の状態を適切に管理する必要があります。

typescript// 従来のアプローチでは複数の状態管理が混在
const App = () => {
  // Apollo Clientによるサーバー状態
  const { data } = useQuery(GET_USERS);

  // Context APIによるクライアント状態
  const { theme, setTheme } = useContext(ThemeContext);

  // useStateによるローカル状態
  const [isModalOpen, setIsModalOpen] = useState(false);
};

このような状況では、データの流れが複雑になり、デバッグや保守が困難になってしまいます。GraphQL で管理されるサーバー状態と、クライアント独自の状態が異なる仕組みで管理されることで、開発者の認知負荷が増大します。

現代の Web アプリケーションでは、認証状態、UI 設定、フィルター条件など、多様なクライアント状態を適切に管理する必要があります。これらの状態が GraphQL キャッシュと統合されていないと、データの整合性やパフォーマンスに問題が生じる可能性があるのです。

従来の状態管理ライブラリとの比較

Redux や Zustand、Context API などの従来の状態管理ライブラリには、それぞれ特徴と課題があります。

ライブラリメリットデメリット
Redux予測可能な状態変更、優れた DevTools複雑な設定、大量のボイラープレート
Context APIReact に標準搭載、シンプルな設定パフォーマンス問題、プロバイダー地獄
Zustand軽量、TypeScript 親和性GraphQL との統合が別途必要
typescript// Reduxの複雑な設定例
const store = configureStore({
  reducer: {
    auth: authSlice.reducer,
    ui: uiSlice.reducer,
    apollo: apolloSlice.reducer, // Apollo Clientとの同期が必要
  },
});

// Context APIのプロバイダー地獄
function App() {
  return (
    <AuthProvider>
      <ThemeProvider>
        <LanguageProvider>
          <ApolloProvider client={client}>
            {/* 実際のアプリケーション */}
          </ApolloProvider>
        </LanguageProvider>
      </ThemeProvider>
    </AuthProvider>
  );
}

これらのライブラリを使用する場合、Apollo Client との連携には追加の実装が必要になります。また、異なる状態管理パターンが混在することで、コードベースの一貫性が損なわれる可能性もあります。

Apollo Client における状態管理の進化

Apollo Client は、GraphQL クライアントとしての機能だけでなく、包括的な状態管理ソリューションとして進化してきました。

typescript// Apollo Client 2.x時代のローカル状態管理
const GET_CART_ITEMS = gql`
  query GetCartItems {
    cartItems @client {
      id
      name
      price
    }
  }
`;

// Apollo Client 3.0でのReactive Variables
const cartItemsVar = makeVar([]);

const GET_CART_ITEMS = gql`
  query GetCartItems {
    cartItems @client
  }
`;

Apollo Client 3.0 で導入された Reactive Variables は、この進化の集大成です。GraphQL スキーマとの統合、型安全性の向上、そして直感的な API によって、開発者体験が大幅に改善されました。

バージョン 2.x 時代のローカルリゾルバーは設定が複雑でしたが、Reactive Variables では最小限のコードで状態管理を実現できます。

課題

Redux や Context API の複雑さ

従来の状態管理ライブラリは、規模の拡大とともに複雑さが増大する傾向があります。

typescript// Reduxでの認証状態管理の複雑さ
interface AuthState {
  user: User | null;
  isLoading: boolean;
  error: string | null;
}

const authSlice = createSlice({
  name: 'auth',
  initialState: {
    user: null,
    isLoading: false,
    error: null,
  } as AuthState,
  reducers: {
    loginStart: (state) => {
      state.isLoading = true;
      state.error = null;
    },
    loginSuccess: (state, action) => {
      state.isLoading = false;
      state.user = action.payload;
    },
    loginFailure: (state, action) => {
      state.isLoading = false;
      state.error = action.payload;
    },
  },
});

// アクションクリエーター、セレクター、ミドルウェアなども必要

このような複雑さは、以下の問題を引き起こします。開発者が新しい機能を追加する際に、多くのファイルを編集する必要があり、ミスが発生しやすくなります。また、テストの複雑さも増大し、品質保証が困難になってしまうのです。

Context API を使用した場合も、プロバイダーの階層が深くなり、パフォーマンスの問題が生じることがあります。

GraphQL キャッシュとローカル状態の分離問題

Apollo Client で GraphQL データを管理しつつ、別の仕組みでローカル状態を管理する場合、データの整合性に問題が生じる可能性があります。

以下の図は、分離された状態管理によるデータフローの複雑さを示しています。

mermaidflowchart TD
  Component[コンポーネント] --> Apollo[Apollo Client キャッシュ]
  Component --> Redux[Redux Store]
  Component --> Context[Context API]
  Apollo --> Server[(GraphQL Server)]
  Redux --> LocalStorage[Local Storage]
  Context --> SessionStorage[Session Storage]

  style Component fill:#e1f5fe
  style Apollo fill:#f3e5f5
  style Redux fill:#fff3e0
  style Context fill:#e8f5e8

この分離により、以下の問題が発生します:

  • データの同期問題: サーバーデータの更新時に、関連するローカル状態が同期されない
  • デバッグの困難さ: 複数の状態管理システムを跨いだデータ追跡が複雑
  • パフォーマンスの劣化: 不要な再レンダリングや重複する状態更新
typescript// データ同期の問題例
const UserProfile = () => {
  // Apollo Clientから取得するユーザーデータ
  const { data: userData } = useQuery(GET_USER);

  // Reduxで管理するUI状態
  const theme = useSelector((state) => state.ui.theme);

  // ユーザー情報が更新された時、テーマ設定も同期する必要がある
  useEffect(() => {
    if (userData?.user?.preferences?.theme) {
      dispatch(setTheme(userData.user.preferences.theme));
    }
  }, [userData]); // 手動で同期が必要
};

状態同期とパフォーマンスの課題

複数の状態管理システムを使用する場合、状態の同期とパフォーマンスが大きな課題となります。

typescript// パフォーマンス問題の例
const Dashboard = () => {
  // 複数の状態を監視
  const apolloData = useQuery(GET_DASHBOARD_DATA);
  const reduxState = useSelector(selectDashboardState);
  const [localState, setLocalState] = useState({});

  // 状態変更時の副作用が複雑に絡み合う
  useEffect(() => {
    // Apollo データの変更時
    if (apolloData.data) {
      setLocalState((prev) => ({ ...prev, loaded: true }));
      dispatch(updateDashboardMetrics(apolloData.data));
    }
  }, [apolloData.data]);

  useEffect(() => {
    // Redux状態の変更時
    if (reduxState.filterChanged) {
      apolloData.refetch();
    }
  }, [reduxState.filterChanged]);
};

このような複雑な状態同期は、メモリリークや不要な再計算を引き起こし、アプリケーションのパフォーマンスを著しく低下させる可能性があります。

解決策

Reactive Variables の仕組み

Apollo Client の Reactive Variables は、リアクティブプログラミングの概念を取り入れた革新的な状態管理ソリューションです。

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

// Reactive Variableの作成
const themeVar = makeVar<'light' | 'dark'>('light');
const userPreferencesVar = makeVar({
  language: 'ja',
  notifications: true,
  autoSave: false,
});

Reactive Variables の核となる仕組みは、値の変更を自動的に検知し、関連するコンポーネントに変更を伝播することです。この仕組みにより、開発者は手動での状態同期を気にすることなく、宣言的に UI を構築できます。

以下の図は、Reactive Variables の動作フローを示しています:

mermaidflowchart LR
  makeVar[makeVar で変数作成] --> Component[コンポーネント]
  Component --> useReactiveVar[useReactiveVar フック]
  useReactiveVar --> Subscribe[変更の監視]
  Update[値の更新] --> Notify[変更通知]
  Notify --> Rerender[自動再レンダリング]

  style makeVar fill:#e3f2fd
  style Component fill:#f3e5f5
  style Update fill:#fff3e0
  style Rerender fill:#e8f5e8

この仕組みにより、状態の変更が発生した際に、関連するすべてのコンポーネントが自動的に更新されます。

Apollo Client との統合メリット

Reactive Variables が Apollo Client と統合されることで、従来の状態管理では実現困難だった多くのメリットが得られます。

typescript// Apollo Clientとの完全な統合
import { InMemoryCache } from '@apollo/client';

const cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        // Reactive Variableをクエリフィールドとして使用
        currentTheme: {
          read() {
            return themeVar();
          },
        },
        userPreferences: {
          read() {
            return userPreferencesVar();
          },
        },
      },
    },
  },
});

この統合により、以下のメリットが実現されます:

  1. 統一されたクエリ体験: GraphQL クエリと同じ方法でローカル状態にアクセス
  2. Apollo DevTools でのデバッグ: サーバーデータとローカル状態を同じツールで確認
  3. キャッシュとの協調: Apollo Client のキャッシングメカニズムとの完全な統合
typescript// GraphQLクエリでローカル状態とサーバー状態を同時取得
const GET_DASHBOARD_DATA = gql`
  query GetDashboardData {
    # サーバーから取得するデータ
    user {
      id
      name
      email
    }
    # Reactive Variableから取得するローカル状態
    currentTheme @client
    userPreferences @client
  }
`;

const Dashboard = () => {
  const { data, loading, error } = useQuery(
    GET_DASHBOARD_DATA
  );

  // サーバーデータとローカル状態を統一的に扱える
  return (
    <div className={`dashboard ${data?.currentTheme}`}>
      <h1>Welcome, {data?.user?.name}</h1>
      <Settings preferences={data?.userPreferences} />
    </div>
  );
};

宣言的な状態管理アプローチ

Reactive Variables を使用することで、命令的な状態管理から宣言的な状態管理へとパラダイムが変わります。

typescript// 従来の命令的アプローチ
const ThemeToggle = () => {
  const [theme, setTheme] = useState('light');

  const toggleTheme = () => {
    const newTheme = theme === 'light' ? 'dark' : 'light';
    setTheme(newTheme);
    localStorage.setItem('theme', newTheme);
    // 他のコンポーネントに通知する必要がある
    window.dispatchEvent(
      new CustomEvent('themeChanged', { detail: newTheme })
    );
  };

  return (
    <button onClick={toggleTheme}>Current: {theme}</button>
  );
};
typescript// Reactive Variablesを使った宣言的アプローチ
const themeVar = makeVar('light');

const ThemeToggle = () => {
  const theme = useReactiveVar(themeVar);

  const toggleTheme = () => {
    const newTheme = theme === 'light' ? 'dark' : 'light';
    themeVar(newTheme); // これだけで全体に変更が伝播される
  };

  return (
    <button onClick={toggleTheme}>Current: {theme}</button>
  );
};

宣言的なアプローチでは、**「何を変更するか」に焦点を当て、「どのように変更を伝播するか」**はフレームワークに委ねます。これにより、コードがより簡潔になり、バグの発生確率も大幅に減少します。

具体例

基本的な Reactive Variables の実装

まず、最もシンプルな Reactive Variable の実装から始めましょう。

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

// 基本的なReactive Variableの作成
const counterVar = makeVar(0);
const messageVar = makeVar('Hello, Apollo!');
const isLoadingVar = makeVar(false);

これらの変数は、値の読み書きが可能な関数として動作します。

typescript// 値の読み取り
console.log(counterVar()); // 0
console.log(messageVar()); // "Hello, Apollo!"

// 値の更新
counterVar(10);
messageVar('Updated message');
isLoadingVar(true);

実際の React コンポーネントでの使用例を見てみましょう。

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

const Counter = () => {
  const count = useReactiveVar(counterVar);
  const isLoading = useReactiveVar(isLoadingVar);

  const increment = () => {
    counterVar(count + 1);
  };

  const decrement = () => {
    counterVar(count - 1);
  };

  if (isLoading) {
    return <div>Loading...</div>;
  }

  return (
    <div>
      <h2>Count: {count}</h2>
      <button onClick={increment}>+</button>
      <button onClick={decrement}>-</button>
    </div>
  );
};

export default Counter;

この実装では、useReactiveVarフックを使って変数の値を監視しています。変数の値が変更されると、コンポーネントが自動的に再レンダリングされます。

useReactiveVar フックの活用

useReactiveVarフックは、Reactive Variables をコンポーネント内で使用するための主要なインターフェースです。

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

// ユーザー設定用のReactive Variable
interface UserSettings {
  theme: 'light' | 'dark';
  language: 'en' | 'ja';
  fontSize: number;
}

const userSettingsVar = makeVar<UserSettings>({
  theme: 'light',
  language: 'ja',
  fontSize: 14,
});

設定を管理するコンポーネントの実装例です。

typescriptconst SettingsPanel = () => {
  const settings = useReactiveVar(userSettingsVar);

  const updateTheme = (theme: 'light' | 'dark') => {
    userSettingsVar({
      ...settings,
      theme,
    });
  };

  const updateLanguage = (language: 'en' | 'ja') => {
    userSettingsVar({
      ...settings,
      language,
    });
  };

  const updateFontSize = (fontSize: number) => {
    userSettingsVar({
      ...settings,
      fontSize,
    });
  };

  return (
    <div
      className={`settings-panel theme-${settings.theme}`}
    >
      <h3>設定パネル</h3>

      <div>
        <label>テーマ:</label>
        <select
          value={settings.theme}
          onChange={(e) =>
            updateTheme(e.target.value as 'light' | 'dark')
          }
        >
          <option value='light'>ライト</option>
          <option value='dark'>ダーク</option>
        </select>
      </div>

      <div>
        <label>言語:</label>
        <select
          value={settings.language}
          onChange={(e) =>
            updateLanguage(e.target.value as 'en' | 'ja')
          }
        >
          <option value='ja'>日本語</option>
          <option value='en'>English</option>
        </select>
      </div>

      <div>
        <label>フォントサイズ: {settings.fontSize}px</label>
        <input
          type='range'
          min='12'
          max='24'
          value={settings.fontSize}
          onChange={(e) =>
            updateFontSize(Number(e.target.value))
          }
        />
      </div>
    </div>
  );
};

この設定変更は、他のコンポーネントからも即座に反映されます。

typescriptconst Header = () => {
  const settings = useReactiveVar(userSettingsVar);

  return (
    <header
      className={`header theme-${settings.theme}`}
      style={{ fontSize: `${settings.fontSize}px` }}
    >
      <h1>
        {settings.language === 'ja'
          ? 'マイアプリ'
          : 'My App'}
      </h1>
    </header>
  );
};

複雑な状態管理パターンの実装

より高度な使用例として、ショッピングカートの状態管理を実装してみましょう。

typescript// 商品の型定義
interface Product {
  id: string;
  name: string;
  price: number;
  image: string;
}

// カートアイテムの型定義
interface CartItem extends Product {
  quantity: number;
}

// カート状態の型定義
interface CartState {
  items: CartItem[];
  isOpen: boolean;
  total: number;
}

カート用の Reactive Variable とヘルパー関数を作成します。

typescriptconst cartVar = makeVar<CartState>({
  items: [],
  isOpen: false,
  total: 0,
});

// カート操作のヘルパー関数
const cartHelpers = {
  addItem: (product: Product) => {
    const currentCart = cartVar();
    const existingItem = currentCart.items.find(
      (item) => item.id === product.id
    );

    let newItems: CartItem[];

    if (existingItem) {
      newItems = currentCart.items.map((item) =>
        item.id === product.id
          ? { ...item, quantity: item.quantity + 1 }
          : item
      );
    } else {
      newItems = [
        ...currentCart.items,
        { ...product, quantity: 1 },
      ];
    }

    const newTotal = newItems.reduce(
      (sum, item) => sum + item.price * item.quantity,
      0
    );

    cartVar({
      ...currentCart,
      items: newItems,
      total: newTotal,
    });
  },

  removeItem: (productId: string) => {
    const currentCart = cartVar();
    const newItems = currentCart.items.filter(
      (item) => item.id !== productId
    );
    const newTotal = newItems.reduce(
      (sum, item) => sum + item.price * item.quantity,
      0
    );

    cartVar({
      ...currentCart,
      items: newItems,
      total: newTotal,
    });
  },

  updateQuantity: (productId: string, quantity: number) => {
    const currentCart = cartVar();

    if (quantity <= 0) {
      cartHelpers.removeItem(productId);
      return;
    }

    const newItems = currentCart.items.map((item) =>
      item.id === productId ? { ...item, quantity } : item
    );

    const newTotal = newItems.reduce(
      (sum, item) => sum + item.price * item.quantity,
      0
    );

    cartVar({
      ...currentCart,
      items: newItems,
      total: newTotal,
    });
  },

  toggleCart: () => {
    const currentCart = cartVar();
    cartVar({
      ...currentCart,
      isOpen: !currentCart.isOpen,
    });
  },

  clearCart: () => {
    cartVar({
      items: [],
      isOpen: false,
      total: 0,
    });
  },
};

カートコンポーネントの実装例です。

typescriptconst ShoppingCart = () => {
  const cart = useReactiveVar(cartVar);

  if (!cart.isOpen) {
    return (
      <button
        className='cart-toggle'
        onClick={cartHelpers.toggleCart}
      >
        カート ({cart.items.length})
      </button>
    );
  }

  return (
    <div className='cart-overlay'>
      <div className='cart-panel'>
        <div className='cart-header'>
          <h3>ショッピングカート</h3>
          <button onClick={cartHelpers.toggleCart}>
            ×
          </button>
        </div>

        <div className='cart-items'>
          {cart.items.length === 0 ? (
            <p>カートは空です</p>
          ) : (
            cart.items.map((item) => (
              <div key={item.id} className='cart-item'>
                <img src={item.image} alt={item.name} />
                <div className='item-details'>
                  <h4>{item.name}</h4>
                  <p>¥{item.price}</p>
                  <div className='quantity-controls'>
                    <button
                      onClick={() =>
                        cartHelpers.updateQuantity(
                          item.id,
                          item.quantity - 1
                        )
                      }
                    >
                      -
                    </button>
                    <span>{item.quantity}</span>
                    <button
                      onClick={() =>
                        cartHelpers.updateQuantity(
                          item.id,
                          item.quantity + 1
                        )
                      }
                    >
                      +
                    </button>
                  </div>
                </div>
                <button
                  onClick={() =>
                    cartHelpers.removeItem(item.id)
                  }
                  className='remove-item'
                >
                  削除
                </button>
              </div>
            ))
          )}
        </div>

        {cart.items.length > 0 && (
          <div className='cart-footer'>
            <div className='total'>
              合計: ¥{cart.total.toLocaleString()}
            </div>
            <button className='checkout-button'>
              レジに進む
            </button>
            <button
              className='clear-cart'
              onClick={cartHelpers.clearCart}
            >
              カートを空にする
            </button>
          </div>
        )}
      </div>
    </div>
  );
};

以下の図は、複雑な状態管理パターンでのデータフローを示しています:

mermaidflowchart TD
  ProductList[商品一覧] --> AddItem[商品追加]
  AddItem --> CartVar[cartVar 状態更新]
  CartVar --> CartComponent[カートコンポーネント]
  CartVar --> HeaderBadge[ヘッダーバッジ]
  CartVar --> CheckoutButton[チェックアウト]

  CartComponent --> UpdateQuantity[数量変更]
  CartComponent --> RemoveItem[商品削除]
  UpdateQuantity --> CartVar
  RemoveItem --> CartVar

  style CartVar fill:#e1f5fe
  style ProductList fill:#f3e5f5
  style CartComponent fill:#fff3e0

この実装では、カート状態の変更が複数のコンポーネントに自動的に反映されます。状態の一貫性が保たれ、手動での同期処理が不要になります。

まとめ

Apollo Client の Reactive Variables は、GraphQL を活用したモダンな Web アプリケーション開発において、状態管理の複雑さを大幅に軽減する革新的なソリューションです。

従来の Redux や Context API といった状態管理ライブラリが抱えていた課題を解決し、以下の大きなメリットを提供します。まず、Apollo Client との完全な統合により、サーバー状態とクライアント状態を統一的に管理できるようになります。これにより、データの整合性が保たれ、開発者の認知負荷が大幅に軽減されるでしょう。

宣言的な状態管理アプローチにより、コードの可読性と保守性が向上します。状態の変更を伝播する仕組みが自動化されるため、手動での同期処理が不要になり、バグの発生確率も大幅に減少します。

最小限の設定で高機能な状態管理を実現できるため、開発効率が向上し、チーム全体の生産性向上にもつながります。TypeScript との親和性も高く、型安全性を保ちながら開発を進められる点も大きな魅力です。

GraphQL を採用したプロジェクトにおいて、Reactive Variables は状態管理の新しいスタンダードとなる可能性を秘めています。従来の複雑な状態管理から脱却し、よりシンプルで効率的な開発体験を実現するために、ぜひ Reactive Variables の導入を検討してみてください。

関連リンク