Vue3の状態管理
Vue3では、状態管理のアプローチが大きく進化しました。Composition APIの導入により、より柔軟で型安全な状態管理が可能になり、Piniaの登場でVuexに代わる新しい選択肢が生まれました。
Vue3の状態管理の概要
状態管理とは
状態管理とは、アプリケーション内のデータ(状態)を効率的に管理し、複数のコンポーネント間で共有するための仕組みです。Vue3では、アプリケーションの複雑さに応じて様々なアプローチを選択できます。
なぜ状態管理が重要なのか
- データの一元管理: アプリケーション全体で一貫したデータ管理
- コンポーネント間の通信: 親子関係にないコンポーネント間でのデータ共有
- 予測可能な状態変更: 状態の変更が追跡しやすく、デバッグが容易
- 再利用性の向上: ロジックの分離により、コンポーネントの再利用性が向上
Vue3での状態管理の進化
Vue2からVue3への移行により、状態管理のアプローチが大きく変化しました:
- Options API → Composition API: より柔軟で型安全な書き方
- Vuex → Pinia: より軽量で直感的な状態管理ライブラリ
- TypeScript対応の向上: 型安全性の大幅な改善
Vue3では、以下のような状態管理の選択肢があります:
- Composition API: コンポーネント内での状態管理
- Provide/Inject: 親子間での状態共有
- Pinia: グローバル状態管理ライブラリ
- Vuex: 従来の状態管理ライブラリ(レガシー)
状態管理の選択基準
プロジェクトの規模と要件に応じて、適切な状態管理手法を選択することが重要です:
小規模プロジェクト(〜10コンポーネント)
- 推奨: Composition API + Provide/Inject
- 理由: シンプルで学習コストが低く、過度な抽象化を避けられる
- 適用例: ランディングページ、簡単なフォーム、小規模なダッシュボード
中規模プロジェクト(10〜50コンポーネント)
- 推奨: Pinia(単一ストア)
- 理由: グローバル状態の管理が容易で、開発者体験が良い
- 適用例: ECサイト、ブログ、管理画面
大規模プロジェクト(50コンポーネント以上)
- 推奨: Pinia + 複数ストアの分割
- 理由: モジュール化により保守性とスケーラビリティを確保
- 適用例: エンタープライズアプリケーション、複雑なSaaS
各手法の詳細比較
| 手法 | 学習コスト | 型安全性 | パフォーマンス | スケーラビリティ | 適用規模 |
|---|---|---|---|---|---|
| Composition API | 🟢 低い | 🟢 高い | 🟢 高い | 🟡 中程度 | 小〜中 |
| Provide/Inject | 🟢 低い | 🟡 中程度 | 🟢 高い | 🔴 低い | 小 |
| Pinia | 🟡 中程度 | 🟢 高い | 🟢 高い | 🟢 高い | 中〜大 |
| Vuex | 🔴 高い | 🟡 中程度 | 🟡 中程度 | 🟢 高い | 大 |
Composition APIを使った状態管理
Composition APIは、Vue3の新機能として導入された、より柔軟なコンポーネントの書き方です。状態管理においても強力な機能を提供します。
Composition APIの特徴
- 論理的な関心の分離: 関連するロジックを一箇所にまとめることができる
- 再利用性の向上: カスタムコンポーザブルとしてロジックを抽出可能
- 型安全性: TypeScriptとの親和性が高い
- ツリーシェイキング: 使用されていない機能を自動的に除外
基本的なリアクティブAPI
Composition APIでは、以下のリアクティブAPIを使用して状態を管理します:
- ref(): プリミティブ型の値をリアクティブにする
- reactive(): オブジェクトをリアクティブにする
- computed(): 計算されたプロパティを作成
- watch(): 状態の変更を監視
- watchEffect(): 副作用を自動的に追跡
基本的な状態管理
ref()を使った状態管理
ref()は、プリミティブ型(文字列、数値、真偽値など)の値をリアクティブにするために使用します。
// composables/useCounter.js
import { ref, computed } from 'vue'
export function useCounter(initialValue = 0) {
// ref()でリアクティブな状態を作成
const count = ref(initialValue)
// 状態を変更する関数
const increment = () => {
count.value++ // .valueでアクセス
}
const decrement = () => {
count.value--
}
const reset = () => {
count.value = initialValue
}
// 計算されたプロパティ
const doubleCount = computed(() => count.value * 2)
const isEven = computed(() => count.value % 2 === 0)
return {
count, // リアクティブな状態
increment, // 状態を変更する関数
decrement,
reset,
doubleCount, // 計算されたプロパティ
isEven
}
}
reactive()を使った状態管理
reactive()は、オブジェクトをリアクティブにするために使用します。オブジェクトのプロパティに直接アクセスできます。
// composables/useUser.js
import { reactive, computed } from 'vue'
export function useUser() {
// reactive()でオブジェクトをリアクティブにする
const user = reactive({
name: '',
email: '',
age: 0,
preferences: {
theme: 'light',
language: 'ja'
}
})
// 状態を更新する関数
const updateUser = (userData) => {
Object.assign(user, userData)
}
const updatePreference = (key, value) => {
user.preferences[key] = value
}
// 計算されたプロパティ
const displayName = computed(() => {
return user.name || 'ゲストユーザー'
})
const isAdult = computed(() => user.age >= 18)
return {
user,
updateUser,
updatePreference,
displayName,
isAdult
}
}
コンポーネントでの使用
基本的な使用例
<template>
<div class="counter">
<h3>カウンターアプリ</h3>
<p>現在の値: {{ count }}</p>
<p>2倍の値: {{ doubleCount }}</p>
<p>偶数かどうか: {{ isEven ? '偶数' : '奇数' }}</p>
<div class="buttons">
<button @click="increment" :disabled="count >= 10">+</button>
<button @click="decrement" :disabled="count <= 0">-</button>
<button @click="reset">リセット</button>
</div>
</div>
</template>
<script setup>
import { useCounter } from '@/composables/useCounter'
// カスタムコンポーザブルを使用
const { count, increment, decrement, reset, doubleCount, isEven } = useCounter(5)
</script>
<style scoped>
.counter {
padding: 20px;
border: 1px solid #ddd;
border-radius: 8px;
max-width: 300px;
}
.buttons {
display: flex;
gap: 10px;
margin-top: 10px;
}
button {
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
複数のコンポーザブルの組み合わせ
<template>
<div class="user-profile">
<h2>ユーザープロフィール</h2>
<!-- カウンター部分 -->
<div class="counter-section">
<h3>アクション回数: {{ count }}</h3>
<button @click="increment">アクション実行</button>
</div>
<!-- ユーザー情報部分 -->
<div class="user-section">
<p>名前: {{ displayName }}</p>
<p>年齢: {{ user.age }}歳 ({{ isAdult ? '成人' : '未成年' }})</p>
<p>テーマ: {{ user.preferences.theme }}</p>
<button @click="toggleTheme">
テーマを{{ user.preferences.theme === 'light' ? 'ダーク' : 'ライト' }}に変更
</button>
</div>
</div>
</template>
<script setup>
import { useCounter } from '@/composables/useCounter'
import { useUser } from '@/composables/useUser'
// 複数のコンポーザブルを組み合わせ
const { count, increment } = useCounter()
const { user, displayName, isAdult, updatePreference } = useUser()
// テーマ切り替え機能
const toggleTheme = () => {
const newTheme = user.preferences.theme === 'light' ? 'dark' : 'light'
updatePreference('theme', newTheme)
}
</script>
複数のコンポーネント間での状態共有
グローバル状態の管理
複数のコンポーネント間で状態を共有する場合、グローバルな状態を作成します。
// composables/useSharedState.js
import { ref, readonly } from 'vue'
// グローバルな状態(モジュールレベルで定義)
const globalState = ref({
user: null,
theme: 'light',
notifications: [],
isLoading: false
})
// 状態を変更する関数
const setUser = (user) => {
globalState.value.user = user
}
const setTheme = (theme) => {
globalState.value.theme = theme
// テーマ変更時にlocalStorageにも保存
localStorage.setItem('theme', theme)
}
const addNotification = (notification) => {
globalState.value.notifications.push({
id: Date.now(),
message: notification.message,
type: notification.type || 'info',
timestamp: new Date()
})
}
const removeNotification = (id) => {
const index = globalState.value.notifications.findIndex(n => n.id === id)
if (index > -1) {
globalState.value.notifications.splice(index, 1)
}
}
const setLoading = (loading) => {
globalState.value.isLoading = loading
}
// コンポーザブル関数
export function useSharedState() {
return {
// readonly()で読み取り専用にして、直接変更を防ぐ
globalState: readonly(globalState),
setUser,
setTheme,
addNotification,
removeNotification,
setLoading
}
}
Provide/Injectを使った状態共有
親コンポーネントから子コンポーネントに状態を提供する場合、Provide/Injectパターンを使用します。
// composables/useProvideState.js
import { provide, inject, ref } from 'vue'
// キーを定義
const STATE_KEY = Symbol('sharedState')
// 親コンポーネントで状態を提供
export function provideState() {
const state = ref({
user: null,
theme: 'light'
})
const updateUser = (user) => {
state.value.user = user
}
const updateTheme = (theme) => {
state.value.theme = theme
}
// 子コンポーネントに状態を提供
provide(STATE_KEY, {
state: readonly(state),
updateUser,
updateTheme
})
return { state, updateUser, updateTheme }
}
// 子コンポーネントで状態を注入
export function injectState() {
const injected = inject(STATE_KEY)
if (!injected) {
throw new Error('useProvideState()が親コンポーネントで呼び出されていません')
}
return injected
}
使用例
<!-- 親コンポーネント -->
<template>
<div class="app">
<Header />
<MainContent />
<Footer />
</div>
</template>
<script setup>
import { provideState } from '@/composables/useProvideState'
import Header from './Header.vue'
import MainContent from './MainContent.vue'
import Footer from './Footer.vue'
// 状態を提供
provideState()
</script>
<!-- 子コンポーネント -->
<template>
<div class="header">
<h1>{{ state.user?.name || 'ゲスト' }}さん、こんにちは!</h1>
<button @click="toggleTheme">
テーマ: {{ state.theme }}
</button>
</div>
</template>
<script setup>
import { injectState } from '@/composables/useProvideState'
// 状態を注入
const { state, updateTheme } = injectState()
const toggleTheme = () => {
const newTheme = state.theme === 'light' ? 'dark' : 'light'
updateTheme(newTheme)
}
</script>
Piniaによる状態管理
Piniaは、Vue3の公式状態管理ライブラリとして推奨されている、Vuexの後継となるライブラリです。
インストールとセットアップ
npm install pinia
// main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
const app = createApp(App)
app.use(createPinia())
app.mount('#app')
基本的なストアの作成
// stores/counter.js
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0,
name: 'Counter Store'
}),
getters: {
doubleCount: (state) => state.count * 2,
greeting: (state) => `Hello, ${state.name}!`
},
actions: {
increment() {
this.count++
},
decrement() {
this.count--
},
async fetchData() {
try {
const response = await fetch('/api/data')
const data = await response.json()
this.count = data.count
} catch (error) {
console.error('Failed to fetch data:', error)
}
}
}
})
Composition APIスタイルでのストア
// stores/user.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useUserStore = defineStore('user', () => {
// State
const user = ref(null)
const isLoading = ref(false)
// Getters
const isLoggedIn = computed(() => !!user.value)
const userName = computed(() => user.value?.name || 'Guest')
// Actions
const login = async (credentials) => {
isLoading.value = true
try {
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials)
})
user.value = await response.json()
} catch (error) {
console.error('Login failed:', error)
throw error
} finally {
isLoading.value = false
}
}
const logout = () => {
user.value = null
}
return {
user,
isLoading,
isLoggedIn,
userName,
login,
logout
}
})
コンポーネントでの使用
<template>
<div>
<div v-if="userStore.isLoading">ローディング中...</div>
<div v-else-if="userStore.isLoggedIn">
<h2>こんにちは、{{ userStore.userName }}さん!</h2>
<button @click="userStore.logout">ログアウト</button>
</div>
<div v-else>
<button @click="handleLogin">ログイン</button>
</div>
</div>
</template>
<script setup>
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
const handleLogin = async () => {
try {
await userStore.login({
email: 'user@example.com',
password: 'password'
})
} catch (error) {
alert('ログインに失敗しました')
}
}
</script>
Vuexとの比較
| 特徴 | Pinia | Vuex |
|---|---|---|
| Vue3対応 | ✅ 完全対応 | ⚠️ 4.xで対応 |
| TypeScript | ✅ 完全対応 | ⚠️ 型推論が限定的 |
| DevTools | ✅ 対応 | ✅ 対応 |
| 学習コスト | 🟢 低い | 🟡 中程度 |
| ボイラープレート | 🟢 少ない | 🔴 多い |
| モジュール分割 | 🟢 簡単 | 🟡 複雑 |
移行のメリット
VuexからPiniaへの移行には以下のメリットがあります:
- 型安全性の向上: TypeScriptとの親和性が高い
- コードの簡潔性: ボイラープレートが少ない
- 開発体験の向上: より直感的なAPI
- パフォーマンス: より軽量で高速
実践的な使用例
複数ストアの連携
// stores/cart.js
import { defineStore } from 'pinia'
import { useUserStore } from './user'
export const useCartStore = defineStore('cart', () => {
const items = ref([])
const userStore = useUserStore()
const addItem = (product) => {
if (!userStore.isLoggedIn) {
throw new Error('ログインが必要です')
}
items.value.push(product)
}
const totalPrice = computed(() => {
return items.value.reduce((sum, item) => sum + item.price, 0)
})
return {
items,
addItem,
totalPrice
}
})
永続化の実装
// stores/settings.js
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useSettingsStore = defineStore('settings', () => {
const theme = ref(localStorage.getItem('theme') || 'light')
const language = ref(localStorage.getItem('language') || 'ja')
const setTheme = (newTheme) => {
theme.value = newTheme
localStorage.setItem('theme', newTheme)
}
const setLanguage = (newLanguage) => {
language.value = newLanguage
localStorage.setItem('language', newLanguage)
}
return {
theme,
language,
setTheme,
setLanguage
}
})
非同期処理の管理
// stores/posts.js
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const usePostsStore = defineStore('posts', () => {
const posts = ref([])
const loading = ref(false)
const error = ref(null)
const fetchPosts = async () => {
loading.value = true
error.value = null
try {
const response = await fetch('/api/posts')
if (!response.ok) {
throw new Error('Failed to fetch posts')
}
posts.value = await response.json()
} catch (err) {
error.value = err.message
} finally {
loading.value = false
}
}
const createPost = async (postData) => {
try {
const response = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(postData)
})
if (!response.ok) {
throw new Error('Failed to create post')
}
const newPost = await response.json()
posts.value.push(newPost)
} catch (err) {
error.value = err.message
throw err
}
}
return {
posts,
loading,
error,
fetchPosts,
createPost
}
})
まとめ
Vue3の状態管理は、Composition APIとPiniaの組み合わせにより、より柔軟で保守性の高いアプリケーションの構築が可能になりました。
選択の指針
- 小規模アプリ: Composition API + Provide/Inject
- 中規模アプリ: Pinia(単一ストア)
- 大規模アプリ: Pinia(複数ストアの分割)
ベストプラクティス
- 適切な粒度でのストア分割
- 型安全性の確保
- 非同期処理の適切な管理
- 状態の永続化の検討
- テストの書きやすさを考慮
Vue3の状態管理を適切に活用することで、スケーラブルで保守性の高いアプリケーションを構築できます。プロジェクトの要件に応じて、最適な手法を選択し、段階的に導入していくことをお勧めします。
よくある問題と解決方法
1. リアクティビティの失効
問題
// ❌ 間違った例
const state = reactive({ count: 0 })
state = { count: 1 } // リアクティビティが失われる
解決方法
// ✅ 正しい例
const state = reactive({ count: 0 })
state.count = 1 // プロパティを直接変更
// または
Object.assign(state, { count: 1 })
2. 配列の操作
問題
// ❌ 間違った例
const items = reactive([])
items = [...items, newItem] // リアクティビティが失われる
解決方法
// ✅ 正しい例
const items = reactive([])
items.push(newItem) // 配列メソッドを使用
// または
items.splice(items.length, 0, newItem)
3. 非同期処理での状態更新
問題
// ❌ 間違った例
const fetchData = async () => {
const response = await fetch('/api/data')
const data = await response.json()
// コンポーネントがアンマウントされた後に状態を更新する可能性
state.data = data
}
解決方法
// ✅ 正しい例
import { onUnmounted } from 'vue'
const fetchData = async () => {
let isCancelled = false
onUnmounted(() => {
isCancelled = true
})
try {
const response = await fetch('/api/data')
const data = await response.json()
if (!isCancelled) {
state.data = data
}
} catch (error) {
if (!isCancelled) {
state.error = error.message
}
}
}
4. メモリリークの防止
問題
// ❌ 間違った例
const setup = () => {
const timer = setInterval(() => {
// 何かの処理
}, 1000)
// タイマーがクリーンアップされない
}
解決方法
// ✅ 正しい例
import { onUnmounted } from 'vue'
const setup = () => {
const timer = setInterval(() => {
// 何かの処理
}, 1000)
onUnmounted(() => {
clearInterval(timer)
})
}
パフォーマンス最適化
1. 不要な再レンダリングの防止
問題
// ❌ 間違った例
const expensiveComputed = computed(() => {
// 重い計算処理
return heavyCalculation(state.data)
})
解決方法
// ✅ 正しい例
const expensiveComputed = computed(() => {
// 依存関係を最小限に
return heavyCalculation(state.essentialData)
})
// または、shallowRefを使用
const heavyData = shallowRef(null)
2. 大量データの処理
問題
// ❌ 間違った例
const largeList = reactive(Array.from({ length: 10000 }, (_, i) => ({ id: i })))
解決方法
// ✅ 正しい例
const largeList = shallowReactive(Array.from({ length: 10000 }, (_, i) => ({ id: i })))
// または、仮想スクロールを使用
import { VirtualList } from '@tanstack/vue-virtual'
3. デバウンスとスロットル
// composables/useDebounce.js
import { ref, watch } from 'vue'
export function useDebounce(value, delay = 300) {
const debouncedValue = ref(value.value)
watch(value, (newValue) => {
const timer = setTimeout(() => {
debouncedValue.value = newValue
}, delay)
return () => clearTimeout(timer)
})
return debouncedValue
}
// 使用例
const searchQuery = ref('')
const debouncedQuery = useDebounce(searchQuery, 500)
テスト戦略
1. コンポーザブルのテスト
// tests/useCounter.test.js
import { describe, it, expect } from 'vitest'
import { useCounter } from '@/composables/useCounter'
describe('useCounter', () => {
it('初期値が正しく設定される', () => {
const { count } = useCounter(5)
expect(count.value).toBe(5)
})
it('incrementが正しく動作する', () => {
const { count, increment } = useCounter(0)
increment()
expect(count.value).toBe(1)
})
it('computedが正しく動作する', () => {
const { count, doubleCount, increment } = useCounter(2)
expect(doubleCount.value).toBe(4)
increment()
expect(doubleCount.value).toBe(6)
})
})
2. Piniaストアのテスト
// tests/stores/counter.test.js
import { describe, it, expect, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useCounterStore } from '@/stores/counter'
describe('Counter Store', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('初期状態が正しい', () => {
const store = useCounterStore()
expect(store.count).toBe(0)
})
it('incrementが正しく動作する', () => {
const store = useCounterStore()
store.increment()
expect(store.count).toBe(1)
})
it('getterが正しく動作する', () => {
const store = useCounterStore()
store.count = 5
expect(store.doubleCount).toBe(10)
})
})
3. コンポーネントのテスト
// tests/components/Counter.test.js
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Counter from '@/components/Counter.vue'
describe('Counter Component', () => {
it('カウンターが正しく表示される', () => {
const wrapper = mount(Counter)
expect(wrapper.text()).toContain('カウント: 0')
})
it('ボタンクリックでカウントが増加する', async () => {
const wrapper = mount(Counter)
const button = wrapper.find('button')
await button.trigger('click')
expect(wrapper.text()).toContain('カウント: 1')
})
})
まとめ
Vue3の状態管理は、Composition APIとPiniaの組み合わせにより、より柔軟で保守性の高いアプリケーションの構築が可能になりました。
選択の指針
- 小規模アプリ: Composition API + Provide/Inject
- 中規模アプリ: Pinia(単一ストア)
- 大規模アプリ: Pinia(複数ストアの分割)
ベストプラクティス
- 適切な粒度でのストア分割
- 型安全性の確保
- 非同期処理の適切な管理
- 状態の永続化の検討
- テストの書きやすさを考慮
- パフォーマンスの最適化
- メモリリークの防止
学習リソース
Vue3の状態管理を適切に活用することで、スケーラブルで保守性の高いアプリケーションを構築できます。プロジェクトの要件に応じて、最適な手法を選択し、段階的に導入していくことをお勧めします。