🔄

Vue3の状態管理について調べてみた

に公開
2

Vue3の状態管理

Vue3では、状態管理のアプローチが大きく進化しました。Composition APIの導入により、より柔軟で型安全な状態管理が可能になり、Piniaの登場でVuexに代わる新しい選択肢が生まれました。

Vue3の状態管理の概要

状態管理とは

状態管理とは、アプリケーション内のデータ(状態)を効率的に管理し、複数のコンポーネント間で共有するための仕組みです。Vue3では、アプリケーションの複雑さに応じて様々なアプローチを選択できます。

なぜ状態管理が重要なのか

  1. データの一元管理: アプリケーション全体で一貫したデータ管理
  2. コンポーネント間の通信: 親子関係にないコンポーネント間でのデータ共有
  3. 予測可能な状態変更: 状態の変更が追跡しやすく、デバッグが容易
  4. 再利用性の向上: ロジックの分離により、コンポーネントの再利用性が向上

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の特徴

  1. 論理的な関心の分離: 関連するロジックを一箇所にまとめることができる
  2. 再利用性の向上: カスタムコンポーザブルとしてロジックを抽出可能
  3. 型安全性: TypeScriptとの親和性が高い
  4. ツリーシェイキング: 使用されていない機能を自動的に除外

基本的なリアクティブ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への移行には以下のメリットがあります:

  1. 型安全性の向上: TypeScriptとの親和性が高い
  2. コードの簡潔性: ボイラープレートが少ない
  3. 開発体験の向上: より直感的なAPI
  4. パフォーマンス: より軽量で高速

実践的な使用例

複数ストアの連携

// 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(複数ストアの分割)

ベストプラクティス

  1. 適切な粒度でのストア分割
  2. 型安全性の確保
  3. 非同期処理の適切な管理
  4. 状態の永続化の検討
  5. テストの書きやすさを考慮

Vue3の状態管理を適切に活用することで、スケーラブルで保守性の高いアプリケーションを構築できます。プロジェクトの要件に応じて、最適な手法を選択し、段階的に導入していくことをお勧めします。

よくある問題と解決方法

1. リアクティビティの失効

問題

// ❌ 間違った例
let state = reactive({ count: 0 })
state = { count: 1 } // リアクティビティが失われる

解決方法

// ✅ 正しい例
const state = reactive({ count: 0 })
state.count = 1 // プロパティを直接変更

// または
Object.assign(state, { count: 1 })

2. 配列の操作

問題

// ❌ 間違った例
let 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(複数ストアの分割)

ベストプラクティス

  1. 適切な粒度でのストア分割
  2. 型安全性の確保
  3. 非同期処理の適切な管理
  4. 状態の永続化の検討
  5. テストの書きやすさを考慮
  6. パフォーマンスの最適化
  7. メモリリークの防止

学習リソース

Vue3の状態管理を適切に活用することで、スケーラブルで保守性の高いアプリケーションを構築できます。プロジェクトの要件に応じて、最適な手法を選択し、段階的に導入していくことをお勧めします。

GitHubで編集を提案

Discussion

junerjuner
// ❌ 間違った例
const state = reactive({ count: 0 })
state = { count: 1 } // リアクティビティが失われる

それだとそもそも const の再代入なのでエラーでは……?