
Vue.js ルーティング演習問題
基本概念まとめ 1. ルーティングの基本設定 2. ナビゲーションガード 3. 動的ルーティング 演習問題(全24問) 初級問題(6問) 基本ルーティング ナビ […]
現代のフロントエンドアプリケーションでは、APIからのデータ取得やバックエンドとの通信といった非同期処理が不可欠です。VuexやPiniaの「アクション(Actions)」は、これらの非同期操作を管理し、最終的に状態を更新するための強力な仕組みを提供します。本記事では、アクションの詳細な使い方、非同期処理のパターン、エラーハンドリング、そして実践的なベストプラクティスについて、具体的なコード例と図解を交えて解説します。
アクションはVuex/Piniaにおける非同期処理とビジネスロジックの中心です。ミューテーションとは異なり、以下の特徴があります:
// 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)
}
}
}
})
// 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からのデータ取得です。
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)
}
}
}
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>
// 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
},
// 他のアクション...
}
// 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
}
}
}
// 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のアクションは、非同期処理と複雑なビジネスロジックを管理するための強力なツールです。本記事で解説した以下のポイントを押さえることで、より堅牢でメンテナンス性の高いアプリケーションを構築できます:
アクションを適切に設計・実装することで、フロントエンドアプリケーションの複雑なデータフローを管理しやすくなり、チーム開発での協力もスムーズになります。VuexとPiniaには違いがありますが、アクションの基本的な役割とベストプラクティスは共通しています。プロジェクトの規模や要件に合わせて適切なアプローチを選択し、これらのパターンを活用してください。