Vuex/Piniaのアクションと非同期処理:実践的な状態管理のテクニック

2025-07-31

はじめに

現代のフロントエンドアプリケーションでは、APIからのデータ取得やバックエンドとの通信といった非同期処理が不可欠です。VuexやPiniaの「アクション(Actions)」は、これらの非同期操作を管理し、最終的に状態を更新するための強力な仕組みを提供します。本記事では、アクションの詳細な使い方、非同期処理のパターン、エラーハンドリング、そして実践的なベストプラクティスについて、具体的なコード例と図解を交えて解説します。

アクションの基本概念

アクションとは?

アクションはVuex/Piniaにおける非同期処理とビジネスロジックの中心です。ミューテーションとは異なり、以下の特徴があります:

  1. 非同期処理が可能:API呼び出しやタイムアウト処理ができる
  2. ミューテーションをコミット:最終的に状態を変更するにはミューテーションを呼び出す
  3. 他のアクションをディスパッチ:複雑なワークフローを組み立てられる

Vuexのアクション基本構造

// store/index.js
export default new Vuex.Store({
  state: {
    users: [],
    loading: false,
    error: null
  },
  mutations: {
    SET_LOADING(state, isLoading) {
      state.loading = isLoading
    },
    SET_USERS(state, users) {
      state.users = users
    },
    SET_ERROR(state, error) {
      state.error = error
    }
  },
  actions: {
    async fetchUsers({ commit }) {
      try {
        commit('SET_LOADING', true)
        commit('SET_ERROR', null)

        const response = await fetch('https://api.example.com/users')
        const users = await response.json()

        commit('SET_USERS', users)
      } catch (error) {
        commit('SET_ERROR', error.message)
        console.error('ユーザー取得に失敗しました:', error)
      } finally {
        commit('SET_LOADING', false)
      }
    }
  }
})

Piniaのアクション基本構造

// stores/userStore.js
import { defineStore } from 'pinia'

export const useUserStore = defineStore('users', {
  state: () => ({
    users: [],
    loading: false,
    error: null
  }),
  actions: {
    async fetchUsers() {
      try {
        this.loading = true
        this.error = null

        const response = await fetch('https://api.example.com/users')
        this.users = await response.json()
      } catch (error) {
        this.error = error.message
        console.error('ユーザー取得に失敗しました:', error)
      } finally {
        this.loading = false
      }
    }
  }
})

アクションの実践的な使用例

APIからのデータ取得

最も一般的なアクションの使用例は、バックエンドAPIからのデータ取得です。

Vuexでの実装

actions: {
  async fetchPosts({ commit, state }, { userId, page = 1 }) {
    if (state.loading) return

    commit('SET_LOADING', true)

    try {
      const response = await fetch(
        `https://api.example.com/users/${userId}/posts?page=${page}`
      )

      if (!response.ok) {
        throw new Error('投稿の取得に失敗しました')
      }

      const posts = await response.json()
      commit('SET_POSTS', posts)
      return posts // 必要に応じて呼び出し元で結果を利用できる
    } catch (error) {
      commit('SET_ERROR', error.message)
      throw error // コンポーネントでさらにエラー処理を行う場合
    } finally {
      commit('SET_LOADING', false)
    }
  }
}

Piniaでの実装

actions: {
  async fetchPosts(userId, page = 1) {
    if (this.loading) return

    this.loading = true

    try {
      const response = await fetch(
        `https://api.example.com/users/${userId}/posts?page=${page}`
      )

      if (!response.ok) {
        throw new Error('投稿の取得に失敗しました')
      }

      this.posts = await response.json()
      return this.posts
    } catch (error) {
      this.error = error.message
      throw error
    } finally {
      this.loading = false
    }
  }
}

コンポーネントからのアクション呼び出し

<template>
  <div>
    <button @click="loadPosts" :disabled="loading">
      {{ loading ? '読み込み中...' : '投稿を読み込む' }}
    </button>

    <div v-if="error" class="error">{{ error }}</div>

    <ul v-if="posts.length">
      <li v-for="post in posts" :key="post.id">
        {{ post.title }}
      </li>
    </ul>
  </div>
</template>

<script>
import { mapState, mapActions } from 'vuex'

export default {
  computed: {
    ...mapState(['posts', 'loading', 'error'])
  },
  methods: {
    ...mapActions(['fetchPosts']),
    async loadPosts() {
      try {
        await this.fetchPosts({ userId: 123, page: 1 })
      } catch (error) {
        // 必要に応じてさらにエラー処理
      }
    }
  }
}
</script>

複雑な非同期処理のパターン

複数のAPI呼び出しを並列/直列で実行

並列実行(Promise.all)

// Vuex
actions: {
  async fetchDashboardData({ commit }) {
    commit('SET_LOADING', true)

    try {
      const [usersResponse, postsResponse] = await Promise.all([
        fetch('https://api.example.com/users'),
        fetch('https://api.example.com/posts')
      ])

      const users = await usersResponse.json()
      const posts = await postsResponse.json()

      commit('SET_USERS', users)
      commit('SET_POSTS', posts)
    } catch (error) {
      commit('SET_ERROR', 'ダッシュボードデータの取得に失敗しました')
    } finally {
      commit('SET_LOADING', false)
    }
  }
}

直列実行

// Pinia
actions: {
  async fetchUserWithPosts(userId) {
    this.loading = true

    try {
      const userResponse = await fetch(`https://api.example.com/users/${userId}`)
      const user = await userResponse.json()
      this.user = user

      const postsResponse = await fetch(
        `https://api.example.com/users/${userId}/posts`
      )
      this.posts = await postsResponse.json()
    } catch (error) {
      this.error = 'ユーザーデータの取得に失敗しました'
    } finally {
      this.loading = false
    }
  }
}

ページネーションの実装

// Vuex
actions: {
  async loadMorePosts({ commit, state }, { userId }) {
    if (state.isLoadingMore || !state.hasMorePosts) return

    commit('SET_LOADING_MORE', true)

    try {
      const nextPage = state.currentPage + 1
      const response = await fetch(
        `https://api.example.com/users/${userId}/posts?page=${nextPage}`
      )

      const newPosts = await response.json()

      commit('APPEND_POSTS', newPosts)
      commit('SET_CURRENT_PAGE', nextPage)
      commit('SET_HAS_MORE_POSTS', newPosts.length > 0)
    } catch (error) {
      commit('SET_ERROR', '追加投稿の読み込みに失敗しました')
    } finally {
      commit('SET_LOADING_MORE', false)
    }
  }
}

エラーハンドリングのベストプラクティス

統一的なエラー処理

// utils/api.js
export async function handleApiRequest(requestFn, store) {
  try {
    return await requestFn()
  } catch (error) {
    const errorMessage = error.response?.data?.message || 
                        error.message || 
                        'リクエストに失敗しました'

    if (store) {
      // Vuexの場合
      store.commit('SET_ERROR', errorMessage)
      // またはPiniaの場合
      // store.error = errorMessage
    }

    throw error // コンポーネントでさらに処理が必要な場合
  }
}

// Vuexでの使用例
actions: {
  async fetchProducts({ commit }) {
    commit('SET_LOADING', true)
    commit('SET_ERROR', null)

    await handleApiRequest(async () => {
      const response = await fetch('https://api.example.com/products')
      const products = await response.json()
      commit('SET_PRODUCTS', products)
    }, this)

    commit('SET_LOADING', false)
  }
}

リトライメカニズム

// Vuex
actions: {
  async fetchWithRetry({ commit }, { url, maxRetries = 3 }) {
    let lastError = null

    for (let i = 0; i < maxRetries; i++) {
      try {
        const response = await fetch(url)

        if (!response.ok) {
          throw new Error(`HTTPエラー: ${response.status}`)
        }

        return await response.json()
      } catch (error) {
        lastError = error
        console.warn(`リトライ ${i + 1}/${maxRetries}`, error)

        if (i < maxRetries - 1) {
          await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1)))
        }
      }
    }

    commit('SET_ERROR', lastError.message)
    throw lastError
  }
}

アクションの高度なパターン

アクションのチェーンリング

// Vuex
actions: {
  async login({ dispatch }, credentials) {
    const user = await dispatch('authenticate', credentials)
    await dispatch('fetchUserProfile', user.id)
    await dispatch('loadPreferences', user.id)
    return user
  },

  async authenticate({ commit }, { email, password }) {
    const response = await fetch('https://api.example.com/login', {
      method: 'POST',
      body: JSON.stringify({ email, password })
    })

    const user = await response.json()
    commit('SET_USER', user)
    return user
  },

  // 他のアクション...
}

オプティミスティックUI更新

// Pinia
actions: {
  async addPost(postContent) {
    // オプティミスティックにUIを更新
    const tempId = Date.now()
    this.posts.unshift({
      id: tempId,
      content: postContent,
      isSaving: true
    })

    try {
      const response = await fetch('https://api.example.com/posts', {
        method: 'POST',
        body: JSON.stringify({ content: postContent })
      })

      const newPost = await response.json()

      // 一時的な投稿を実際の投稿で置き換え
      const index = this.posts.findIndex(post => post.id === tempId)
      if (index !== -1) {
        this.posts.splice(index, 1, newPost)
      } else {
        this.posts.unshift(newPost)
      }
    } catch (error) {
      // エラーが発生した場合は一時的な投稿を削除
      this.posts = this.posts.filter(post => post.id !== tempId)
      this.error = '投稿の保存に失敗しました'
      throw error
    }
  }
}

テスト戦略

アクションのユニットテスト例(Jestを使用)

// Vuexアクションのテスト例
import actions from '@/store/actions'
import flushPromises from 'flush-promises'

jest.mock('axios', () => ({
  get: jest.fn(() => Promise.resolve({ data: [{ id: 1, title: 'テスト投稿' }] }))
}))

describe('fetchPostsアクション', () => {
  let commit
  let state

  beforeEach(() => {
    commit = jest.fn()
    state = {}
  })

  it('投稿を正常に取得できる', async () => {
    await actions.fetchPosts({ commit, state })
    await flushPromises()

    expect(commit).toHaveBeenCalledWith('SET_LOADING', true)
    expect(commit).toHaveBeenCalledWith('SET_POSTS', [{ id: 1, title: 'テスト投稿' }])
    expect(commit).toHaveBeenCalledWith('SET_LOADING', false)
  })

  it('エラーが発生した場合に適切に処理する', async () => {
    require('axios').get.mockRejectedValueOnce(new Error('ネットワークエラー'))

    await actions.fetchPosts({ commit, state })
    await flushPromises()

    expect(commit).toHaveBeenCalledWith('SET_ERROR', 'ネットワークエラー')
  })
})

パフォーマンス最適化

リクエストのデバウンス

// utils/debounce.js
export function debounce(fn, delay) {
  let timeoutId
  return function(...args) {
    clearTimeout(timeoutId)
    timeoutId = setTimeout(() => fn.apply(this, args), delay)
  }
}

// Vuexでの使用例
actions: {
  search: debounce(async function({ commit }, query) {
    if (!query.trim()) {
      commit('SET_SEARCH_RESULTS', [])
      return
    }

    commit('SET_SEARCH_LOADING', true)

    try {
      const response = await fetch(`https://api.example.com/search?q=${encodeURIComponent(query)}`)
      const results = await response.json()
      commit('SET_SEARCH_RESULTS', results)
    } catch (error) {
      commit('SET_SEARCH_ERROR', error.message)
    } finally {
      commit('SET_SEARCH_LOADING', false)
    }
  }, 300)
}

まとめ

Vuex/Piniaのアクションは、非同期処理と複雑なビジネスロジックを管理するための強力なツールです。本記事で解説した以下のポイントを押さえることで、より堅牢でメンテナンス性の高いアプリケーションを構築できます:

  1. アクションの基本構造:非同期処理のラッパーとして機能し、最終的に状態を更新
  2. エラーハンドリング:統一的なエラー処理パターンでユーザー体験を向上
  3. 高度なパターン:並列/直列実行、オプティミスティックUI更新など
  4. テスト戦略:モックを使用したアクションの単体テスト
  5. パフォーマンス最適化:デバウンスやキャンセレーションの実装

アクションを適切に設計・実装することで、フロントエンドアプリケーションの複雑なデータフローを管理しやすくなり、チーム開発での協力もスムーズになります。VuexとPiniaには違いがありますが、アクションの基本的な役割とベストプラクティスは共通しています。プロジェクトの規模や要件に合わせて適切なアプローチを選択し、これらのパターンを活用してください。