Pinia と VueUse の useStorage/useFetch 比較:軽量レシピで代替できる境界
Vue.js でアプリケーションを開発していると、状態管理やデータ取得、ローカルストレージの連携など、よく使う機能を効率的に実装したくなりますよね。そんなとき、Pinia や VueUse といったライブラリが便利です。しかし、全ての機能をライブラリに頼る必要はありません。
この記事では、Pinia と VueUse の useStorage と useFetch について、それぞれの特徴を比較し、どのような場面で軽量な自作レシピで代替できるのか、その境界線を明らかにしていきます。実装コード例も豊富に用意しましたので、ぜひ最後までお読みください。
背景
Vue.js エコシステムにおける状態管理とユーティリティの位置づけ
Vue.js のエコシステムには、開発効率を高めるための様々なライブラリがあります。中でも状態管理やユーティリティ関数は、アプリケーションの設計において重要な役割を果たしています。
Pinia は Vue 3 公式の状態管理ライブラリとして、Vuex の後継として推奨されています。一方、VueUse は Vue Composition API 向けのユーティリティコレクションで、リアクティブな機能を簡単に実装できる関数群を提供します。
以下の図は、Vue.js アプリケーションにおけるこれらのライブラリの位置づけを示したものです。
mermaidflowchart TB
app["Vue.js アプリ"]
pinia["Pinia<br/>(状態管理)"]
vueuse["VueUse<br/>(ユーティリティ)"]
custom["自作レシピ<br/>(軽量実装)"]
app -->|グローバル状態| pinia
app -->|便利関数| vueuse
app -->|シンプルな処理| custom
pinia -.->|複雑な場合| custom
vueuse -.->|シンプルな場合| custom
この図から、Pinia と VueUse はそれぞれ異なる役割を持ちながらも、シンプルな処理であれば自作レシピで代替できることがわかります。
VueUse が提供する主要機能
VueUse は 200 以上の Composition 関数を提供しており、以下のような機能が含まれています。
| # | カテゴリ | 代表的な関数 | 用途 |
|---|---|---|---|
| 1 | State | useStorage, useLocalStorage | ストレージ連携 |
| 2 | Network | useFetch, useWebSocket | データ取得・通信 |
| 3 | Browser | useClipboard, useFullscreen | ブラウザ API |
| 4 | Sensors | useMouse, useScroll | ユーザー操作検知 |
| 5 | Animation | useTransition, useInterval | アニメーション・タイマー |
これらの関数は非常に便利ですが、すべての機能を使うわけではない場合、バンドルサイズが肥大化する可能性があります。
Pinia のストレージプラグインとの関係
Pinia 自体はストレージ永続化機能を持っていませんが、プラグインを使うことで実現できます。代表的なものに pinia-plugin-persistedstate があります。
一方、VueUse の useStorage を使えば、Pinia のストアと組み合わせて手軽にストレージ連携を実装できるため、プラグインが不要になるケースもあります。
課題
ライブラリ依存による課題
Vue.js 開発において、Pinia や VueUse を使うことで開発効率は大幅に向上しますが、以下のような課題も存在します。
バンドルサイズの増加
VueUse は多機能ゆえに、Tree Shaking が効かない場合や、必要以上の機能をインポートしてしまうと、バンドルサイズが増加します。特にモバイル環境では、初期ロード時間に影響を与える可能性があります。
学習コストとブラックボックス化
VueUse や Pinia のプラグインは便利ですが、内部実装を理解せずに使うとブラックボックス化してしまい、トラブルシューティングが難しくなります。また、チーム開発では、全メンバーがこれらのライブラリに精通している必要があります。
過度な抽象化によるオーバーエンジニアリング
シンプルなローカルストレージへの保存や、単純な fetch 処理に対して、高機能なライブラリを使うことは、オーバーエンジニアリングになる場合があります。
以下の図は、ライブラリ使用時の課題を整理したものです。
mermaidflowchart LR
lib["ライブラリ使用"]
bundle["バンドルサイズ増加"]
learning["学習コスト"]
over["オーバーエンジニアリング"]
lib --> bundle
lib --> learning
lib --> over
bundle -->|影響| slow["初期ロード遅延"]
learning -->|影響| trouble["トラブル対応困難"]
over -->|影響| complex["不要な複雑性"]
どこまで自作すべきか?の判断基準が不明確
「このケースは VueUse を使うべき?それとも自作すべき?」という判断基準が明確でないと、プロジェクトごとに実装方針がバラバラになり、コードの一貫性が失われます。
特に以下のような状況では、判断が難しくなります。
| # | 状況 | 判断のポイント |
|---|---|---|
| 1 | 単純な localStorage の読み書き | VueUse の useStorage は必要か? |
| 2 | 単一エンドポイントへの fetch | VueUse の useFetch は必要か? |
| 3 | Pinia ストアのストレージ永続化 | プラグインか自作か? |
| 4 | エラーハンドリングのカスタマイズ | ライブラリの制約に縛られないか? |
これらの判断基準を明確にすることが、効率的な開発には不可欠です。
解決策
自作レシピで代替できる境界線
Pinia や VueUse を使うべきか、自作レシピで済ませるべきかの判断基準を明確にしましょう。以下の表は、その境界線を示したものです。
| # | 機能 | VueUse / Pinia を使うべきケース | 自作レシピで十分なケース |
|---|---|---|---|
| 1 | localStorage 連携 | 複数の型サポート、SSR 対応が必要 | 単純な文字列・JSON の保存のみ |
| 2 | fetch 処理 | リトライ、キャンセル、キャッシュが必要 | 単一エンドポイントへの単純な GET / POST |
| 3 | Pinia 永続化 | 複数ストア、暗号化、条件付き保存 | 単一ストアの全状態保存 |
| 4 | エラーハンドリング | 統一されたエラー処理フレームワーク | カスタムエラー処理ロジック |
| 5 | TypeScript サポート | 複雑な型推論、ジェネリクス対応 | シンプルな型定義 |
この表を基準にすることで、プロジェクトごとに一貫した判断ができます。
判断フローチャート
どちらを選ぶべきか迷ったときは、以下のフローチャートに従って判断しましょう。
mermaidflowchart TD
start["機能実装の必要性"] --> simple{"シンプルな処理<br/>のみ?"}
simple -->|はい| ssr{"SSR対応<br/>必要?"}
simple -->|いいえ| lib["VueUse / Pinia 使用"]
ssr -->|いいえ| type{"型安全性<br/>重視?"}
ssr -->|はい| lib
type -->|シンプルでOK| custom["自作レシピ"]
type -->|複雑な型推論| lib
custom --> maintain{"メンテナンス<br/>負荷は?"}
maintain -->|低い| done["自作採用"]
maintain -->|高い| lib
このフローに従うことで、適切な選択ができるようになります。
軽量自作レシピの設計方針
自作レシピを作成する際は、以下の設計方針を守ることで、保守性と拡張性を確保できます。
単一責任の原則
1 つの Composable 関数は 1 つの責任のみを持たせます。例えば、useLocalStorage はストレージの読み書きのみを担当し、バリデーションやエラーハンドリングは別関数に分離します。
TypeScript による型安全性
ジェネリクスを活用して、型安全な実装を心がけます。これにより、エディタの補完機能も効果的に使えます。
Composition API の活用
Vue 3 の Composition API を最大限に活用し、リアクティブな状態管理を実現します。ref、computed、watch などを適切に使い分けましょう。
エラーハンドリングの明確化
エラーが発生した場合の挙動を明確にし、呼び出し側で適切に処理できるようにします。
具体例
useStorage の自作実装
VueUse の useStorage に相当する、軽量な自作実装を見ていきましょう。
基本的な型定義
まず、localStorage と連携するための型定義を作成します。
typescript// types/storage.ts
export interface StorageOptions<T> {
key: string;
defaultValue: T;
serializer?: {
read: (raw: string) => T;
write: (value: T) => string;
};
}
この型定義では、キー名、デフォルト値、そしてオプションでシリアライザーを指定できるようにしています。シリアライザーを指定することで、JSON 以外の形式にも対応可能です。
コア実装
次に、実際に localStorage と連携する Composable 関数を実装します。
typescript// composables/useLocalStorage.ts
import { ref, watch, type Ref } from 'vue';
import type { StorageOptions } from '@/types/storage';
export function useLocalStorage<T>(
options: StorageOptions<T>
): Ref<T> {
const { key, defaultValue, serializer } = options;
// デフォルトのシリアライザー
const defaultSerializer = {
read: (raw: string): T => JSON.parse(raw) as T,
write: (value: T): string => JSON.stringify(value),
};
const ser = serializer || defaultSerializer;
// 初期値の読み込み処理は次のブロックで説明します
}
ここでは、ジェネリクス <T> を使って型安全性を確保し、デフォルトのシリアライザーとして JSON の parse / stringify を使用しています。
初期値の読み込み
localStorage から既存の値を読み込み、存在しない場合はデフォルト値を使います。
typescript// composables/useLocalStorage.ts (続き)
export function useLocalStorage<T>(
options: StorageOptions<T>
): Ref<T> {
// ...前述のコード
// 初期値の読み込み
const loadInitialValue = (): T => {
try {
const raw = localStorage.getItem(key);
if (raw === null) {
return defaultValue;
}
return ser.read(raw);
} catch (error) {
console.warn(
`Failed to load from localStorage: ${key}`,
error
);
return defaultValue;
}
};
const data = ref<T>(loadInitialValue()) as Ref<T>;
// リアクティブな監視処理は次のブロックで説明します
}
エラーハンドリングを含めることで、localStorage が利用できない環境でも安全に動作します。
リアクティブな保存
watch を使って、値が変更されたら自動的に localStorage に保存します。
typescript// composables/useLocalStorage.ts (続き)
export function useLocalStorage<T>(
options: StorageOptions<T>
): Ref<T> {
// ...前述のコード
// 値の変更を監視して自動保存
watch(
data,
(newValue) => {
try {
localStorage.setItem(key, ser.write(newValue));
} catch (error) {
console.error(
`Failed to save to localStorage: ${key}`,
error
);
}
},
{ deep: true } // オブジェクトの深い変更も検知
);
return data;
}
{ deep: true } オプションを指定することで、オブジェクトのネストされたプロパティが変更された場合も検知できます。
使用例
作成した useLocalStorage を実際に使ってみましょう。
typescript// components/UserPreferences.vue
<script setup lang="ts">
import { useLocalStorage } from '@/composables/useLocalStorage'
// ユーザー設定の型定義
interface UserSettings {
theme: 'light' | 'dark'
fontSize: number
notifications: boolean
}
// リアクティブな状態として使用
const settings = useLocalStorage<UserSettings>({
key: 'user-settings',
defaultValue: {
theme: 'light',
fontSize: 14,
notifications: true
}
})
// 値を変更すると自動的に localStorage に保存される
const toggleTheme = () => {
settings.value.theme = settings.value.theme === 'light' ? 'dark' : 'light'
}
</script>
このように、VueUse の useStorage と同等の機能を、わずか 30 行程度のコードで実現できます。
useFetch の自作実装
続いて、VueUse の useFetch に相当する自作実装を見ていきましょう。
型定義
まず、fetch 処理で使用する型を定義します。
typescript// types/fetch.ts
export interface FetchOptions {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
headers?: Record<string, string>;
body?: unknown;
}
export interface FetchState<T> {
data: Ref<T | null>;
error: Ref<Error | null>;
loading: Ref<boolean>;
}
FetchState は、データ、エラー、ローディング状態を保持するリアクティブな状態です。
コア実装
次に、実際に fetch を実行する Composable 関数を実装します。
typescript// composables/useSimpleFetch.ts
import { ref, type Ref } from 'vue';
import type {
FetchOptions,
FetchState,
} from '@/types/fetch';
export function useSimpleFetch<T>(
url: string,
options?: FetchOptions
): FetchState<T> & { execute: () => Promise<void> } {
const data = ref<T | null>(null);
const error = ref<Error | null>(null);
const loading = ref<boolean>(false);
// execute 関数は次のブロックで実装します
}
ここでは、状態を保持する ref を作成し、後述する execute 関数と一緒に返す準備をしています。
fetch 実行関数
実際に fetch を実行し、結果を状態に反映します。
typescript// composables/useSimpleFetch.ts (続き)
export function useSimpleFetch<T>(
url: string,
options?: FetchOptions
): FetchState<T> & { execute: () => Promise<void> } {
// ...前述のコード
const execute = async (): Promise<void> => {
loading.value = true;
error.value = null;
try {
const response = await fetch(url, {
method: options?.method || 'GET',
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
body: options?.body
? JSON.stringify(options.body)
: undefined,
});
// エラーハンドリングは次のブロックで説明します
} catch (err) {
// エラー処理は次のブロックで説明します
} finally {
loading.value = false;
}
};
return { data, error, loading, execute };
}
この関数は、fetch 実行前にローディング状態を true にし、完了後に false に戻します。
エラーハンドリング
HTTP エラーやネットワークエラーを適切に処理します。
typescript// composables/useSimpleFetch.ts (続き)
const execute = async (): Promise<void> => {
loading.value = true;
error.value = null;
try {
const response = await fetch(url, {
method: options?.method || 'GET',
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
body: options?.body
? JSON.stringify(options.body)
: undefined,
});
// HTTP エラーチェック
if (!response.ok) {
throw new Error(
`HTTP Error ${response.status}: ${response.statusText}`
);
}
// JSON パース
const result = (await response.json()) as T;
data.value = result;
} catch (err) {
// エラーオブジェクトの作成
if (err instanceof Error) {
error.value = err;
} else {
error.value = new Error('Unknown error occurred');
}
data.value = null;
} finally {
loading.value = false;
}
};
HTTP ステータスコードが 200 番台以外の場合は、エラーとして扱います。エラーメッセージには、ステータスコードとテキストを含めることで、デバッグしやすくしています。
使用例
作成した useSimpleFetch を実際に使ってみましょう。
typescript// components/UserList.vue
<script setup lang="ts">
import { onMounted } from 'vue'
import { useSimpleFetch } from '@/composables/useSimpleFetch'
// ユーザーデータの型定義
interface User {
id: number
name: string
email: string
}
// fetch の準備
const { data, error, loading, execute } = useSimpleFetch<User[]>(
'https://api.example.com/users'
)
// コンポーネントマウント時に実行
onMounted(() => {
execute()
})
</script>
<template>
<div>
<p v-if="loading">読み込み中...</p>
<p v-if="error" class="error">エラー: {{ error.message }}</p>
<ul v-if="data">
<li v-for="user in data" :key="user.id">
{{ user.name }} ({{ user.email }})
</li>
</ul>
</div>
</template>
このように、シンプルな fetch 処理であれば、わずか 40 行程度で実装できます。
Pinia との統合例
Pinia ストアに自作の useLocalStorage を統合して、状態を永続化してみましょう。
ストアの定義
まず、Pinia ストアを定義します。
typescript// stores/user.ts
import { defineStore } from 'pinia';
import { useLocalStorage } from '@/composables/useLocalStorage';
export const useUserStore = defineStore('user', () => {
// localStorage と連携した状態
const user = useLocalStorage<{
id: number | null;
name: string;
email: string;
}>({
key: 'user-data',
defaultValue: {
id: null,
name: '',
email: '',
},
});
// アクション定義は次のブロックで説明します
});
このように、useLocalStorage の戻り値をそのままストアの状態として使用できます。
アクションの実装
ユーザー情報を更新するアクションを実装します。
typescript// stores/user.ts (続き)
export const useUserStore = defineStore('user', () => {
// ...前述のコード
// ログイン処理
const login = (
id: number,
name: string,
email: string
) => {
user.value = { id, name, email };
// この変更は自動的に localStorage に保存される
};
// ログアウト処理
const logout = () => {
user.value = {
id: null,
name: '',
email: '',
};
};
// ゲッター
const isLoggedIn = computed(() => user.value.id !== null);
return {
user,
login,
logout,
isLoggedIn,
};
});
user.value を更新するだけで、自動的に localStorage に保存されるため、プラグイン不要で永続化が実現できます。
コンポーネントでの使用
作成したストアをコンポーネントで使用してみましょう。
typescript// components/LoginForm.vue
<script setup lang="ts">
import { ref } from 'vue'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
const name = ref('')
const email = ref('')
const handleLogin = () => {
userStore.login(
Date.now(), // 仮の ID
name.value,
email.value
)
}
</script>
<template>
<div v-if="!userStore.isLoggedIn">
<input v-model="name" placeholder="名前" />
<input v-model="email" placeholder="メール" />
<button @click="handleLogin">ログイン</button>
</div>
<div v-else>
<p>ようこそ、{{ userStore.user.name }} さん</p>
<button @click="userStore.logout">ログアウト</button>
</div>
</template>
このように、pinia-plugin-persistedstate を使わなくても、簡単に状態の永続化が実現できます。
エラーハンドリングの拡張
実際のプロダクション環境では、より詳細なエラーハンドリングが必要になります。ここでは、エラーコードを含めた拡張版を紹介します。
エラー型の定義
まず、エラー情報を詳細に保持する型を定義します。
typescript// types/error.ts
export interface AppError {
code: string;
message: string;
statusCode?: number;
details?: unknown;
}
export class FetchError extends Error implements AppError {
code: string;
statusCode?: number;
details?: unknown;
constructor(
code: string,
message: string,
statusCode?: number,
details?: unknown
) {
super(message);
this.code = code;
this.statusCode = statusCode;
this.details = details;
this.name = 'FetchError';
}
}
この FetchError クラスを使うことで、エラーコードや HTTP ステータスコードを含めた詳細なエラー情報を保持できます。
拡張版 useFetch
エラーコードを含む拡張版の useFetch を実装します。
typescript// composables/useEnhancedFetch.ts
import { ref, type Ref } from 'vue';
import { FetchError } from '@/types/error';
export function useEnhancedFetch<T>(url: string) {
const data = ref<T | null>(null);
const error = ref<FetchError | null>(null);
const loading = ref<boolean>(false);
const execute = async (): Promise<void> => {
loading.value = true;
error.value = null;
try {
const response = await fetch(url);
if (!response.ok) {
// HTTP エラーの詳細なハンドリング
throw new FetchError(
`HTTP_${response.status}`,
`Request failed: ${response.statusText}`,
response.status,
await response.text()
);
}
data.value = (await response.json()) as T;
} catch (err) {
if (err instanceof FetchError) {
error.value = err;
} else if (err instanceof TypeError) {
// ネットワークエラー
error.value = new FetchError(
'NETWORK_ERROR',
'Network request failed. Please check your connection.',
undefined,
err
);
} else {
error.value = new FetchError(
'UNKNOWN_ERROR',
'An unknown error occurred',
undefined,
err
);
}
} finally {
loading.value = false;
}
};
return { data, error, loading, execute };
}
このように実装することで、エラーの種類に応じた適切なエラーコードとメッセージを設定できます。
エラー表示コンポーネント
エラーコードに応じた表示を行うコンポーネントを作成します。
typescript// components/ErrorDisplay.vue
<script setup lang="ts">
import type { FetchError } from '@/types/error'
interface Props {
error: FetchError | null
}
const props = defineProps<Props>()
const getErrorMessage = (error: FetchError): string => {
switch (error.code) {
case 'HTTP_404':
return 'リソースが見つかりませんでした。'
case 'HTTP_500':
return 'サーバーエラーが発生しました。'
case 'NETWORK_ERROR':
return 'ネットワーク接続を確認してください。'
default:
return error.message
}
}
</script>
<template>
<div v-if="error" class="error-box">
<h3>エラーが発生しました</h3>
<p><strong>エラーコード:</strong> {{ error.code }}</p>
<p><strong>メッセージ:</strong> {{ getErrorMessage(error) }}</p>
<details v-if="error.details">
<summary>詳細情報</summary>
<pre>{{ error.details }}</pre>
</details>
</div>
</template>
<style scoped>
.error-box {
border: 1px solid #f44336;
background-color: #ffebee;
padding: 1rem;
border-radius: 4px;
color: #c62828;
}
details {
margin-top: 0.5rem;
}
pre {
background-color: #ffffff;
padding: 0.5rem;
border-radius: 4px;
overflow-x: auto;
}
</style>
このコンポーネントを使うことで、エラーコードに応じた適切なメッセージをユーザーに表示できます。
まとめ
この記事では、Pinia と VueUse の useStorage と useFetch について、それぞれの特徴と自作レシピとの比較を行いました。重要なポイントを振り返りましょう。
ライブラリを使うべきケースは、複雑な機能が必要な場合です。具体的には、SSR 対応、リトライ機能、キャンセル機能、キャッシュ機構、複数ストアの統合管理などが該当します。
自作レシピで十分なケースは、シンプルな処理のみで完結する場合です。単純な localStorage の読み書き、単一エンドポイントへの fetch、単一ストアの永続化などがこれに当たります。
判断基準としては、まず機能の複雑さを評価し、次に SSR 対応の必要性を確認し、最後にメンテナンス負荷を考慮します。この順序で判断することで、適切な選択ができるでしょう。
自作レシピのメリットは、軽量なバンドルサイズ、内部実装の完全な理解、柔軟なカスタマイズ性です。一方で、デメリットは、メンテナンスコスト、エッジケースへの対応、テストの自己責任という点です。
最終的には、プロジェクトの規模、チームのスキルレベル、パフォーマンス要件を総合的に判断して、最適な選択をすることが大切です。小規模なプロジェクトやプロトタイプでは自作レシピが有効ですが、大規模なプロダクションアプリケーションでは、VueUse や Pinia のプラグインを活用することで、開発効率を大幅に向上させることができます。
ぜひ、この記事で紹介した判断基準とコード例を参考に、皆さんのプロジェクトに最適な実装方法を選択してください。
関連リンク
articlePinia と VueUse の useStorage/useFetch 比較:軽量レシピで代替できる境界
articlePinia ストア間の循環参照を断つ:依存分解とイベント駆動の現場テク
articlePinia 2025 アップデート総まとめ:非互換ポイントと安全な移行チェックリスト
articlePinia 可観測性の作り方:DevTools × OpenTelemetry で変更を可視化する
articlePinia 正規化データ設計:Entity アダプタで巨大リストを高速・一貫に保つ
articlePinia ストア分割テンプレ集:domain/ui/session の三層パターン
articleReact クリーンアーキテクチャ実践:UI・アプリ・ドメイン・データの責務分離
articleWebLLM vs サーバー推論 徹底比較:レイテンシ・コスト・スケールの実測レポート
articleVitest モック技術比較:MSW / `vi.mock` / 手動スタブ — API テストの最適解はどれ?
articlePython ORMs 実力検証:SQLAlchemy vs Tortoise vs Beanie の選び方
articleVite で Web Worker / SharedWorker を TypeScript でバンドルする初期設定
articlePrisma Accelerate と PgBouncer を比較:サーバレス時代の接続戦略ベンチ
blogiPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
blogGoogleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
blog【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
blogGoogleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
blogPixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
blogフロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
review今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
reviewついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
review愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
review週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
review新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
review科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来