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により、すべての厳格な型チェックが有効になります。noUnusedLocalsとnoUnusedParametersは、使われていない変数や引数を検出してくれるのです。
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)
})
})
リセット機能のテストも重要です。これらのテストにより、ストアの動作が保証されます。
プロジェクト構成の全体像
最後に、これまで作成したファイルの全体構成を確認しましょう。
| # | ディレクトリ/ファイル | 役割 |
|---|---|---|
| 1 | src/types/common.ts | 共通型定義 |
| 2 | src/stores/user.ts | ユーザーストア |
| 3 | src/stores/settings.ts | 設定ストア |
| 4 | src/stores/cart.ts | カートストア |
| 5 | src/stores/plugins/persist.ts | 永続化プラグイン |
| 6 | src/composables/useLocalStorage.ts | LocalStorage コンポーザブル |
| 7 | src/composables/useApi.ts | API 呼び出しコンポーザブル |
| 8 | src/composables/useFetch.ts | フェッチコンポーザブル |
| 9 | src/composables/useStorePersist.ts | ストア永続化コンポーザブル |
| 10 | vite.config.ts | Vite 設定 |
| 11 | tsconfig.json | TypeScript 設定 |
| 12 | src/main.ts | エントリーポイント |
この構成により、型安全性を保ちながら、保守性の高いコードベースが実現できました。
まとめ
Pinia × VueUse × Vite の組み合わせで、型安全なストアとユーティリティを最短で構築する方法をご紹介しました。
重要なポイントを振り返ってみましょう。
型定義を明確にする
interface と type を適切に使い分け、ストアの状態とアクションに対して完全な型推論を実現することで、コンパイル時にエラーを検出できます。
再利用可能なパターンを確立する
コンポーザブルとプラグインを活用することで、プロジェクト全体で一貫した実装が保たれ、保守性が向上します。
Vite と TypeScript の設定を最適化する
パスエイリアスや厳格な型チェックにより、開発効率と品質の両立が可能になるのです。
VueUse との統合を活用する
VueUse の豊富なユーティリティを Pinia ストアと組み合わせることで、車輪の再発明を避けながら強力な機能を実装できます。
永続化機能を自動化する
Pinia プラグインとして永続化機能を実装することで、各ストアで個別に実装する手間が省けますね。
この雛形をベースにすることで、プロジェクトの立ち上げ時間を大幅に短縮でき、型安全性を保ちながら開発を進められます。皆さんのプロジェクトでも、ぜひこのパターンを活用してみてください。
関連リンク
articlePinia × VueUse × Vite 雛形:型安全ストアとユーティリティを最短で組む
articlePinia と VueUse の useStorage/useFetch 比較:軽量レシピで代替できる境界
articlePinia ストア間の循環参照を断つ:依存分解とイベント駆動の現場テク
articlePinia 2025 アップデート総まとめ:非互換ポイントと安全な移行チェックリスト
articlePinia 可観測性の作り方:DevTools × OpenTelemetry で変更を可視化する
articlePinia 正規化データ設計:Entity アダプタで巨大リストを高速・一貫に保つ
articleReact でデータ取得を最適化:TanStack Query 基礎からキャッシュ戦略まで実装
articleAnsible Jinja2 テンプレート速攻リファレンス:filters/tests/macros
articlePython Dev Containers 完全レシピ:再現可能な開発箱を VS Code で作る
articleStorybook で Zustand をモックする:Controls 連動とシナリオ駆動 UI
articlePrisma を Monorepo で使い倒す:パス解決・generate の共有・依存戦略
articleプラグイン競合の特定術:WordPress で原因切り分けを高速化する手順
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 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来