T-CREATOR

Pinia と TanStack Query の使い分けを徹底検証:サーバー/クライアント状態の最適解

Pinia と TanStack Query の使い分けを徹底検証:サーバー/クライアント状態の最適解

Vue.js エコシステムにおける状態管理は、Vue 3 の登場とともに大きな変革期を迎えました。従来の Vuex から新世代の Pinia へ、そしてサーバー状態管理の専門ライブラリである TanStack Query の普及により、開発者は適切な状態管理手法を選択する必要に迫られています。

本記事では、Pinia と TanStack Query の特徴を深く理解し、それぞれが得意とする領域を明確に定義することで、最適な使い分けによる効果的な状態管理を実現していきます。実際のプロジェクトでの選択基準と実装パターンを通じて、開発者体験の向上とアプリケーションパフォーマンスの最適化を両立させる方法をお伝えいたします。

背景

Vue 3 時代の状態管理の変遷

Vue 3 の登場により、状態管理の世界は劇的に変化しました。Composition API の導入によって、よりモジュラーで再利用可能な状態管理パターンが可能になったのです。

Vue 3 における状態管理の進化を図で確認しましょう。

mermaidflowchart LR
    vue2["Vue 2 + Vuex"] -->|進化| vue3_1["Vue 3 + Vuex 4"]
    vue3_1 -->|改良| vue3_2["Vue 3 + Pinia"]
    vue3_2 -->|専門化| modern["Vue 3 + Pinia<br/>+ TanStack Query"]

    vue2 --> limitation["制約"]
    limitation --> ts_support["TypeScript<br/>サポート不足"]
    limitation --> module_complexity["モジュール<br/>構造の複雑さ"]

    modern --> benefits["利点"]
    benefits --> type_safety["完全な<br/>型安全性"]
    benefits --> role_separation["役割の<br/>明確な分離"]
    benefits --> dev_experience["優れた<br/>開発体験"]

Vue 2 時代では Vuex が事実上唯一の選択肢でしたが、TypeScript サポートの不足やモジュール構造の複雑さという課題を抱えていました。Vue 3 の登場により、これらの課題が順次解決され、現在では目的に応じた最適なライブラリを選択できる環境が整っています。

Vuex からの移行と Pinia の登場

Pinia は「Vue の新しい状態管理ライブラリ」として開発され、現在では Vue の公式推奨状態管理ライブラリとなっています。

Vuex から Pinia への移行における主要な改善点を整理しましょう。

項目Vuex 4Pinia
TypeScript サポート部分的完全対応
Composition APIアダプター経由ネイティブサポート
DevTools サポート基本的な機能強化された機能
モジュール構造複雑な設定が必要シンプルな store 定義
コード分割手動設定自動対応
SSR サポート設定が複雑簡単な設定

Pinia の最大の特徴は、「ストアの定義がシンプルである」ことです。従来の Vuex で必要だった冗長な設定や複雑なモジュール構造から解放され、直感的で保守性の高いコードが書けるようになりました。

サーバー状態とクライアント状態の明確な分離の重要性

現代の Web アプリケーション開発において、状態を「クライアント状態」と「サーバー状態」に分離することは、アーキテクチャ設計の基本となっています。

状態の分類と特徴を図で理解しましょう。

mermaidflowchart TD
    state["アプリケーション状態"] --> client_state["クライアント状態"]
    state --> server_state["サーバー状態"]

    client_state --> ui_state["UI状態"]
    client_state --> user_input["ユーザー入力"]
    client_state --> app_config["アプリ設定"]

    server_state --> api_data["API データ"]
    server_state --> cache_data["キャッシュデータ"]
    server_state --> realtime_data["リアルタイムデータ"]

    ui_state --> pinia1["Pinia で管理"]
    user_input --> pinia2["Pinia で管理"]
    app_config --> pinia3["Pinia で管理"]

    api_data --> tanstack1["TanStack Query で管理"]
    cache_data --> tanstack2["TanStack Query で管理"]
    realtime_data --> tanstack3["TanStack Query で管理"]

この分離により、それぞれの状態に最適化されたライブラリを使用でき、開発効率とアプリケーションパフォーマンスの両方を向上させることができます。クライアント状態は Pinia で、サーバー状態は TanStack Query で管理することで、各ライブラリの強みを最大限に活用できるのです。

課題

状態管理ライブラリ選択の混乱

Vue.js エコシステムにおける状態管理ライブラリの選択肢が増えたことで、開発者は「どのライブラリをいつ使うべきか」という判断に迷うことが多くなりました。

現在主流となっている状態管理のパターンを整理しましょう。

mermaidflowchart LR
    decision["状態管理ライブラリ選択"] --> confusion["選択時の混乱要因"]

    confusion --> overlap["機能の重複"]
    confusion --> docs["ドキュメントの<br/>使い分け説明不足"]
    confusion --> migration["既存プロジェクト<br/>移行の複雑さ"]

    overlap --> similar_features["類似した機能<br/>による迷い"]
    docs --> best_practices["ベストプラクティス<br/>の不明確さ"]
    migration --> risk["移行リスク<br/>の懸念"]

    decision --> criteria["判断基準"]
    criteria --> data_source["データソース"]
    criteria --> lifecycle["ライフサイクル"]
    criteria --> synchronization["同期方法"]

特に中規模以上のプロジェクトでは、複数の状態管理手法が混在することで、コードの一貫性が損なわれ、新しいチームメンバーの学習コストが増大する問題が発生しています。

サーバー状態とクライアント状態の境界線が不明確

実際の開発現場では、「この状態はどちらで管理すべきか?」という判断が難しい場面が頻繁に発生します。

境界線が曖昧になりやすい状態の例を見てみましょう。

状態の例一般的な分類判断が分かれるケース
ユーザー認証情報クライアント状態JWT トークンの自動更新時
ショッピングカートクライアント状態サーバー同期が必要な場合
フォーム入力値クライアント状態下書き保存機能がある場合
商品一覧データサーバー状態フィルタリング状態との組み合わせ
通知設定サーバー状態ローカル一時設定との使い分け

このような境界線の曖昧さが、設計の一貫性を損ない、バグの温床となることがあります。特に、複数の開発者が関わるプロジェクトでは、統一された判断基準の策定が重要になります。

パフォーマンスと DX の両立の難しさ

優れた開発者体験(DX: Developer Experience)とアプリケーションパフォーマンスの両立は、現代のフロントエンド開発における重要な課題です。

パフォーマンスと DX のトレードオフを図で確認しましょう。

mermaidflowchart TD
    challenge["パフォーマンス vs DX"] --> performance["パフォーマンス重視"]
    challenge --> dx["DX 重視"]

    performance --> perf_benefits["利点"]
    performance --> perf_costs["代償"]

    dx --> dx_benefits["利点"]
    dx --> dx_costs["代償"]

    perf_benefits --> fast["高速動作"]
    perf_benefits --> efficient["効率的なメモリ使用"]
    perf_costs --> complex["複雑な実装"]
    perf_costs --> maintenance["保守コスト増"]

    dx_benefits --> readable["読みやすいコード"]
    dx_benefits --> productive["高い開発生産性"]
    dx_costs --> overhead["実行時オーバーヘッド"]
    dx_costs --> bundle["バンドルサイズ増"]

    challenge --> solution["最適解"]
    solution --> appropriate["適切な使い分け"]
    solution --> optimization["段階的最適化"]

従来は、パフォーマンスを重視すると複雑な実装が必要になり、開発体験が犠牲になることがありました。しかし、Pinia と TanStack Query の適切な使い分けにより、両者のバランスを取ることが可能になっています。

解決策

Pinia:クライアント状態管理の専門化

Pinia は、Vue.js アプリケーションのクライアント状態管理に特化したライブラリとして、シンプルで直感的な API を提供します。

Pinia の基本的な store 定義から見ていきましょう。

typescript// stores/user.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';

export const useUserStore = defineStore('user', () => {
  // 状態定義
  const user = ref(null);
  const isAuthenticated = ref(false);
  const preferences = ref({
    theme: 'light',
    language: 'ja',
  });

  return {
    user,
    isAuthenticated,
    preferences,
  };
});

Composition API スタイルでの store 定義により、Vue コンポーネントと同じ記法で状態管理ができます。

ユーザー認証に関するアクションとゲッターを追加してみましょう。

typescript// stores/user.ts(続き)
export const useUserStore = defineStore('user', () => {
  // 状態定義(前回と同じ)
  const user = ref(null);
  const isAuthenticated = ref(false);
  const preferences = ref({
    theme: 'light',
    language: 'ja',
  });

  // ゲッター(computed)
  const userName = computed(
    () => user.value?.name || 'ゲスト'
  );
  const isDarkMode = computed(
    () => preferences.value.theme === 'dark'
  );

  // アクション(functions)
  const login = (userData) => {
    user.value = userData;
    isAuthenticated.value = true;
  };

  const logout = () => {
    user.value = null;
    isAuthenticated.value = false;
  };

  const updatePreferences = (newPreferences) => {
    preferences.value = {
      ...preferences.value,
      ...newPreferences,
    };
  };

  return {
    // 状態
    user,
    isAuthenticated,
    preferences,
    // ゲッター
    userName,
    isDarkMode,
    // アクション
    login,
    logout,
    updatePreferences,
  };
});

コンポーネントでの Pinia store の使用方法を確認しましょう。

vue<template>
  <div class="user-panel">
    <h2>こんにちは、{{ userName }}さん</h2>
    <p>
      認証状態:
      {{ isAuthenticated ? '認証済み' : '未認証' }}
    </p>
    <button @click="toggleTheme">
      {{ isDarkMode ? 'ライト' : 'ダーク' }}モードに切り替え
    </button>
  </div>
</template>

<script setup lang="ts">
import { useUserStore } from '@/stores/user';

const userStore = useUserStore();

// ストアの状態とゲッターに直接アクセス
const { userName, isAuthenticated, isDarkMode } = userStore;

// テーマ切り替えアクション
const toggleTheme = () => {
  const newTheme = isDarkMode ? 'light' : 'dark';
  userStore.updatePreferences({ theme: newTheme });
};
</script>

Pinia の特徴として、TypeScript との親和性が高く、型安全性を保ちながら開発できる点が挙げられます。また、DevTools との連携も優秀で、状態の変化をリアルタイムで確認できます。

TanStack Query:サーバー状態管理の最適化

TanStack Query(旧 React Query の Vue 版)は、サーバー状態の取得、キャッシュ、同期、更新を効率的に行うライブラリです。

TanStack Query の基本セットアップから始めましょう。

typescript// main.ts
import { createApp } from 'vue';
import { VueQueryPlugin } from '@tanstack/vue-query';
import App from './App.vue';

const app = createApp(App);

app.use(VueQueryPlugin, {
  queryClientConfig: {
    defaultOptions: {
      queries: {
        refetchOnWindowFocus: false,
        retry: 1,
        staleTime: 5 * 60 * 1000, // 5分間
      },
    },
  },
});

app.mount('#app');

商品データの取得を例に、TanStack Query の基本的な使用方法を見てみましょう。

typescript// composables/useProducts.ts
import {
  useQuery,
  useMutation,
  useQueryClient,
} from '@tanstack/vue-query';
import { productApi } from '@/api/products';

export const useProducts = () => {
  // 商品一覧の取得
  const {
    data: products,
    isLoading,
    error,
    refetch,
  } = useQuery({
    queryKey: ['products'],
    queryFn: productApi.getProducts,
    staleTime: 10 * 60 * 1000, // 10分間キャッシュ
  });

  return {
    products,
    isLoading,
    error,
    refetch,
  };
};

商品データの更新機能を mutation として実装しましょう。

typescript// composables/useProducts.ts(続き)
export const useProductMutations = () => {
  const queryClient = useQueryClient();

  // 商品追加のmutation
  const addProductMutation = useMutation({
    mutationFn: productApi.addProduct,
    onSuccess: () => {
      // 成功時にキャッシュを無効化して再取得
      queryClient.invalidateQueries({
        queryKey: ['products'],
      });
    },
    onError: (error) => {
      console.error('商品追加エラー:', error);
    },
  });

  // 商品更新のmutation
  const updateProductMutation = useMutation({
    mutationFn: ({ id, data }) =>
      productApi.updateProduct(id, data),
    onSuccess: (updatedProduct) => {
      // 楽観的更新でUIを即座に反映
      queryClient.setQueryData(['products'], (oldData) =>
        oldData?.map((product) =>
          product.id === updatedProduct.id
            ? updatedProduct
            : product
        )
      );
    },
  });

  return {
    addProduct: addProductMutation.mutate,
    updateProduct: updateProductMutation.mutate,
    isAddingProduct: addProductMutation.isPending,
    isUpdatingProduct: updateProductMutation.isPending,
  };
};

コンポーネントでの TanStack Query の活用例を確認しましょう。

vue<template>
  <div class="product-list">
    <h2>商品一覧</h2>

    <!-- ローディング状態 -->
    <div v-if="isLoading" class="loading">
      商品データを読み込み中...
    </div>

    <!-- エラー状態 -->
    <div v-else-if="error" class="error">
      エラーが発生しました: {{ error.message }}
      <button @click="refetch">再試行</button>
    </div>

    <!-- 商品リスト -->
    <div v-else-if="products" class="products">
      <div
        v-for="product in products"
        :key="product.id"
        class="product-card"
      >
        <h3>{{ product.name }}</h3>
        <p>価格: ¥{{ product.price.toLocaleString() }}</p>
        <button
          @click="handleUpdateProduct(product.id)"
          :disabled="isUpdatingProduct"
        >
          更新
        </button>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import {
  useProducts,
  useProductMutations,
} from '@/composables/useProducts';

const { products, isLoading, error, refetch } =
  useProducts();
const { updateProduct, isUpdatingProduct } =
  useProductMutations();

const handleUpdateProduct = (productId: string) => {
  updateProduct({
    id: productId,
    data: { updatedAt: new Date().toISOString() },
  });
};
</script>

TanStack Query の主要な利点は、自動的なキャッシュ管理、バックグラウンド更新、楽観的更新などの高度な機能を簡単に実装できることです。これにより、ユーザー体験の向上とサーバー負荷の軽減を同時に実現できます。

役割分担による効率的な開発体制

Pinia と TanStack Query の適切な役割分担により、チーム開発における効率性と保守性を大幅に向上させることができます。

役割分担のベストプラクティスを図で整理しましょう。

mermaidflowchart TD
    app["Vue.js アプリケーション"] --> pinia_layer["Pinia レイヤー"]
    app --> tanstack_layer["TanStack Query レイヤー"]

    pinia_layer --> ui_states["UI状態管理"]
    pinia_layer --> user_states["ユーザー状態管理"]
    pinia_layer --> app_states["アプリケーション設定"]

    tanstack_layer --> api_states["API データ管理"]
    tanstack_layer --> cache_management["キャッシュ管理"]
    tanstack_layer --> sync_states["同期状態管理"]

    ui_states --> modal["モーダル表示状態"]
    ui_states --> navigation["ナビゲーション状態"]
    user_states --> auth["認証情報"]
    user_states --> preferences["ユーザー設定"]
    app_states --> theme["テーマ設定"]
    app_states --> locale["言語設定"]

    api_states --> products["商品データ"]
    api_states --> users["ユーザーデータ"]
    cache_management --> background["バックグラウンド更新"]
    cache_management --> invalidation["キャッシュ無効化"]
    sync_states --> realtime["リアルタイム同期"]
    sync_states --> offline["オフライン対応"]

この役割分担により、各開発者は自分の担当領域に集中でき、コードの品質と開発速度の両方を向上させることができます。

具体例

EC サイトでの実装比較

実際の EC サイトプロジェクトを例に、Pinia と TanStack Query の具体的な使い分けを詳しく見ていきましょう。

EC サイトの状態管理アーキテクチャを図で確認しましょう。

mermaidflowchart LR
    user["ユーザー"] --> ui["UI コンポーネント"]
    ui --> pinia["Pinia ストア"]
    ui --> tanstack["TanStack Query"]

    pinia --> cart["ショッピングカート"]
    pinia --> auth["認証状態"]
    pinia --> ui_state["UI状態"]

    tanstack --> products["商品データ"]
    tanstack --> inventory["在庫情報"]
    tanstack --> orders["注文履歴"]

    cart --> localStorage["Local Storage"]
    auth --> sessionStorage["Session Storage"]

    products --> api1["商品API"]
    inventory --> api2["在庫API"]
    orders --> api3["注文API"]

    api1 --> database[("データベース")]
    api2 --> database
    api3 --> database

この図から分かるように、ユーザーの操作に関わるローカル状態は Pinia で、サーバーからのデータは TanStack Query で管理する明確な分離ができています。

ユーザー認証状態(Pinia)

ユーザー認証状態は、アプリケーション全体で共有され、ログイン・ログアウトなどのアクションを含むクライアント状態です。

認証ストアの完全な実装を見てみましょう。

typescript// stores/auth.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import { authApi } from '@/api/auth';

interface User {
  id: string;
  email: string;
  name: string;
  role: 'customer' | 'admin';
}

export const useAuthStore = defineStore('auth', () => {
  // 状態
  const user = ref<User | null>(null);
  const token = ref<string | null>(null);
  const isLoading = ref(false);

  // ゲッター
  const isAuthenticated = computed(
    () => !!user.value && !!token.value
  );
  const isAdmin = computed(
    () => user.value?.role === 'admin'
  );
  const userName = computed(
    () => user.value?.name || 'ゲスト'
  );

  return {
    user,
    token,
    isLoading,
    isAuthenticated,
    isAdmin,
    userName,
  };
});

ログイン・ログアウト機能の実装を追加しましょう。

typescript// stores/auth.ts(続き)
export const useAuthStore = defineStore('auth', () => {
  // 前回の状態定義は省略

  // アクション
  const login = async (email: string, password: string) => {
    isLoading.value = true;

    try {
      const response = await authApi.login({
        email,
        password,
      });

      user.value = response.user;
      token.value = response.token;

      // トークンをセッションストレージに保存
      sessionStorage.setItem('auth_token', response.token);

      return { success: true };
    } catch (error) {
      console.error('ログインエラー:', error);
      return { success: false, error: error.message };
    } finally {
      isLoading.value = false;
    }
  };

  const logout = () => {
    user.value = null;
    token.value = null;
    sessionStorage.removeItem('auth_token');
  };

  const initializeAuth = () => {
    const savedToken = sessionStorage.getItem('auth_token');
    if (savedToken) {
      token.value = savedToken;
      // ユーザー情報は別途取得(TanStack Query使用)
    }
  };

  return {
    // 状態とゲッター(省略)
    // アクション
    login,
    logout,
    initializeAuth,
  };
});

認証ストアをコンポーネントで使用する例を確認しましょう。

vue<!-- components/LoginForm.vue -->
<template>
  <form @submit.prevent="handleLogin" class="login-form">
    <h2>ログイン</h2>

    <div class="form-group">
      <label for="email">メールアドレス</label>
      <input
        id="email"
        v-model="email"
        type="email"
        required
      />
    </div>

    <div class="form-group">
      <label for="password">パスワード</label>
      <input
        id="password"
        v-model="password"
        type="password"
        required
      />
    </div>

    <button
      type="submit"
      :disabled="authStore.isLoading"
      class="login-button"
    >
      {{
        authStore.isLoading ? 'ログイン中...' : 'ログイン'
      }}
    </button>

    <div v-if="errorMessage" class="error">
      {{ errorMessage }}
    </div>
  </form>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useAuthStore } from '@/stores/auth';

const router = useRouter();
const authStore = useAuthStore();

const email = ref('');
const password = ref('');
const errorMessage = ref('');

const handleLogin = async () => {
  errorMessage.value = '';

  const result = await authStore.login(
    email.value,
    password.value
  );

  if (result.success) {
    router.push('/dashboard');
  } else {
    errorMessage.value =
      result.error || 'ログインに失敗しました';
  }
};
</script>

商品データ取得(TanStack Query)

商品データは典型的なサーバー状態であり、キャッシュ、自動更新、エラーハンドリングが重要です。

商品データ取得のためのコンポーザブル関数を実装しましょう。

typescript// composables/useProducts.ts
import {
  useQuery,
  useInfiniteQuery,
} from '@tanstack/vue-query';
import { computed } from 'vue';
import { productApi } from '@/api/products';

interface ProductFilters {
  category?: string;
  priceRange?: { min: number; max: number };
  search?: string;
}

export const useProducts = (
  filters: ProductFilters = {}
) => {
  // 商品一覧の基本取得
  const {
    data: products,
    isLoading,
    error,
    refetch,
  } = useQuery({
    queryKey: ['products', filters],
    queryFn: () => productApi.getProducts(filters),
    staleTime: 5 * 60 * 1000, // 5分間フレッシュ
    gcTime: 10 * 60 * 1000, // 10分間キャッシュ保持
  });

  // 商品数の計算
  const productCount = computed(
    () => products.value?.length || 0
  );

  return {
    products,
    productCount,
    isLoading,
    error,
    refetch,
  };
};

無限スクロール対応の商品取得機能を追加しましょう。

typescript// composables/useProducts.ts(続き)
export const useInfiniteProducts = (
  filters: ProductFilters = {}
) => {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isLoading,
    error,
  } = useInfiniteQuery({
    queryKey: ['products', 'infinite', filters],
    queryFn: ({ pageParam = 0 }) =>
      productApi.getProducts({
        ...filters,
        page: pageParam,
        limit: 20,
      }),
    getNextPageParam: (lastPage, pages) => {
      return lastPage.hasMore ? pages.length : undefined;
    },
    staleTime: 5 * 60 * 1000,
  });

  // 全商品を平坦化
  const allProducts = computed(() => {
    return (
      data.value?.pages.flatMap((page) => page.products) ||
      []
    );
  });

  return {
    products: allProducts,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isLoading,
    error,
  };
};

商品詳細データの取得機能を実装しましょう。

typescript// composables/useProduct.ts
import { useQuery } from '@tanstack/vue-query';
import { productApi } from '@/api/products';

export const useProduct = (productId: string) => {
  const {
    data: product,
    isLoading,
    error,
  } = useQuery({
    queryKey: ['product', productId],
    queryFn: () => productApi.getProduct(productId),
    enabled: !!productId, // productIdが存在する場合のみ実行
    staleTime: 10 * 60 * 1000, // 商品詳細は10分間フレッシュ
  });

  return {
    product,
    isLoading,
    error,
  };
};

ショッピングカート状態(Pinia)

ショッピングカートは、ユーザーの操作により頻繁に変更されるクライアント状態です。

カートストアの実装を見てみましょう。

typescript// stores/cart.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';

interface CartItem {
  productId: string;
  name: string;
  price: number;
  quantity: number;
  image?: string;
}

export const useCartStore = defineStore('cart', () => {
  // 状態
  const items = ref<CartItem[]>([]);
  const isLoading = ref(false);

  // ゲッター
  const itemCount = computed(() =>
    items.value.reduce(
      (total, item) => total + item.quantity,
      0
    )
  );

  const totalPrice = computed(() =>
    items.value.reduce(
      (total, item) => total + item.price * item.quantity,
      0
    )
  );

  const formattedTotalPrice = computed(
    () => ${totalPrice.value.toLocaleString()}`
  );

  const isEmpty = computed(() => items.value.length === 0);

  return {
    items,
    isLoading,
    itemCount,
    totalPrice,
    formattedTotalPrice,
    isEmpty,
  };
});

カート操作のアクションを実装しましょう。

typescript// stores/cart.ts(続き)
export const useCartStore = defineStore('cart', () => {
  // 前回の状態とゲッターは省略

  // アクション
  const addItem = (
    product: Omit<CartItem, 'quantity'>,
    quantity = 1
  ) => {
    const existingItem = items.value.find(
      (item) => item.productId === product.productId
    );

    if (existingItem) {
      existingItem.quantity += quantity;
    } else {
      items.value.push({ ...product, quantity });
    }

    saveToLocalStorage();
  };

  const removeItem = (productId: string) => {
    const index = items.value.findIndex(
      (item) => item.productId === productId
    );
    if (index > -1) {
      items.value.splice(index, 1);
      saveToLocalStorage();
    }
  };

  const updateQuantity = (
    productId: string,
    quantity: number
  ) => {
    const item = items.value.find(
      (item) => item.productId === productId
    );
    if (item) {
      if (quantity <= 0) {
        removeItem(productId);
      } else {
        item.quantity = quantity;
        saveToLocalStorage();
      }
    }
  };

  const clearCart = () => {
    items.value = [];
    saveToLocalStorage();
  };

  // ローカルストレージへの保存
  const saveToLocalStorage = () => {
    localStorage.setItem(
      'shopping_cart',
      JSON.stringify(items.value)
    );
  };

  // ローカルストレージからの復元
  const loadFromLocalStorage = () => {
    const saved = localStorage.getItem('shopping_cart');
    if (saved) {
      try {
        items.value = JSON.parse(saved);
      } catch (error) {
        console.error(
          'カートデータの読み込みエラー:',
          error
        );
        items.value = [];
      }
    }
  };

  return {
    // 状態とゲッター(省略)
    // アクション
    addItem,
    removeItem,
    updateQuantity,
    clearCart,
    loadFromLocalStorage,
  };
});

リアルタイム在庫確認(TanStack Query)

在庫情報は頻繁に変更されるサーバー状態であり、リアルタイム性が重要です。

在庫確認のためのコンポーザブル関数を実装しましょう。

typescript// composables/useInventory.ts
import {
  useQuery,
  useQueryClient,
} from '@tanstack/vue-query';
import { onUnmounted } from 'vue';
import { inventoryApi } from '@/api/inventory';

export const useInventory = (productId: string) => {
  const queryClient = useQueryClient();

  // 在庫情報の取得
  const {
    data: inventory,
    isLoading,
    error,
  } = useQuery({
    queryKey: ['inventory', productId],
    queryFn: () => inventoryApi.getInventory(productId),
    enabled: !!productId,
    staleTime: 30 * 1000, // 30秒間フレッシュ(在庫は頻繁に変更される)
    refetchInterval: 60 * 1000, // 1分ごとに自動更新
  });

  return {
    inventory,
    isLoading,
    error,
    stock: inventory?.stock || 0,
    isInStock: (inventory?.stock || 0) > 0,
    isLowStock:
      (inventory?.stock || 0) > 0 &&
      (inventory?.stock || 0) <= 10,
  };
};

WebSocket を使用したリアルタイム在庫更新機能を追加しましょう。

typescript// composables/useRealtimeInventory.ts
import {
  useQuery,
  useQueryClient,
} from '@tanstack/vue-query';
import { onMounted, onUnmounted } from 'vue';
import {
  inventoryApi,
  inventoryWebSocket,
} from '@/api/inventory';

export const useRealtimeInventory = (
  productIds: string[]
) => {
  const queryClient = useQueryClient();
  let websocket: WebSocket | null = null;

  // 複数商品の在庫情報取得
  const {
    data: inventories,
    isLoading,
    error,
  } = useQuery({
    queryKey: ['inventories', productIds],
    queryFn: () => inventoryApi.getInventories(productIds),
    enabled: productIds.length > 0,
    staleTime: 30 * 1000,
  });

  // WebSocket接続の確立
  const connectWebSocket = () => {
    websocket = inventoryWebSocket.connect(productIds);

    websocket.onmessage = (event) => {
      const update = JSON.parse(event.data);

      // 在庫更新時にキャッシュを更新
      queryClient.setQueryData(
        ['inventories', productIds],
        (oldData) => {
          if (!oldData) return oldData;

          return oldData.map((inventory) =>
            inventory.productId === update.productId
              ? {
                  ...inventory,
                  stock: update.stock,
                  updatedAt: update.updatedAt,
                }
              : inventory
          );
        }
      );

      // 個別商品の在庫キャッシュも更新
      queryClient.setQueryData(
        ['inventory', update.productId],
        update
      );
    };

    websocket.onerror = (error) => {
      console.error('WebSocket エラー:', error);
    };
  };

  const disconnectWebSocket = () => {
    if (websocket) {
      websocket.close();
      websocket = null;
    }
  };

  onMounted(() => {
    if (productIds.length > 0) {
      connectWebSocket();
    }
  });

  onUnmounted(() => {
    disconnectWebSocket();
  });

  return {
    inventories,
    isLoading,
    error,
    connectWebSocket,
    disconnectWebSocket,
  };
};

在庫状態を表示するコンポーネントの実装例を確認しましょう。

vue<!-- components/InventoryStatus.vue -->
<template>
  <div class="inventory-status">
    <div v-if="isLoading" class="loading">
      在庫確認中...
    </div>

    <div v-else-if="error" class="error">
      在庫情報の取得に失敗しました
    </div>

    <div v-else class="stock-info">
      <span
        class="stock-badge"
        :class="{
          'in-stock': isInStock && !isLowStock,
          'low-stock': isLowStock,
          'out-of-stock': !isInStock,
        }"
      >
        {{ stockMessage }}
      </span>

      <span v-if="isInStock" class="stock-count">
        残り{{ stock }}個
      </span>
    </div>
  </div>
</template>

<script setup lang="ts">
import { computed } from 'vue';
import { useInventory } from '@/composables/useInventory';

interface Props {
  productId: string;
}

const props = defineProps<Props>();

const {
  inventory,
  isLoading,
  error,
  stock,
  isInStock,
  isLowStock,
} = useInventory(props.productId);

const stockMessage = computed(() => {
  if (!isInStock) return '在庫切れ';
  if (isLowStock) return '残りわずか';
  return '在庫あり';
});
</script>

<style scoped>
.stock-badge {
  padding: 4px 8px;
  border-radius: 4px;
  font-size: 0.875rem;
  font-weight: 500;
}

.in-stock {
  background-color: #dcfce7;
  color: #166534;
}

.low-stock {
  background-color: #fef3c7;
  color: #92400e;
}

.out-of-stock {
  background-color: #fee2e2;
  color: #991b1b;
}
</style>

このような実装により、Pinia はクライアント状態(認証、カート)を、TanStack Query はサーバー状態(商品、在庫)を効率的に管理し、それぞれの特性を活かした最適な状態管理を実現しています。

まとめ

Pinia と TanStack Query の適切な使い分けにより、Vue.js アプリケーションの状態管理を大幅に改善できることが確認できました。

各ライブラリの特徴と適切な使い分けのポイント

本記事で検証した内容をまとめると、以下の使い分け指針が明確になりました。

Pinia が最適な場面:

  • ユーザー認証状態の管理
  • UI の表示状態(モーダル、ナビゲーションなど)
  • アプリケーション設定(テーマ、言語設定)
  • ショッピングカートなどのローカル状態
  • フォーム入力値の一時保存

TanStack Query が最適な場面:

  • API からのデータ取得と管理
  • サーバーデータのキャッシュ機能
  • リアルタイム情報の同期
  • 大量データの効率的な取得
  • オフライン対応が必要なデータ

効果的な組み合わせのメリット:

  1. 開発効率の向上:それぞれの特性を活かした実装により、コード量削減と保守性向上を実現
  2. パフォーマンス最適化:適切なキャッシュ戦略とリアクティブ更新による高速化
  3. 型安全性の確保:TypeScript との優れた親和性により、開発時エラーの早期発見
  4. チーム開発の効率化:明確な責任分離により、複数人での並行開発が容易

最終的に、この使い分けアプローチにより、開発者体験の向上アプリケーションパフォーマンスの最適化を両立する、持続可能で拡張性の高い状態管理アーキテクチャを構築できます。

現代の Vue.js 開発においては、単一のライブラリで全てを解決しようとするのではなく、各ライブラリの強みを理解し、適材適所で活用することが成功の鍵となるのです。

関連リンク