T-CREATOR

Pinia × VueUse × Vite 雛形:型安全ストアとユーティリティを最短で組む

Pinia × VueUse × Vite 雛形:型安全ストアとユーティリティを最短で組む

Vue 3 のエコシステムで状態管理を始める際、Pinia、VueUse、Vite の組み合わせは最強の選択肢です。この記事では、型安全性を保ちながら、実用的なストアとユーティリティを最短で構築する雛形をご紹介します。開発効率を劇的に向上させる構成を、実際のコード例とともに解説していきますね。

背景

Vue 3 エコシステムにおける状態管理の進化

Vue 3 がリリースされて以降、状態管理の世界は大きく変わりました。Vuex に代わる公式推奨の状態管理ライブラリとして Pinia が登場し、Composition API との親和性が格段に向上しています。

また、VueUse という豊富なユーティリティコレクションにより、よくある機能を車輪の再発明することなく実装できるようになりました。さらに Vite の登場により、開発サーバーの起動時間や HMR(Hot Module Replacement)の速度が飛躍的に改善されています。

以下の図は、Vue 3 エコシステムにおける各ライブラリの関係性を示します。

mermaidflowchart TB
  app["Vue 3 アプリケーション"]
  pinia["Pinia<br/>状態管理"]
  vueuse["VueUse<br/>ユーティリティ"]
  vite["Vite<br/>ビルドツール"]

  vite -->|"高速開発環境"| app
  app -->|"状態管理"| pinia
  app -->|"便利機能"| vueuse
  pinia -.->|"組み合わせ"| vueuse

  style pinia fill:#42b883
  style vueuse fill:#41b883
  style vite fill:#646cff

この図から分かるように、Vite が高速な開発環境を提供し、その上で Pinia と VueUse が協力して強力な機能を実現しています。

TypeScript による型安全性の重要性

現代のフロントエンド開発において、TypeScript の採用は必須となっています。型安全性により、コンパイル時にエラーを検出でき、リファクタリングも安全に行えます。

特に Pinia は、TypeScript との統合が非常に優れており、ストアの状態やアクションに対して完全な型推論が得られるのです。

課題

従来の状態管理における問題点

Pinia、VueUse、Vite を組み合わせる際、いくつかの課題に直面します。以下は主な問題点です。

1. 型定義の複雑さ

Pinia のストアで型安全性を保つには、適切な型定義が必要です。しかし、初心者にとっては型定義の書き方が分かりにくく、エラーに悩まされることが多いでしょう。

2. ボイラープレートコードの増加

各ストアごとに同じようなセットアップコードを書く必要があり、プロジェクトが大きくなるにつれてコードの重複が発生します。

3. VueUse との統合パターンの不明確さ

VueUse の機能を Pinia ストア内で使う際、どのように統合すべきか明確なパターンがありません。reactive、ref、computed などの使い分けも迷いどころです。

以下の図は、これらの課題がどのように相互に影響するかを示しています。

mermaidflowchart LR
  complex["型定義の<br/>複雑さ"]
  boiler["ボイラープレート<br/>の増加"]
  pattern["統合パターンの<br/>不明確さ"]

  complex -->|"学習コスト増"| dev["開発効率の低下"]
  boiler -->|"保守性悪化"| dev
  pattern -->|"実装ミス"| dev

  dev -->|"品質低下"| product["プロダクト品質"]
  dev -->|"納期遅延"| product

これらの課題は開発効率を低下させ、最終的にプロダクトの品質にも影響を及ぼします。

設定ファイルとビルド環境の複雑化

Vite の設定、TypeScript の設定、ESLint や Prettier の設定など、多くの設定ファイルを適切に管理する必要があります。これらが正しく連携していないと、型チェックが機能しなかったり、ビルドエラーが発生したりするのです。

解決策

型安全なストア雛形の構築アプローチ

これらの課題を解決するため、以下の 3 つの柱で雛形を構築します。

★ 明確な型定義パターンの確立

interface と type を適切に使い分け、ストアの状態とアクションに対して完全な型推論を実現します。

★ 再利用可能なコンポーザブルの作成

VueUse のユーティリティをラップした独自コンポーザブルを作成し、プロジェクト全体で一貫した実装を保ちます。

★ 最小限の設定で最大限の効果

Vite の設定を最適化し、開発体験とビルド速度の両立を図ります。

以下の図は、解決策のアーキテクチャを示します。

mermaidflowchart TB
  subgraph solution["解決策アーキテクチャ"]
    types["型定義層<br/>interfaces & types"]
    store["ストア層<br/>Pinia stores"]
    composable["コンポーザブル層<br/>VueUse wrappers"]
    config["設定層<br/>Vite config"]
  end

  types -->|"型情報提供"| store
  types -->|"型情報提供"| composable
  composable -->|"機能提供"| store
  config -->|"ビルド環境"| store
  config -->|"ビルド環境"| composable

  store -->|"状態管理"| component["Vueコンポーネント"]
  composable -->|"ユーティリティ"| component

この階層化されたアーキテクチャにより、各レイヤーの責任が明確になり、保守性が向上します。

具体例

プロジェクトセットアップ

まずは Vite でプロジェクトを作成しましょう。以下のコマンドで Vue 3 + TypeScript のプロジェクトを初期化します。

bashyarn create vite my-pinia-app --template vue-ts
cd my-pinia-app

次に、必要なパッケージをインストールします。Pinia、VueUse、および開発に便利なプラグインを追加しましょう。

bashyarn add pinia
yarn add @vueuse/core
yarn add -D @types/node

Vite 設定の最適化

Vite の設定ファイルを最適化し、型安全性と開発効率を両立させます。パスエイリアスを設定することで、インポート文がすっきりしますね。

typescript// vite.config.ts
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { resolve } from 'path';

export default defineConfig({
  plugins: [vue()],

  // パスエイリアスの設定
  resolve: {
    alias: {
      '@': resolve(__dirname, 'src'),
      '@stores': resolve(__dirname, 'src/stores'),
      '@composables': resolve(__dirname, 'src/composables'),
      '@types': resolve(__dirname, 'src/types'),
    },
  },
});

このエイリアス設定により、..​/​..​/​..​/​stores​/​userのような相対パスではなく、@stores​/​userとシンプルに記述できます。

typescript  // 開発サーバー設定
  server: {
    port: 3000,
    open: true,
    // HMRの最適化
    hmr: {
      overlay: true,
    },
  },

開発サーバーは 3000 番ポートで起動し、ブラウザが自動的に開くように設定しています。HMR のオーバーレイ表示により、エラーをすぐに確認できるでしょう。

typescript  // ビルド設定
  build: {
    target: 'esnext',
    minify: 'esbuild',
    // チャンク分割の最適化
    rollupOptions: {
      output: {
        manualChunks: {
          'vue-vendor': ['vue', 'pinia'],
          'vueuse-vendor': ['@vueuse/core'],
        },
      },
    },
  },
})

ビルド時には、Vue 関連と VueUse 関連のライブラリを別チャンクに分割し、キャッシュ効率を高めています。

TypeScript 設定の強化

TypeScript の設定を厳格化し、型安全性を最大限に高めます。以下の設定により、潜在的なバグを事前に発見できますよ。

json{
  "compilerOptions": {
    "target": "ESNext",
    "useDefineForClassFields": true,
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "jsx": "preserve",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "esModuleInterop": true,
    "lib": ["ESNext", "DOM", "DOM.Iterable"],
    "skipLibCheck": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true

strict: trueにより、すべての厳格な型チェックが有効になります。noUnusedLocalsnoUnusedParametersは、使われていない変数や引数を検出してくれるのです。

json    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"],
      "@stores/*": ["src/stores/*"],
      "@composables/*": ["src/composables/*"],
      "@types/*": ["src/types/*"]
    },
    "types": ["vite/client"]
  },
  "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
  "references": [{ "path": "./tsconfig.node.json" }]
}

パスマッピングを Vite 設定と一致させることで、IDE とビルドツールの両方で正しく解決されます。

型定義の基礎構築

プロジェクト全体で使用する基本的な型定義を作成します。これらの型は、ストアやコンポーザブルで再利用されるでしょう。

typescript// src/types/common.ts

/**
 * API レスポンスの基本型
 */
export interface ApiResponse<T> {
  data: T;
  message: string;
  status: number;
}

API 通信の結果を表現する汎用的な型です。ジェネリクス<T>により、任意のデータ型に対応できます。

typescript/**
 * ページネーション情報
 */
export interface Pagination {
  currentPage: number;
  totalPages: number;
  perPage: number;
  total: number;
}

一覧表示でよく使うページネーション情報を型定義しています。

typescript/**
 * 読み込み状態を管理する型
 */
export interface LoadingState {
  isLoading: boolean;
  error: Error | null;
}

非同期処理の状態管理に使う型定義です。エラー情報も含めることで、エラーハンドリングが容易になりますね。

typescript/**
 * ユーザー情報の型
 */
export interface User {
  id: number;
  name: string;
  email: string;
  avatar?: string;
  createdAt: Date;
  updatedAt: Date;
}

アプリケーションで扱うユーザー情報の型です。オプショナルなavatarフィールドにより、柔軟なデータ構造を実現しています。

型安全な Pinia ストアの作成

Pinia ストアを型安全に作成します。Setup Store 構文を使うことで、Composition API と同じ書き心地で実装できるでしょう。

typescript// src/stores/user.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import type { User } from '@types/common';

/**
 * ユーザーストアの型定義
 */
export interface UserStoreState {
  currentUser: User | null;
  users: User[];
  isLoading: boolean;
  error: Error | null;
}

ストアが持つ状態を明示的にインターフェースで定義します。これにより、状態の構造が一目で分かりますね。

typescriptexport const useUserStore = defineStore('user', () => {
  // State
  const currentUser = ref<User | null>(null)
  const users = ref<User[]>([])
  const isLoading = ref(false)
  const error = ref<Error | null>(null)

refを使って状態を定義しています。型パラメータを明示することで、完全な型推論が得られます。

typescript// Getters
const isAuthenticated = computed(
  () => currentUser.value !== null
);

const userCount = computed(() => users.value.length);

const getUserById = computed(() => {
  return (id: number) =>
    users.value.find((u) => u.id === id);
});

Getters はcomputedで実装します。getUserByIdのように関数を返すことで、引数付きの Getter も実現できるのです。

typescript// Actions
async function fetchCurrentUser(): Promise<void> {
  isLoading.value = true;
  error.value = null;

  try {
    // API呼び出し(実際のエンドポイントに置き換えてください)
    const response = await fetch('/api/user/me');
    const data = await response.json();

    currentUser.value = data;
  } catch (e) {
    error.value =
      e instanceof Error ? e : new Error('Unknown error');
    throw error.value;
  } finally {
    isLoading.value = false;
  }
}

非同期処理を含むアクションでは、loading 状態とエラー状態を適切に管理します。finallyブロックで必ずisLoadingを false にするのがポイントですね。

typescriptasync function fetchUsers(): Promise<void> {
  isLoading.value = true;
  error.value = null;

  try {
    const response = await fetch('/api/users');
    const data = await response.json();

    users.value = data;
  } catch (e) {
    error.value =
      e instanceof Error ? e : new Error('Unknown error');
    throw error.value;
  } finally {
    isLoading.value = false;
  }
}

ユーザー一覧を取得するアクションも同様のパターンで実装します。

typescriptfunction setCurrentUser(user: User | null): void {
  currentUser.value = user;
}

function clearError(): void {
  error.value = null;
}

function $reset(): void {
  currentUser.value = null;
  users.value = [];
  isLoading.value = false;
  error.value = null;
}

同期的なアクションはシンプルに実装します。$resetメソッドは、ストアを初期状態に戻すための便利な機能です。

typescript  // 公開するプロパティとメソッド
  return {
    // State
    currentUser,
    users,
    isLoading,
    error,
    // Getters
    isAuthenticated,
    userCount,
    getUserById,
    // Actions
    fetchCurrentUser,
    fetchUsers,
    setCurrentUser,
    clearError,
    $reset,
  }
})

最後に、公開するプロパティとメソッドを返します。この構造により、完全な型推論が得られるのです。

VueUse を活用したコンポーザブルの作成

VueUse の機能をラップした独自コンポーザブルを作成し、プロジェクト全体で一貫した使い方を実現します。

typescript// src/composables/useLocalStorage.ts
import { useStorage } from '@vueuse/core';
import type { RemovableRef } from '@vueuse/core';

/**
 * LocalStorageを型安全に使うためのコンポーザブル
 */
export function useTypedLocalStorage<T>(
  key: string,
  defaultValue: T
): RemovableRef<T> {
  return useStorage(key, defaultValue, localStorage, {
    mergeDefaults: true,
  });
}

VueUse のuseStorageをラップし、型パラメータを受け取れるようにしています。これにより、LocalStorage に保存するデータも型安全になりますね。

typescript// 使用例の型定義
export interface AppSettings {
  theme: 'light' | 'dark';
  language: 'ja' | 'en';
  notifications: boolean;
}

// 使用例
export function useAppSettings() {
  const settings = useTypedLocalStorage<AppSettings>(
    'app-settings',
    {
      theme: 'light',
      language: 'ja',
      notifications: true,
    }
  );

  return { settings };
}

実際の使用例です。設定情報を LocalStorage に永続化しながら、完全な型安全性を保てます。

Pinia ストア内で VueUse を統合

Pinia ストアと VueUse を組み合わせることで、さらに強力な機能を実現できます。

typescript// src/stores/settings.ts
import { defineStore } from 'pinia'
import { useTypedLocalStorage } from '@composables/useLocalStorage'
import { useDark, useToggle } from '@vueuse/core'
import type { AppSettings } from '@composables/useLocalStorage'

export const useSettingsStore = defineStore('settings', () => {
  // LocalStorageと同期する設定
  const settings = useTypedLocalStorage<AppSettings>('app-settings', {
    theme: 'light',
    language: 'ja',
    notifications: true,
  })

LocalStorage と同期する設定を、先ほど作成したコンポーザブルで管理します。

typescript// ダークモードの管理
const isDark = useDark({
  storageKey: 'theme-mode',
  valueDark: 'dark',
  valueLight: 'light',
});

const toggleDark = useToggle(isDark);

VueUse のuseDarkを使うことで、システムのテーマ設定とも連動したダークモード切り替えが簡単に実装できるのです。

typescript// アクション
function updateLanguage(language: 'ja' | 'en'): void {
  settings.value.language = language;
}

function toggleNotifications(): void {
  settings.value.notifications =
    !settings.value.notifications;
}

function resetSettings(): void {
  settings.value = {
    theme: 'light',
    language: 'ja',
    notifications: true,
  };
}

設定を変更するアクションを定義します。LocalStorage への保存は自動的に行われるため、コードがシンプルになりますね。

typescript  return {
    settings,
    isDark,
    toggleDark,
    updateLanguage,
    toggleNotifications,
    resetSettings,
  }
})

ストアから公開するプロパティとメソッドを返します。

カスタムコンポーザブルで API 呼び出しを抽象化

API 呼び出しのパターンを抽象化し、再利用可能なコンポーザブルを作成しましょう。

typescript// src/composables/useApi.ts
import { ref } from 'vue';
import type { Ref } from 'vue';
import type {
  ApiResponse,
  LoadingState,
} from '@types/common';

/**
 * API呼び出しの結果を管理する型
 */
export interface UseApiReturn<T> extends LoadingState {
  data: Ref<T | null>;
  execute: (...args: unknown[]) => Promise<void>;
}

API 呼び出しの結果を表現する型定義です。データ、ローディング状態、エラー、実行関数を含んでいます。

typescript/**
 * API呼び出しを型安全に行うコンポーザブル
 */
export function useApi<T>(
  apiFunction: (...args: unknown[]) => Promise<ApiResponse<T>>
): UseApiReturn<T> {
  const data = ref<T | null>(null)
  const isLoading = ref(false)
  const error = ref<Error | null>(null)

ジェネリック関数として実装することで、あらゆる型の API レスポンスに対応できます。

typescriptasync function execute(...args: unknown[]): Promise<void> {
  isLoading.value = true;
  error.value = null;

  try {
    const response = await apiFunction(...args);
    data.value = response.data;
  } catch (e) {
    error.value =
      e instanceof Error ? e : new Error('API Error');
    throw error.value;
  } finally {
    isLoading.value = false;
  }
}

実行関数では、loading 状態を管理しながら API 関数を呼び出します。エラーハンドリングも統一されていますね。

typescript  return {
    data,
    isLoading,
    error,
    execute,
  }
}

結果をオブジェクトで返すことで、destructuring で必要なプロパティだけを取り出せます。

フェッチコンポーザブルの実装

VueUse のuseFetchを拡張し、プロジェクト固有の要件に対応します。

typescript// src/composables/useFetch.ts
import { useFetch as vueUseFetch } from '@vueuse/core'
import type { UseFetchOptions, UseFetchReturn } from '@vueuse/core'

/**
 * プロジェクト固有のフェッチコンポーザブル
 */
export function useFetch<T>(
  url: string,
  options?: UseFetchOptions
): UseFetchReturn<T> {
  // ベースURLの設定
  const baseURL = import.meta.env.VITE_API_BASE_URL || '/api'
  const fullURL = url.startsWith('http') ? url : `${baseURL}${url}`

環境変数からベース URL を取得し、相対 URL を完全な URL に変換しています。

typescript  // 共通ヘッダーの設定
  const defaultOptions: UseFetchOptions = {
    beforeFetch({ options }) {
      const token = localStorage.getItem('auth-token')

      if (token) {
        options.headers = {
          ...options.headers,
          Authorization: `Bearer ${token}`,
        }
      }

      return { options }
    },

beforeFetchフックを使って、すべてのリクエストに認証トークンを自動的に付与します。

typescript    afterFetch({ data, response }) {
      // レスポンスの変換処理
      if (response.status === 401) {
        // 認証エラー時の処理
        localStorage.removeItem('auth-token')
        window.location.href = '/login'
      }

      return { data }
    },

afterFetchフックで、認証エラー時に自動的にログイン画面にリダイレクトします。

typescript    onFetchError({ error }) {
      console.error('Fetch error:', error)
      return { error }
    },
  }

  return vueUseFetch<T>(fullURL, {
    ...defaultOptions,
    ...options,
  }).json()
}

エラーハンドリングを設定し、オプションをマージして VueUse のuseFetchを呼び出します。.json()により、JSON パースも自動化されるのです。

Vue コンポーネントでの使用例

作成したストアとコンポーザブルを Vue コンポーネントで使用する例を見ていきましょう。

vue<script setup lang="ts">
import { onMounted } from 'vue'
import { useUserStore } from '@stores/user'
import { useSettingsStore } from '@stores/settings'
import { storeToRefs } from 'pinia'

// ストアの取得
const userStore = useUserStore()
const settingsStore = useSettingsStore()

<script setup>構文により、シンプルにストアを取得できます。

vue// リアクティブな参照を取得 const { currentUser, users,
isLoading, error } = storeToRefs(userStore) const {
isAuthenticated, userCount } = storeToRefs(userStore) //
アクションは直接取得可能 const { fetchCurrentUser,
fetchUsers } = userStore

storeToRefsを使うことで、state と getters をリアクティブな参照として取り出せます。アクションは直接 destructuring して問題ありません。

vue// 初期化処理
onMounted(async () => {
  try {
    await fetchCurrentUser()
    await fetchUsers()
  } catch (e) {
    console.error('Failed to fetch user data:', e)
  }
})
</script>

コンポーネントのマウント時にデータを取得します。エラーハンドリングも忘れずに行いましょう。

vue<template>
  <div>
    <div v-if="isLoading" class="loading">
      読み込み中...
    </div>

    <div v-else-if="error" class="error">
      エラーが発生しました: {{ error.message }}
    </div>
  </div>
</template>

loading 状態とエラー状態を適切に表示します。

vue<div v-else-if="isAuthenticated">
      <h2>ようこそ、{{ currentUser?.name }}さん</h2>
      <p>登録ユーザー数: {{ userCount }}</p>

      <ul>
        <li v-for="user in users" :key="user.id">
          {{ user.name }} ({{ user.email }})
        </li>
      </ul>
    </div>

ストアの状態を使って、UI を構築します。オプショナルチェイニング(?.)により、null チェックも安全に行えますね。

vue    <div v-else>
      <p>ログインしてください</p>
    </div>
  </div>
</template>

認証されていない場合の UI も用意します。

ストアの永続化とリセット

アプリケーション全体でストアの状態を永続化し、必要に応じてリセットする機能を実装しましょう。

typescript// src/composables/useStorePersist.ts
import { watch } from 'vue';
import type { Store } from 'pinia';

/**
 * ストアの状態を永続化するオプション
 */
export interface PersistOptions {
  key?: string;
  storage?: Storage;
  paths?: string[];
}

永続化オプションの型定義です。保存先のキーやストレージ、特定のパスのみを永続化する設定が可能です。

typescript/**
 * ストアの状態をLocalStorageに永続化するコンポーザブル
 */
export function useStorePersist(
  store: Store,
  options: PersistOptions = {}
): void {
  const {
    key = `pinia-${store.$id}`,
    storage = localStorage,
    paths,
  } = options

デフォルトではストア ID をキーに使い、LocalStorage に保存します。

typescript// 保存された状態を復元
const savedState = storage.getItem(key);
if (savedState) {
  try {
    const parsed = JSON.parse(savedState);
    store.$patch(parsed);
  } catch (e) {
    console.error('Failed to restore state:', e);
  }
}

アプリケーション起動時に、保存された状態を復元します。JSON パースエラーにも対応していますね。

typescript  // 状態の変更を監視して保存
  watch(
    () => store.$state,
    (state) => {
      try {
        let toSave = state

        // 特定のパスのみを保存
        if (paths) {
          toSave = paths.reduce((acc, path) => {
            const value = path.split('.').reduce((obj, key) => obj?.[key], state as unknown)
            return { ...acc, [path]: value }
          }, {})
        }

        storage.setItem(key, JSON.stringify(toSave))
      } catch (e) {
        console.error('Failed to persist state:', e)
      }
    },
    { deep: true }
  )
}

状態の変更を監視し、自動的に LocalStorage に保存します。deep: trueにより、ネストしたオブジェクトの変更も検出できるのです。

Pinia プラグインとしての実装

永続化機能を Pinia プラグインとして実装することで、すべてのストアに自動適用できます。

typescript// src/stores/plugins/persist.ts
import type { PiniaPluginContext } from 'pinia';
import { watch } from 'vue';

/**
 * ストア定義に永続化オプションを追加する型拡張
 */
declare module 'pinia' {
  export interface DefineStoreOptionsBase<S, Store> {
    persist?:
      | boolean
      | {
          key?: string;
          storage?: Storage;
          paths?: string[];
        };
  }
}

TypeScript の型拡張により、ストア定義時にpersistオプションを指定できるようにします。

typescript/**
 * Pinia永続化プラグイン
 */
export function createPersistedState() {
  return ({ options, store }: PiniaPluginContext) => {
    if (!options.persist) return

    const persistOptions = typeof options.persist === 'boolean'
      ? {}
      : options.persist

    const {
      key = `pinia-${store.$id}`,
      storage = localStorage,
      paths,
    } = persistOptions

プラグインでは、ストアごとに永続化オプションをチェックします。

typescript    // 状態の復元
    const savedState = storage.getItem(key)
    if (savedState) {
      try {
        store.$patch(JSON.parse(savedState))
      } catch (e) {
        console.error(`Failed to restore ${store.$id}:`, e)
      }
    }

    // 状態の永続化
    watch(
      () => store.$state,
      (state) => {
        try {
          let toSave = state
          if (paths) {
            toSave = paths.reduce((acc, path) => {
              const keys = path.split('.')
              const value = keys.reduce((obj, key) => obj?.[key], state as unknown)
              return { ...acc, [path]: value }
            }, {})
          }
          storage.setItem(key, JSON.stringify(toSave))
        } catch (e) {
          console.error(`Failed to persist ${store.$id}:`, e)
        }
      },
      { deep: true }
    )
  }
}

プラグイン内で復元と永続化の処理を行います。これにより、各ストアで個別に実装する必要がなくなりますね。

Pinia のセットアップ

アプリケーションのエントリーポイントで Pinia を設定します。

typescript// src/main.ts
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import { createPersistedState } from '@stores/plugins/persist';
import App from './App.vue';

const app = createApp(App);

Vue アプリケーションのインスタンスを作成します。

typescript// Piniaインスタンスの作成
const pinia = createPinia();

// プラグインの登録
pinia.use(createPersistedState());

app.use(pinia);

永続化プラグインを登録してから、Pinia をアプリケーションに登録します。

typescriptapp.mount('#app');

最後にアプリケーションをマウントします。これで完全なセットアップが完了しました。

永続化を有効にしたストアの例

永続化機能を使ったストアの実装例を見てみましょう。

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

export interface CartItem {
  id: number
  name: string
  price: number
  quantity: number
}

export const useCartStore = defineStore('cart', () => {
  const items = ref<CartItem[]>([])

カート内の商品を管理する状態です。

typescriptconst totalItems = computed(() =>
  items.value.reduce((sum, item) => sum + item.quantity, 0)
);

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

合計数と合計金額を計算する getters です。

typescriptfunction addItem(item: Omit<CartItem, 'quantity'>): void {
  const existingItem = items.value.find(
    (i) => i.id === item.id
  );

  if (existingItem) {
    existingItem.quantity++;
  } else {
    items.value.push({ ...item, quantity: 1 });
  }
}

function removeItem(id: number): void {
  const index = items.value.findIndex((i) => i.id === id);
  if (index !== -1) {
    items.value.splice(index, 1);
  }
}

function updateQuantity(
  id: number,
  quantity: number
): void {
  const item = items.value.find((i) => i.id === id);
  if (item) {
    item.quantity = Math.max(0, quantity);
  }
}

function clearCart(): void {
  items.value = [];
}

カート操作のアクションを定義します。

typescript  return {
    items,
    totalItems,
    totalPrice,
    addItem,
    removeItem,
    updateQuantity,
    clearCart,
  }
}, {
  // 永続化を有効化
  persist: true,
})

最後のpersist: trueにより、このストアの状態が自動的に LocalStorage に保存されるのです。ページをリロードしても、カートの内容が保持されますね。

テストの書き方

型安全なストアとコンポーザブルのテストを書きましょう。Vitest を使った例です。

typescript// src/stores/__tests__/user.test.ts
import { setActivePinia, createPinia } from 'pinia'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { useUserStore } from '../user'

describe('UserStore', () => {
  beforeEach(() => {
    // 各テストの前にPiniaインスタンスを初期化
    setActivePinia(createPinia())
  })

テスト前に新しい Pinia インスタンスを作成することで、テスト間の独立性を保ちます。

typescriptit('初期状態が正しいこと', () => {
  const store = useUserStore();

  expect(store.currentUser).toBeNull();
  expect(store.users).toEqual([]);
  expect(store.isLoading).toBe(false);
  expect(store.error).toBeNull();
  expect(store.isAuthenticated).toBe(false);
});

ストアの初期状態を検証します。

typescriptit('ユーザーをセットできること', () => {
  const store = useUserStore();
  const mockUser = {
    id: 1,
    name: 'Test User',
    email: 'test@example.com',
    createdAt: new Date(),
    updatedAt: new Date(),
  };

  store.setCurrentUser(mockUser);

  expect(store.currentUser).toEqual(mockUser);
  expect(store.isAuthenticated).toBe(true);
});

アクションの動作を確認します。型安全性により、モックデータも正しい型で定義できますね。

typescript  it('エラーをクリアできること', () => {
    const store = useUserStore()
    store.error = new Error('Test error')

    store.clearError()

    expect(store.error).toBeNull()
  })

  it('リセットで初期状態に戻ること', () => {
    const store = useUserStore()
    const mockUser = {
      id: 1,
      name: 'Test User',
      email: 'test@example.com',
      createdAt: new Date(),
      updatedAt: new Date(),
    }

    store.setCurrentUser(mockUser)
    store.$reset()

    expect(store.currentUser).toBeNull()
    expect(store.isAuthenticated).toBe(false)
  })
})

リセット機能のテストも重要です。これらのテストにより、ストアの動作が保証されます。

プロジェクト構成の全体像

最後に、これまで作成したファイルの全体構成を確認しましょう。

#ディレクトリ/ファイル役割
1src/types/common.ts共通型定義
2src/stores/user.tsユーザーストア
3src/stores/settings.ts設定ストア
4src/stores/cart.tsカートストア
5src/stores/plugins/persist.ts永続化プラグイン
6src/composables/useLocalStorage.tsLocalStorage コンポーザブル
7src/composables/useApi.tsAPI 呼び出しコンポーザブル
8src/composables/useFetch.tsフェッチコンポーザブル
9src/composables/useStorePersist.tsストア永続化コンポーザブル
10vite.config.tsVite 設定
11tsconfig.jsonTypeScript 設定
12src/main.tsエントリーポイント

この構成により、型安全性を保ちながら、保守性の高いコードベースが実現できました。

まとめ

Pinia × VueUse × Vite の組み合わせで、型安全なストアとユーティリティを最短で構築する方法をご紹介しました。

重要なポイントを振り返ってみましょう。

型定義を明確にする

interface と type を適切に使い分け、ストアの状態とアクションに対して完全な型推論を実現することで、コンパイル時にエラーを検出できます。

再利用可能なパターンを確立する

コンポーザブルとプラグインを活用することで、プロジェクト全体で一貫した実装が保たれ、保守性が向上します。

Vite と TypeScript の設定を最適化する

パスエイリアスや厳格な型チェックにより、開発効率と品質の両立が可能になるのです。

VueUse との統合を活用する

VueUse の豊富なユーティリティを Pinia ストアと組み合わせることで、車輪の再発明を避けながら強力な機能を実装できます。

永続化機能を自動化する

Pinia プラグインとして永続化機能を実装することで、各ストアで個別に実装する手間が省けますね。

この雛形をベースにすることで、プロジェクトの立ち上げ時間を大幅に短縮でき、型安全性を保ちながら開発を進められます。皆さんのプロジェクトでも、ぜひこのパターンを活用してみてください。

関連リンク