本章では、Vue.jsアプリケーションにおける状態管理の概念と、Piniaを使用した効果的な状態管理の方法について学びます。状態管理の必要性、Piniaの基本的な使い方、ストアの設計パターン、そしてVue.jsのリアクティブシステムとの統合について理解を深めます。
まず、アプリケーションの状態管理とは何か、なぜ必要なのかを理解しましょう。
アプリケーションの「状態」とは、アプリケーションが動作するために必要なデータを指します。例えば、ユーザー情報、製品リスト、現在の表示モード、フォームの入力値などがアプリケーションの状態に含まれます。
小規模なアプリケーションでは、コンポーネント内でローカルの状態(ref
やreactive
)を使用して管理することができます。しかし、アプリケーションが大きくなるにつれて、以下のような問題が発生します:
これらの問題を解決するために、「状態管理パターン」が生まれました。状態管理パターンは、以下の要素で構成されます:
状態管理パターンでは、状態の変更は一方向のデータフローに従います:ビューはアクションをトリガーし、アクションは状態を変更し、状態の変更がビューに反映されます。
状態管理パターンを実装するために、専用のライブラリを使用することが一般的です。Vue.jsエコシステムでは、以下のような状態管理ライブラリがあります:
Piniaは、Vue 3のために設計された次世代の状態管理ライブラリです。当初はVuexの代替として開発されましたが、現在はVue.jsの公式状態管理ライブラリとして採用されています。
Vuexには以下のような問題点がありました:
Piniaは、これらの問題を解決するために設計されました:
Piniaを使い始めるには、まずインストールと基本設定が必要です。
npm install pinia
// src/main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.mount('#app')
Piniaでは、「ストア」はアプリケーションの状態を保持し、操作するためのオブジェクトです。
Piniaストアは、defineStore
関数を使用して定義します。
// src/stores/counter.js
import { defineStore } from 'pinia'
// ストアの定義(IDと設定オブジェクト)
export const useCounterStore = defineStore('counter', {
// 状態(State)
state: () => ({
count: 0,
name: 'カウンター'
}),
// ゲッター(Getters)
getters: {
doubleCount: (state) => state.count * 2,
// 他のゲッターを使用するゲッター
doubleCountPlusOne() {
return this.doubleCount + 1
}
},
// アクション(Actions)
actions: {
increment() {
this.count++
},
decrement() {
this.count--
},
// 非同期アクション
async fetchCount() {
try {
const response = await fetch('/api/count')
const data = await response.json()
this.count = data.count
} catch (error) {
console.error('カウントの取得に失敗しました:', error)
}
}
}
})
Vue 3のComposition API構文を使用して、より簡潔にストアを定義することもできます。
// src/stores/counter.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useCounterStore = defineStore('counter', () => {
// 状態(State)- refを使用
const count = ref(0)
const name = ref('カウンター')
// ゲッター(Getters)- computedを使用
const doubleCount = computed(() => count.value * 2)
const doubleCountPlusOne = computed(() => doubleCount.value + 1)
// アクション(Actions)- 通常の関数を使用
function increment() {
count.value++
}
function decrement() {
count.value--
}
async function fetchCount() {
try {
const response = await fetch('/api/count')
const data = await response.json()
count.value = data.count
} catch (error) {
console.error('カウントの取得に失敗しました:', error)
}
}
// 公開するプロパティとメソッドを返す
return {
count,
name,
doubleCount,
doubleCountPlusOne,
increment,
decrement,
fetchCount
}
})
定義したストアを使用して、コンポーネント内で状態を管理する方法を見ていきましょう。
<script setup>
import { useCounterStore } from '@/stores/counter'
// ストアのインスタンスを取得
const counterStore = useCounterStore()
// 状態、ゲッター、アクションにアクセス
console.log(counterStore.count) // 状態へのアクセス
console.log(counterStore.doubleCount) // ゲッターへのアクセス
counterStore.increment() // アクションの実行
</script>
<template>
<div>
<h1>{{ counterStore.name }}</h1>
<p>カウント: {{ counterStore.count }}</p>
<p>2倍のカウント: {{ counterStore.doubleCount }}</p>
<button @click="counterStore.increment()">増やす</button>
<button @click="counterStore.decrement()">減らす</button>
</div>
</template>
storeToRefs
関数を使用すると、ストアのプロパティを分解しながらリアクティブ性を保持できます。
<script setup>
import { useCounterStore } from '@/stores/counter'
import { storeToRefs } from 'pinia'
const counterStore = useCounterStore()
// storeToRefsを使用して状態とゲッターを分解
// アクションは含めない(リアクティブでないため)
const { count, name, doubleCount } = storeToRefs(counterStore)
// アクションは直接分解できる
const { increment, decrement } = counterStore
</script>
<template>
<div>
<h1>{{ name }}</h1>
<p>カウント: {{ count }}</p>
<p>2倍のカウント: {{ doubleCount }}</p>
<button @click="increment()">増やす</button>
<button @click="decrement()">減らす</button>
</div>
</template>
Piniaの主な利点の1つは、コンポーネント間で状態を簡単に共有できることです。
<!-- ComponentA.vue -->
<script setup>
import { useCounterStore } from '@/stores/counter'
const counterStore = useCounterStore()
function resetCounter() {
counterStore.count = 0
}
</script>
<template>
<div>
<h2>コンポーネントA</h2>
<p>カウント: {{ counterStore.count }}</p>
<button @click="counterStore.increment()">A: 増やす</button>
<button @click="resetCounter">リセット</button>
</div>
</template>
<!-- ComponentB.vue -->
<script setup>
import { useCounterStore } from '@/stores/counter'
import { storeToRefs } from 'pinia'
const counterStore = useCounterStore()
const { count, doubleCount } = storeToRefs(counterStore)
</script>
<template>
<div>
<h2>コンポーネントB</h2>
<p>カウント: {{ count }}</p>
<p>2倍のカウント: {{ doubleCount }}</p>
<button @click="counterStore.increment()">B: 増やす</button>
</div>
</template>
<!-- App.vue -->
<template>
<div>
<h1>Piniaによる状態共有</h1>
<ComponentA />
<ComponentB />
</div>
</template>
上記の例では、ComponentAとComponentBが同じカウンターストアを使用しています。一方のコンポーネントで状態を変更すると、もう一方のコンポーネントでも変更が反映されます。
Piniaでは、状態を変更する方法がいくつかあります。
Vuexと異なり、Piniaでは状態を直接変更できます。
const counterStore = useCounterStore()
// 直接変更
counterStore.count = 10
// オブジェクトや配列のプロパティも直接変更可能
counterStore.user.name = '山田太郎'
counterStore.items[0] = '新しいアイテム'
状態の変更ロジックをカプセル化するために、アクションを使用することを推奨します。
// ストア定義
export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0
}),
actions: {
// 複雑なロジックをカプセル化
incrementBy(amount) {
if (amount <= 0) return
this.count += amount
},
async fetchAndUpdate() {
try {
const response = await fetch('/api/data')
const data = await response.json()
this.count = data.newCount
} catch (error) {
console.error(error)
}
}
}
})
// 使用
const counterStore = useCounterStore()
counterStore.incrementBy(5)
await counterStore.fetchAndUpdate()
複数の状態を一度に更新するには、$patch
メソッドを使用できます。
const userStore = useUserStore()
// オブジェクトを使用して複数のプロパティを更新
userStore.$patch({
name: '山田太郎',
age: 25,
isActive: true
})
// 関数を使用して複雑な更新を行う
userStore.$patch((state) => {
state.count++
state.history.push('カウントが増加: ' + state.count)
state.lastUpdated = new Date()
})
ストアの状態を初期値にリセットするには、$reset
メソッドを使用します。
const counterStore = useCounterStore()
// ストアの状態を初期値にリセット
counterStore.$reset()
複数のストアを組み合わせて使用する方法を見ていきましょう。
ストア内で他のストアを使用することができます。
// src/stores/cart.js
import { defineStore } from 'pinia'
import { useProductStore } from './product'
export const useCartStore = defineStore('cart', {
state: () => ({
items: [] // { productId, quantity }
}),
getters: {
total() {
const productStore = useProductStore()
return this.items.reduce((sum, item) => {
const product = productStore.getProductById(item.productId)
return sum + (product?.price || 0) * item.quantity
}, 0)
}
},
actions: {
addItem(productId, quantity = 1) {
const productStore = useProductStore()
const product = productStore.getProductById(productId)
if (!product || product.stock < quantity) {
return false
}
// カートに追加
const existingItem = this.items.find(item => item.productId === productId)
if (existingItem) {
// 既存のアイテムの数量を更新
existingItem.quantity += quantity
} else {
// 新しいアイテムを追加
this.items.push({ productId, quantity })
}
// 商品の在庫を減らす
productStore.decreaseStock(productId, quantity)
return true
},
removeItem(productId) {
const index = this.items.findIndex(item => item.productId === productId)
if (index !== -1) {
this.items.splice(index, 1)
}
}
}
})
// src/stores/product.js
import { defineStore } from 'pinia'
export const useProductStore = defineStore('product', {
state: () => ({
products: [
{ id: 1, name: '商品A', price: 100, stock: 10 },
{ id: 2, name: '商品B', price: 200, stock: 5 },
{ id: 3, name: '商品C', price: 300, stock: 3 }
]
}),
getters: {
getProductById: (state) => {
return (productId) => state.products.find(p => p.id === productId)
},
availableProducts: (state) => {
return state.products.filter(p => p.stock > 0)
}
},
actions: {
decreaseStock(productId, quantity = 1) {
const product = this.products.find(p => p.id === productId)
if (product && product.stock >= quantity) {
product.stock -= quantity
return true
}
return false
}
}
})
複雑なアプリケーションでは、コンポーザブル関数を使用して、複数のストアを組み合わせたカスタムロジックを作成できます。
// src/composables/useShoppingList.js
import { computed } from 'vue'
import { useCartStore } from '@/stores/cart'
import { useProductStore } from '@/stores/product'
import { useUserStore } from '@/stores/user'
export function useShoppingList() {
const cartStore = useCartStore()
const productStore = useProductStore()
const userStore = useUserStore()
// カートのアイテムを商品情報と一緒に取得
const cartItems = computed(() => {
return cartStore.items.map(item => {
const product = productStore.getProductById(item.productId)
return {
...item,
product,
total: (product?.price || 0) * item.quantity
}
})
})
// ユーザーの最大予算に基づいた購入可能なアイテム
const affordableItems = computed(() => {
const budget = userStore.currentUser?.budget || 0
return cartItems.value.filter(item => item.total <= budget)
})
// 購入処理
async function checkout() {
if (cartStore.total > (userStore.currentUser?.budget || 0)) {
throw new Error('予算が不足しています')
}
try {
// 購入処理を実行
await userStore.updateBudget(-cartStore.total)
await cartStore.checkout()
return true
} catch (error) {
console.error('購入処理に失敗しました:', error)
return false
}
}
return {
cartItems,
affordableItems,
checkout
}
}
ここでは、Piniaを使ったTodoリストアプリケーションの例を見てみましょう。
// src/stores/todo.js
import { defineStore } from 'pinia'
export const useTodoStore = defineStore('todo', {
state: () => ({
todos: [],
filter: 'all', // 'all', 'completed', 'active'
nextId: 0
}),
getters: {
filteredTodos(state) {
if (state.filter === 'completed') {
return state.todos.filter(todo => todo.completed)
} else if (state.filter === 'active') {
return state.todos.filter(todo => !todo.completed)
}
return state.todos
},
totalCount: state => state.todos.length,
completedCount: state => state.todos.filter(todo => todo.completed).length,
activeCount: state => state.todos.filter(todo => !todo.completed).length,
allCompleted: state => state.todos.length > 0 && state.todos.every(todo => todo.completed)
},
actions: {
addTodo(text) {
if (!text.trim()) return
this.todos.push({
id: this.nextId++,
text,
completed: false,
createdAt: new Date()
})
},
removeTodo(id) {
const index = this.todos.findIndex(todo => todo.id === id)
if (index !== -1) {
this.todos.splice(index, 1)
}
},
toggleTodo(id) {
const todo = this.todos.find(todo => todo.id === id)
if (todo) {
todo.completed = !todo.completed
}
},
setFilter(filter) {
this.filter = filter
},
toggleAllTodos(completed = true) {
this.todos.forEach(todo => {
todo.completed = completed
})
},
clearCompleted() {
this.todos = this.todos.filter(todo => !todo.completed)
},
// 非同期アクション
async fetchTodos() {
try {
const response = await fetch('/api/todos')
const data = await response.json()
// 既存のTodoをクリアして新しいデータをセット
this.todos = data.map((todo, index) => ({
id: index,
text: todo.text,
completed: todo.completed,
createdAt: new Date(todo.createdAt)
}))
// 次のIDを更新
this.nextId = this.todos.length
} catch (error) {
console.error('Todoの取得に失敗しました:', error)
}
}
}
})
<!-- src/components/TodoList.vue -->
<script setup>
import { ref } from 'vue'
import { useTodoStore } from '@/stores/todo'
import { storeToRefs } from 'pinia'
const todoStore = useTodoStore()
const { filteredTodos, totalCount, completedCount, activeCount } = storeToRefs(todoStore)
const newTodoText = ref('')
function addTodo() {
todoStore.addTodo(newTodoText.value)
newTodoText.value = ''
}
function toggleAll() {
todoStore.toggleAllTodos(!todoStore.allCompleted)
}
</script>
<template>
<div class="todo-app">
<h1>Todoリスト</h1>
<!-- Todoフォーム -->
<div class="todo-form">
<input
v-model="newTodoText"
@keyup.enter="addTodo"
placeholder="新しいタスクを入力..."
type="text"
>
<button @click="addTodo">追加</button>
</div>
<!-- フィルター -->
<div class="todo-filters">
<button
:class="{ active: todoStore.filter === 'all' }"
@click="todoStore.setFilter('all')"
>
すべて ({{ totalCount }})
</button>
<button
:class="{ active: todoStore.filter === 'active' }"
@click="todoStore.setFilter('active')"
>
未完了 ({{ activeCount }})
</button>
<button
:class="{ active: todoStore.filter === 'completed' }"
@click="todoStore.setFilter('completed')"
>
完了済み ({{ completedCount }})
</button>
</div>
<!-- Todoリスト -->
<div v-if="filteredTodos.length" class="todo-list">
<div class="todo-controls">
<button @click="toggleAll">
{{ todoStore.allCompleted ? 'すべて未完了にする' : 'すべて完了にする' }}
</button>
<button @click="todoStore.clearCompleted" v-if="completedCount">
完了済みを削除
</button>
</div>
<ul>
<li
v-for="todo in filteredTodos"
:key="todo.id"
:class="{ completed: todo.completed }"
class="todo-item"
>
<input
type="checkbox"
:checked="todo.completed"
@change="todoStore.toggleTodo(todo.id)"
>
<span class="todo-text">{{ todo.text }}</span>
<span class="todo-date">{{ todo.createdAt.toLocaleString() }}</span>
<button @click="todoStore.removeTodo(todo.id)" class="delete-btn">削除</button>
</li>
</ul>
</div>
<!-- 空の状態 -->
<div v-else class="empty-state">
<p>{{ todoStore.totalCount ? 'フィルター条件に一致するTodoがありません' : 'Todoがありません' }}</p>
</div>
</div>
</template>
<style scoped>
.todo-app {
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.todo-form {
display: flex;
margin-bottom: 20px;
}
.todo-form input {
flex-grow: 1;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px 0 0 4px;
}
.todo-form button {
padding: 8px 16px;
border: none;
background-color: #42b983;
color: white;
border-radius: 0 4px 4px 0;
cursor: pointer;
}
.todo-filters {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
.todo-filters button {
padding: 6px 12px;
background-color: #f2f2f2;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
}
.todo-filters button.active {
background-color: #42b983;
color: white;
border-color: #42b983;
}
.todo-controls {
display: flex;
justify-content: space-between;
margin-bottom: 10px;
}
.todo-controls button {
padding: 6px 12px;
background-color: #f2f2f2;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
}
.todo-list ul {
list-style-type: none;
padding: 0;
}
.todo-item {
display: flex;
align-items: center;
padding: 10px;
border-bottom: 1px solid #eee;
}
.todo-item:last-child {
border-bottom: none;
}
.todo-item.completed .todo-text {
text-decoration: line-through;
color: #999;
}
.todo-text {
flex-grow: 1;
margin: 0 10px;
}
.todo-date {
color: #999;
font-size: 0.8em;
margin-right: 10px;
}
.delete-btn {
padding: 4px 8px;
background-color: #ff4c4c;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.empty-state {
text-align: center;
color: #999;
font-style: italic;
padding: 20px;
}
</style>
Piniaには、より高度なユースケースに対応するための機能があります。
ページのリロード後もストアの状態を保持するために、永続化プラグインを使用できます。
// src/plugins/piniaPersist.js
import { watch } from 'vue'
// 簡単な永続化プラグイン
export function createPiniaPersistPlugin(options = {}) {
const storage = options.storage || localStorage
const key = options.key || 'pinia'
return function({ store }) {
// 初期状態をロード
const savedState = JSON.parse(storage.getItem(`${key}-${store.$id}`))
if (savedState) {
store.$patch(savedState)
}
// 状態の変更を監視して保存
watch(
() => store.$state,
(state) => {
storage.setItem(`${key}-${store.$id}`, JSON.stringify(state))
},
{ deep: true }
)
// ストアにリセットメソッドを追加
store.$clearPersistedState = () => {
storage.removeItem(`${key}-${store.$id}`)
}
}
}
// src/main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { createPiniaPersistPlugin } from './plugins/piniaPersist'
import App from './App.vue'
const app = createApp(App)
const pinia = createPinia()
// プラグインを使用
pinia.use(createPiniaPersistPlugin({
storage: sessionStorage, // localStorage or sessionStorage
key: 'my-app'
}))
app.use(pinia)
app.mount('#app')
Piniaでは、ストアの状態が変更されたときにフックを実行できます。
// src/plugins/piniaHooks.js
export function createPiniaHooksPlugin() {
return function({ store }) {
// 元のアクションを保存
const originalActions = { ...store }
// アクションの前後にフックを追加
Object.keys(originalActions).forEach(actionName => {
if (typeof originalActions[actionName] === 'function' && !actionName.startsWith(')) {
store[actionName] = async function(...args) {
console.log(`[${store.$id}] ${actionName} 実行前`, args)
try {
const result = await originalActions[actionName].apply(this, args)
console.log(`[${store.$id}] ${actionName} 実行成功`, result)
return result
} catch (error) {
console.error(`[${store.$id}] ${actionName} 実行エラー`, error)
throw error
}
}
}
})
// 状態変更の監視
store.$subscribe((mutation, state) => {
console.log(`[${store.$id}] 状態が変更されました`, mutation)
})
}
}
// src/main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { createPiniaHooksPlugin } from './plugins/piniaHooks'
import App from './App.vue'
const app = createApp(App)
const pinia = createPinia()
// フックプラグインを使用
pinia.use(createPiniaHooksPlugin())
app.use(pinia)
app.mount('#app')
Piniaは、TypeScriptと非常に相性が良いです。
// src/stores/user.ts
import { defineStore } from 'pinia'
interface User {
id: number
name: string
email: string
isAdmin: boolean
}
interface UserState {
currentUser: User | null
isAuthenticated: boolean
token: string | null
}
export const useUserStore = defineStore('user', {
state: (): UserState => ({
currentUser: null,
isAuthenticated: false,
token: null
}),
getters: {
fullName(): string {
return this.currentUser ? this.currentUser.name : 'ゲスト'
},
isAdmin(): boolean {
return !!this.currentUser?.isAdmin
}
},
actions: {
async login(email: string, password: string): Promise {
try {
// ログイン処理...
const response = await fetch('/api/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ email, password })
})
if (!response.ok) {
throw new Error('認証に失敗しました')
}
const data = await response.json()
this.currentUser = data.user
this.isAuthenticated = true
this.token = data.token
return true
} catch (error) {
console.error(error)
return false
}
},
logout(): void {
this.currentUser = null
this.isAuthenticated = false
this.token = null
}
}
})
Piniaを効果的に使用するためのベストプラクティスを紹介します。
// src/stores/index.js
// すべてのストアをエクスポート
export * from './user'
export * from './product'
export * from './cart'
export * from './notification'
// src/stores/user.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useNotificationStore } from './notification'
// Composition API構文
export const useUserStore = defineStore('user', () => {
// 通知ストアをインポート
const notificationStore = useNotificationStore()
// 状態
const currentUser = ref(null)
const isLoading = ref(false)
const error = ref(null)
// ゲッター
const isAuthenticated = computed(() => !!currentUser.value)
const isAdmin = computed(() => currentUser.value?.role === 'admin')
// アクション
async function login(credentials) {
isLoading.value = true
error.value = null
try {
// ログイン処理...
const user = await api.login(credentials)
currentUser.value = user
// 通知を表示
notificationStore.show({
type: 'success',
message: 'ログインしました'
})
return true
} catch (e) {
error.value = e.message
notificationStore.show({
type: 'error',
message: `ログインに失敗しました: ${e.message}`
})
return false
} finally {
isLoading.value = false
}
}
function logout() {
currentUser.value = null
// その他のログアウト処理...
notificationStore.show({
type: 'info',
message: 'ログアウトしました'
})
}
// 公開するプロパティとメソッド
return {
currentUser,
isLoading,
error,
isAuthenticated,
isAdmin,
login,
logout
}
})
本章では、Vue.jsにおける状態管理の概念と、Piniaを使用した効果的な状態管理の方法について学びました。
状態管理の必要性とパターン、従来のVuexからPiniaへの移行理由、Piniaの基本的な使い方、ストアの設計パターン、そしてPiniaの高度な機能について理解を深めました。
Piniaは、Vue 3のComposition APIと完全に統合され、TypeScriptとの相性が良く、シンプルで直感的なAPIを提供する強力な状態管理ライブラリです。これを活用することで、大規模なVue.jsアプリケーションでも状態を効率的に管理できます。
次の章では、Vue.jsアプリケーションへのTypeScriptの導入について学びます。