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 4 | Pinia |
---|---|---|
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 からのデータ取得と管理
- サーバーデータのキャッシュ機能
- リアルタイム情報の同期
- 大量データの効率的な取得
- オフライン対応が必要なデータ
効果的な組み合わせのメリット:
- 開発効率の向上:それぞれの特性を活かした実装により、コード量削減と保守性向上を実現
- パフォーマンス最適化:適切なキャッシュ戦略とリアクティブ更新による高速化
- 型安全性の確保:TypeScript との優れた親和性により、開発時エラーの早期発見
- チーム開発の効率化:明確な責任分離により、複数人での並行開発が容易
最終的に、この使い分けアプローチにより、開発者体験の向上とアプリケーションパフォーマンスの最適化を両立する、持続可能で拡張性の高い状態管理アーキテクチャを構築できます。
現代の Vue.js 開発においては、単一のライブラリで全てを解決しようとするのではなく、各ライブラリの強みを理解し、適材適所で活用することが成功の鍵となるのです。
関連リンク
- article
Pinia と TanStack Query の使い分けを徹底検証:サーバー/クライアント状態の最適解
- article
Pinia で状態が更新されない?参照の再利用・シャロー比較・getter 依存の落とし穴
- article
Pinia アーキテクチャ超図解:リアクティビティとストアの舞台裏を一枚で理解
- article
Pinia × TypeScript:型安全なストア設計入門
- article
Pinia の基本 API 解説:defineStore・state・getters・actions
- article
Vuex から Pinia への移行ガイド:違いとメリットを比較
- article
Prisma トラブルシュート大全:P1000/P1001/P1008 ほか接続系エラーの即解決ガイド
- article
ESLint vs Biome vs Rome 後継:速度・エコシステム・移行コストを実測比較
- article
Pinia と TanStack Query の使い分けを徹底検証:サーバー/クライアント状態の最適解
- article
Dify と LangGraph/LangChain を比較:表現力・保守性・学習コストのリアル
- article
Cursor と Copilot Chat/Codeium の役割比較:設計支援 vs 実装支援の最適配置
- article
Obsidian Sync と iCloud/Dropbox/Google Drive:速度・信頼性・復旧性を実測比較
- blog
iPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
- blog
Googleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
- blog
【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
- blog
Googleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
- blog
Pixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
- blog
フロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
- review
今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
- review
ついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
- review
愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
- review
週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
- review
新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
- review
科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来