
Composing actions with Vuex
Introduction to implementing complex actions using Vuex, the Vue.js state management library.
In the last article I mentioned that in any larger Vue.js application, the data and logic should be separated from the UI components. The best way to do it is to use a Vuex store.
One large store for the entire application quickly becomes hard to maintain. Just like the user interface is separated into various components, the store can also be separated into modules. But this separation is not as obvious as in case of the user interface.
You can easily identify UI components by just looking at the page: any part of the page which is duplicated in different places or which is a visually separate element will become a component.
The state of the application usually consists of both simple variables and complex data structures. Some of them are related, others are independent. This is a very simple example:
const state = {
baseUrl: '',
userName: '',
sidebarCollapsed: false,
products: []
cart: []
};
Introducing modules
First let’s answer the following question: what exactly is a Vuex module?
The state of the store can contain child objects, forming a hierarchical structure of information. For example:
const state = {
global: {
baseUrl: '',
userName: ''
},
ui: {
windowCollapsed: false
},
products: {
all: []
},
cart: {
added: []
}
};export default new Vuex.Store( {
state
} );
Various general purpose variables go into the global
object. Variables which control the state of the user interface go into the ui
object. The products
and cart
objects contain application specific data.
Dividing the state in this way is useful, but we still have to put everything into a single file and the mutations and actions are not separated in any way. We can solve these problems using Vuex modules.
You can think about a module as a separate object, which groups together some variables. It can also contain its own mutations and actions. And most importantly, it can be defined in a separate file, which makes the code more readable and easier to maintain.
For example, the global
module can contain just the following state:
const state = {
baseUrl: '',
userName: ''
};export default {
namespaced: true,
state
};
The main file which defines the Vuex store only contains a list of modules:
import Vue from 'vue'
import Vuex from 'vuex'import global from './modules/global'
import ui from './modules/ui'
import products from './modules/products'
import cart from './modules/cart'Vue.use( Vuex );export default new Vuex.Store( {
modules: {
global,
ui,
products,
cart
}
} );
Mutations and actions
The store is useless without mutations and actions, so let’s introduce them.
Mutations should only modify the state of the module in which they are defined. In other words, in order to modify the baseUrl
variable of the global
module, you should create the following mutation in the global
module:
const mutations = {
setBaseUrl( store, { baseUrl } ) {
store.baseUrl = baseUrl;
}
};
In order to call this mutation, you should prefix its name with the name of the module:
store.commit( 'global/setBaseUrl', { baseUrl: '...' } );
Note that this behavior is disabled by default — to enable it, set the namespaced
option to true in the module definition, like in the example above. This is very useful not only to avoid naming conflicts between modules, but also to make it easier to find the specific mutation or action.
Actions are different from mutations in that they cannot directly modify the state of the store — they need to call mutations to do that. At first this seems redundant, but in fact actions are very useful.
The most obvious use of actions is for performing asynchronous operations, where a mutation is called after the operation completes. Note that mutations themselves cannot be asynchronous.
However, actions are much more powerful. They can access the state of the entire store and invoke mutations and other actions, even in other modules.
I recommend using actions to implement all non-trivial logic, even synchronous operations. On the other hand, you should avoid putting any logic inside mutations and treat them as very simple “setters”, as shown above. This will make the code easier to understand, as the purpose of a mutation can be easily determined from context, without looking at its code.
So let’s look at an example of a complex action from the products
module:
const actions = {
loadProducts( { state, getters, commit, dispatch } ) {
if ( !state.loading ) {
commit( 'setLoading', true );
const query = getters.loadProductsQuery;
axios.post( 'some/url', query ).then( response => {
commit( 'setLoading', false );
if ( isEqual( query, getters.loadProductsQuery ) )
commit( 'setData', response.data );
else
dispatch( 'loadProducts' );
} );
}
}
};
As you can see, it performs an asynchronous AJAX request to the server using the axios library.
The loading
property is used to prevent sending multiple requests at the same time. It can also be used by a UI component to provide some visual feedback to the user.
The loadProductsQuery
getter returns an object which describes the products that should be loaded from the server. It might include the current category, filters, page number, etc.
When the server returns a response, we check if the query wasn’t changed in the meantime. We use the isEqual()
function which can be imported from the lodash.isequal module, for example. If the query remains the same, we save the returned products in the store using the setData
mutation. Otherwise, we reload the products using the new query.
By now you should already see that actions in Vuex are very powerful. You should generally think about actions as functions which may implement even very complex logic. You just have to remember that you need to call commit()
instead of modifying the state directly and dispatch()
instead of simply calling other functions, but that’s something that you quickly get used to.
Composing actions
Actions are typically invoked by components in response to user events, but since they can also be invoked by other actions, nothing stops us from creating “internal” actions, which contain common code used by other actions. It’s almost the same as extracting common code into separate functions or methods.
In fact, nothing stops us from creating entire modules which consist only of such internal or shared actions.
In the example code above, the axios.post()
function was called directly, without any options. In practice, you will often need to pass a set of common options to that function: the base URL, headers, cookies, etc. Also there is no error handling in case the request fails for any reason.
Let’s fix this by creating and additional Vuex module called api
. It will contain some helper actions for performing AJAX requests, taking care of passing appropriate options and handling errors.
This simple module doesn’t need any state. It will contain a helper getter which returns the options object that will be passed to axios.post()
:
const getters = {
config( state, getters, rootState ) {
return {
baseURL: rootState.global.baseUrl,
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
}
}
};
As you can see, a getter can retrieve data from another module, in our case the global
module which stores the base URL of the server.
Let’s define some actions:
const actions = {
post( { getters, commit, dispatch }, { url, data = {} } ) {
return new Promise( ( resolve, reject ) => {
axios.post( url, data, getters.config )
.then( response => resolve( response.data ) )
.catch( error => dispatch( 'error', { error } ) );
} );
},
error( context, { error } ) {
console.error( error );
}
};
The post
action takes a relative URL and optional request data as arguments. It returns an asynchronous Promise
with resolves with the response data in case of a successful response. In case of an error, the internal error
action is invoked and the promise never resolves.
All actions can return a promise, which makes it very easy to chain multiple actions together and pass asynchronous results from one action to another.
The error
action simply outputs the error to the console in this example, but it might also change the state of the ui
module to display an error message.
Now we can modify the example loadProducts
action to use our new helper action:
const actions = {
loadProducts( { state, getters, commit, dispatch } ) {
if ( !state.loading ) {
commit( 'setLoading', true );
const query = getters.loadProductsQuery;
dispatch( 'api/post',
{ url: 'some/url', data: query },
{ root: true }
).then( data => {
commit( 'setLoading', false );
if ( isEqual( query, getters.loadProductsQuery ) )
commit( 'setData', data );
else
dispatch( 'loadProducts' );
} );
}
}
};
As you can see, in order to invoke an action from another module, we need to prefix it with the module name, in our case api/post
. We also need to pass the root: true
option.
Root actions
Certain actions can perform operations which affect the entire application, for example initialization or routing (I wrote about this the last article). They are not associated with any specific module, so they can be defined in the root of the Vuex store.
I recommend putting them into a separate file called actions.js
:
export function startup( { dispatch } ) {
// ...
}export function navigate( { dispatch } ) {
// ...
}
Then they can be imported and added to the store like this:
import * as actions from './actions'export default new Vuex.Store( {
actions,
modules: {
// ...
}
} );
Such actions should invoke mutations and actions from other modules instead of modifying the state of the store directly.
This simple example illustrates how complex actions can be composed of simpler actions. Vuex modules can contain not only public actions, invoked by UI components, but also actions used by other actions in the same module or other modules.
You can even create internal modules used only by other modules. This way you can implement the whole application logic as part of the Vuex store. It takes a bit of time to get used to the commit/dispatch syntax, but it’s a very powerful tool.