1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

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. リアクティビティの失効

問題

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

ベストプラクティス

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

学習リソース

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

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?