Reducing Flux (Vuex) boilerplate for AJAX calls
Anyone who has used the flux architecture in their apps, be it Vuex, Redux or another, knows a lot of boilerplate comes with it. Let’s see how we can extract away the boilerplate for asynchronous calls, and handle the call — pending — success — failure pattern.
This article builds on my previous one, but goes further and extracts the mutations into utility function, as well as improve the action utility function.
Demo here, and source here.
The current state
The workflow for a successful asynchronous request should look like:
$store.dispatch(‘getAsync’)
// dispatch the request
$store.dispatch(‘getAsync’)
state = {
getInfoAsyncPending: true // pending... show a spinner
getInfoAsyncStatusCode: null,
getInfoAsyncData: null
}
// some time later... the ajax request is successful
state = {
getInfoAsyncPending: false, // no longer pending
getInfoAsyncStatusCode: 200, // success code 200
getInfoAsyncData: {...} // response data
}
Standard Vuex code would yield something like this:
state = {
getInfoAsyncPending: false
getInfoAsyncStatusCode: null,
getInfoAsyncData: null
}
mutations = {
SET_INFO_PENDING (state, payload) {
getInfoAsyncPending = payload.pending
},
SET_INFO_SUCCESS (state, payload) {
getInfoAsyncStatusCode = payload.statusCode
getInfoAsyncData = payload.data
getInfoAsyncPending = payload.pending
},
SET_INFO_FAILURE (state, payload) {
// ...
}
}
actions = {
getInfo (store) {
store.commit('SET_INFO_PENDING', { pending: true })
return axios('someApiService')
.then(response => {
store.commit('SET_INFO_SUCCESS', {
pending: false
statusCode: response.status,
data: response.data
})
})
.catch(error => {
store.commit('SET_INFO_FAILURE', {
pending: false,
statusCode: error.status
})
})
}
}
Roughly 40 lines of code. Let’s say we have 5 more requests for different resources. We now need to define 15 properties in the state — pending
, data
and statusCode
for each request, and 5 mutations — SUCCESS
, FAILURE
, and PENDING
, and 5 actions, which all do the same thing. Immediately the store bloats to several hundred lines. Let’s DRY it up.
The Mutations and State
Let’s say I want to retrieve a post from a REST API. We can use jsonplaceholder to test. For my action, getAsync
, I want to generate the following object, which defines the mutations and state properties:
GET_INFO_ASYNC = {
BASE: 'GET_INFO_ASYNC',
SUCCESS: 'GET_INFO_ASYNC_SUCCESS',
PENDING: 'GET_INFO_ASYNC_PENDING',
FAILURE: 'GET_INFO_ASYNC_FAILURE',
loadingKey: 'getInfoAsyncPending',
errorCode: 'getInfoAsyncErrorCode',
stateKey: 'getInfoAsyncData'
}
GET_INFO_ASYNC
will be the base mutation that handles SUCCESS
, PENDING
and FAILURE
, and will decide how to update the state based on request status. loadingKey
will be a boolean that indicates whether the request is in progress or not — useful for showing a loading animation. statusCode
will store the requests status, 200 for success, a 401, 404, etc for an error. stateKey
will be the property the response is mapped to.
This can be implemented like so:
// mutation-types.js
import _ from 'lodash'
const createAsyncMutation = (type) => ({
BASE: `${type}`,
SUCCESS: `${type}_SUCCESS`,
FAILURE: `${type}_FAILURE`,
PENDING: `${type}_PENDING`,
loadingKey: `${_.camelCase(type)}Pending`,
statusCode: `${_.camelCase(type)}StatusCode`,
stateKey: `${_.camelCase(type)}Data`
})
This creates the API outlined above for a given mutation type.
Handling the AJAX call
Next, a utility function to handle the request flow. The request lifecycle, again:
- Make the initial call. Set a
loading
flag to betrue
. - The request succeeds. Set the
loading
flag tofalse
, and setstate.data
to the response body. SetstatusCode
to 200. - OR, the request failed. Still set
loading
tofalse.
SetstatusCode
(401, 404, etc).
A first attempt at an implementation is below. See after for an explanation.
// async-util.js
import axios from 'axios'
const doAsync = (store, { url, mutationTypes }) => {
// Send the pending flag. Useful for showing a spinner, etc
store.commit(mutationTypes.BASE, {
type: mutationTypes.PENDING, value: true
})
// make the ajax call.
return axios(url, {})
.then(response => {
let data = response
// the call was successful!
// commit the response and status code to the store.
// we will write the actual mutation logic next.
store.commit(mutationTypes.BASE, {
type: mutationTypes.SUCCESS,
data: data,
statusCode: response.status
})
// also sent pending to false, since the call is complete.
store.commit(mutationTypes.BASE, {
type: mutationTypes.PENDING, value: false
})
})
.catch(error => {
// there was an error. Commit the status code to the store.
// we will write the mutation logic soon.
store.commit(mutationTypes.BASE, {
type: mutationTypes.FAILURE,
statusCode: error.response.status
})
// since the call is complete, sent pending to false.
store.commit(mutationTypes.BASE, {
type: mutationTypes.PENDING,
value: false
})
})
}
The first argument is the store,
which is needed to commit the mutations as the AJAX call goes through it’s lifecycle. The second argument is an object, containing the url
for the endpoint, and mutationTypes
, which are created using the createAsyncMutation
outlined just above (containing GET_ASYNC_INFO_SUCCESS
, stateKey
, and so on.
Note regardless of the success or failure of the call, we commit mutationTypes.BASE
, and then in the payload, pass the type
. This makes implementing the mutation later on more straight forward.
So far, we have createAsyncMutation
and doAsync
. Assuming we export them from wherever file they were defined, we can use in our store like such:
import { createAsyncMutation } from './mutation-types'
import { doAsync } from './async-util'
state = {}
mutations = {} // make these next.
const getInfoAsync = createAsyncMutation('GET_INFO_ASYNC')
const actions = {
getAsync(store, url) {
return doAsync(
store, { url, mutationTypes: getInfoAsync })
}
}
Pretty concise! Creating another async action would only be another 2–3 lines, just pass in the url
and the mutationType
.
Next, we need to generate the mutations.
Generating the Mutations
Actually, we will just generate one mutation, using the BASE
property from the object return from createAsyncMutation
. In the mutation, we will handle SUCCESS
, FAILURE
, and PENDING
using a switch
statement.
SUCCESS
will get the response, mapping to thestateKey
from thecreateAsyncMutation
object. So forgetInfoAsync
, it’ll begetInfoAsyncData
. It will also setgetInfoAsyncStatusCode
to 200.PENDING
will set theloadingKey
, in this casegetInfoAsyncPending
, totrue
orfalse
.FAILURE
will set simply set thestatusCode.
A hardcoded implementation is shown below. The goal is to make this dynamically, for any amount of actions.
GET_INFO_ASYNC_BASE (state, payload) => {
switch (payload.type) {
case GET_INFO_ASYNC_PENDING:
return Vue.set(state, types[type].loadingKey, payload.value)
case GET_INFO_ASYNC_SUCCESS:
Vue.set(state, getInfoAsyncStatusCode, payload.statusCode)
return Vue.set(state, getInfoAsyncData, payload.data)
case GET_INFO_ASYNC_FAILURE:
return Vue.set(state,
getInfoAsyncStatusCode,
payload.statusCode)
}
}
}
Note we use Vue.set
to mutate the state. Since we are not declaring the values when the store is initialized, rather when the request is first made, we need to use Vue.set
. Simply doing state.someDynamicVal = 4
will assign the value to the state, but Vue’s reactivity system will not be aware of it, and changes won’t be reflected in the UI.
Now the final piece of the puzzle: creating the mutations dynamically. This is easy, using the awesome ES6 computed properties. So far we only made one mutation set using createAsyncMutation
, called getInfoAsync
. Say we had two. We can generate the mutations for both like this:
const GET_INFO_ASYNC = createAsyncMutation('GET_INFO_ASYNC')
const GET_MORE_DATA = createAsyncMutation('GET_MORE_DATA')
// put them in a single object so we can iterate over them.
const allAsyncActions = {
'GET_INFO_ASYNC': GET_INFO_ASYNC,
'GET_MORE_DATA': GET_MORE_DATA
}
mutations = {}
// Loop the object, and generate using ES6 computed properties.
Object.keys(types).forEach(type => {
mutations[types[type].BASE] = (state, payload) => {
switch (payload.type) {
case types[type].PENDING:
return Vue.set(state, types[type].loadingKey, payload.value)
case types[type].SUCCESS:
Vue.set(state, types[type].statusCode, payload.statusCode)
return Vue.set(state, types[type].stateKey, payload.data)
case types[type].FAILURE:
return Vue.set(
state,
types[type].statusCode,
payload.statusCode
)
}
}
})
A little hard to read at first glance, but basically using the properties generated in createAsyncAction
, the mutation is created and assigned to the mutations
object. The pending
, statusCode
and data
objects are also created in the store for us, prepended with mutation type — so getInfoAsyncPending
, getInfoAsyncStatusCode
, and getInfoAsyncData.
That’s it! Around 50 lines of code, and we have a simple system to the status of any number of asynchronous calls.
Many improvements can be made. Since the mutations are automatically created, we don’t have a chance to process the data before it hits the store. Often, you want to extract some of the data, clean, or process it in some way before it is committed to the state.
One alternative is to pass an extra property to the doAsync
method, a callback method, which is invoked when the response is a success. The callback contains the custom processing logic. Say we hit an API for blog posts, but only care about the blog title, not the body, but we get it anyway in the response:
// callback to extract the desired data.
const getTitleOnly = (response) => {
return response.data.title
}
const actions = {
getBlogPost(store, url) {
return doAsync(store, {
url: url,
mutationTypes: types.GET_INFO_ASYNC,
callback: getTitleOnly
})
}
}
// inside of async-util.js
// if a callback is passed, invoke it on the response
// before committing to the store.
const doAsync = (store, { url, mutationTypes, callback }) => {
// ..
return axios(url, {})
.then(response => {
let data = response
// if we passed a callback, invoke it here.
if (callback) {
data = callback(response)
}
store.commit(mutationTypes.BASE, {
type: mutationTypes.SUCCESS,
data: data,
statusCode: response.status
})}
// handle failed call...
})
Some other things to consider would be how to handle POST requests (I only demonstrate GET here) and more complex scenarios.
I’ll start to move this into a library bit by bit, and hopefully typing out CRUD actions will be a thing of the past.
Thanks for reading! Any comments or ideas are more than welcome.