T-CREATOR

Pinia と VueUse の useStorage/useFetch 比較:軽量レシピで代替できる境界

Pinia と VueUse の useStorage/useFetch 比較:軽量レシピで代替できる境界

Vue.js でアプリケーションを開発していると、状態管理やデータ取得、ローカルストレージの連携など、よく使う機能を効率的に実装したくなりますよね。そんなとき、Pinia や VueUse といったライブラリが便利です。しかし、全ての機能をライブラリに頼る必要はありません。

この記事では、Pinia と VueUse の useStorageuseFetch について、それぞれの特徴を比較し、どのような場面で軽量な自作レシピで代替できるのか、その境界線を明らかにしていきます。実装コード例も豊富に用意しましたので、ぜひ最後までお読みください。

背景

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 関数を提供しており、以下のような機能が含まれています。

#カテゴリ代表的な関数用途
1StateuseStorage, useLocalStorageストレージ連携
2NetworkuseFetch, useWebSocketデータ取得・通信
3BrowseruseClipboard, useFullscreenブラウザ API
4SensorsuseMouse, useScrollユーザー操作検知
5AnimationuseTransition, 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単一エンドポイントへの fetchVueUse の useFetch は必要か?
3Pinia ストアのストレージ永続化プラグインか自作か?
4エラーハンドリングのカスタマイズライブラリの制約に縛られないか?

これらの判断基準を明確にすることが、効率的な開発には不可欠です。

解決策

自作レシピで代替できる境界線

Pinia や VueUse を使うべきか、自作レシピで済ませるべきかの判断基準を明確にしましょう。以下の表は、その境界線を示したものです。

#機能VueUse / Pinia を使うべきケース自作レシピで十分なケース
1localStorage 連携複数の型サポート、SSR 対応が必要単純な文字列・JSON の保存のみ
2fetch 処理リトライ、キャンセル、キャッシュが必要単一エンドポイントへの単純な GET / POST
3Pinia 永続化複数ストア、暗号化、条件付き保存単一ストアの全状態保存
4エラーハンドリング統一されたエラー処理フレームワークカスタムエラー処理ロジック
5TypeScript サポート複雑な型推論、ジェネリクス対応シンプルな型定義

この表を基準にすることで、プロジェクトごとに一貫した判断ができます。

判断フローチャート

どちらを選ぶべきか迷ったときは、以下のフローチャートに従って判断しましょう。

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 を最大限に活用し、リアクティブな状態管理を実現します。refcomputedwatch などを適切に使い分けましょう。

エラーハンドリングの明確化

エラーが発生した場合の挙動を明確にし、呼び出し側で適切に処理できるようにします。

具体例

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 の useStorageuseFetch について、それぞれの特徴と自作レシピとの比較を行いました。重要なポイントを振り返りましょう。

ライブラリを使うべきケースは、複雑な機能が必要な場合です。具体的には、SSR 対応、リトライ機能、キャンセル機能、キャッシュ機構、複数ストアの統合管理などが該当します。

自作レシピで十分なケースは、シンプルな処理のみで完結する場合です。単純な localStorage の読み書き、単一エンドポイントへの fetch、単一ストアの永続化などがこれに当たります。

判断基準としては、まず機能の複雑さを評価し、次に SSR 対応の必要性を確認し、最後にメンテナンス負荷を考慮します。この順序で判断することで、適切な選択ができるでしょう。

自作レシピのメリットは、軽量なバンドルサイズ、内部実装の完全な理解、柔軟なカスタマイズ性です。一方で、デメリットは、メンテナンスコスト、エッジケースへの対応、テストの自己責任という点です。

最終的には、プロジェクトの規模、チームのスキルレベル、パフォーマンス要件を総合的に判断して、最適な選択をすることが大切です。小規模なプロジェクトやプロトタイプでは自作レシピが有効ですが、大規模なプロダクションアプリケーションでは、VueUse や Pinia のプラグインを活用することで、開発効率を大幅に向上させることができます。

ぜひ、この記事で紹介した判断基準とコード例を参考に、皆さんのプロジェクトに最適な実装方法を選択してください。

関連リンク