Vuex
# Vuex
Vuex is a state management pattern developed specifically for Vue.js applications.
# Vuex Usage Demo
Using a project newly created with vue-cli3 as an example to demonstrate the Vuex usage process.
Create a project:
vue create vuex-test
cd vuex-test
npm run serve
2
3
Install vuex:
npm i vuex -S
Navigate to the project's src/ directory and create a new file store/index.js with the following content:
// store/index.js
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
const store = new Vuex.Store({ // Store container (constructor Store starts with uppercase)
state: { // State
count: 0
},
mutations: { // Mutations (using mutations to commit changes makes it easy to track change history)
increment (state){
state.count++
}
}
})
export default store // Export
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Go to main.js and inject store to make all Vue components able to use Vuex:
// main.js
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
Vue.config.productionTip = false
new Vue({
router,
store,
render: h => h(App)
}).$mount('#app')
2
3
4
5
6
7
8
9
10
11
12
13
Now we can commit a change from a component's method:
methods: {
increment() {
this.$store.commit('increment') // .commit('<mutation event name>')
console.log(this.$store.state.count)
}
}
2
3
4
5
6
Using state in a component template:
{{ count }}
computed: {
count() {
return this.$store.state.count
}
}
2
3
4
5
6
7
State changes trigger recomputation of computed properties
# Core Concepts
# State
Vuex uses a single state tree -- each application will contain only one store instance.
# mapState Helper
When a component needs to access multiple states, declaring all of them as computed properties can be repetitive and verbose. To solve this problem, we can use the mapState helper to generate computed properties, saving you some keystrokes:
// In a standalone build, the helper is available as Vuex.mapState
import { mapState } from 'vuex'
export default {
// ...
computed: mapState({
// Arrow function for concise code
count: state => state.count,
// Passing the string 'count' is equivalent to `state => state.count`
countAlias: 'count',
// To use `this` to access local state, a regular function must be used
countPlusLocalState (state) {
return state.count + this.localCount
}
})
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
All three approaches in the mapState() parameter above are ways to get the count value.
When the mapped computed property name matches a state sub-tree node name, we can also pass a string array to mapState.
computed: mapState([
// Maps this.count to store.state.count
'count'
])
2
3
4
# Object Spread Operator
mapState returns an object. We can use the object spread operator to mix it into the outer object:
computed: {
localComputed () { /* ... */ }, // other computed properties
// Use the object spread operator to mix this object into the outer object
...mapState({
// ... same getter methods as above
})
}
2
3
4
5
6
7
# Components Can Still Have Local State
Using Vuex doesn't mean you should put all your state in Vuex. Although putting all state in Vuex makes state changes more explicit and easy to debug, it would also make the code verbose and unintuitive. If some state strictly belongs to a single component, it's fine to keep it as local state. You should weigh the trade-offs based on your application's development needs.
# Getter
Sometimes we need to derive some state from the store's state, for example filtering through a list and counting:
computed: {
doneTodosCount () {
return this.$store.state.todos.filter(todo => todo.done).length
}
}
2
3
4
5
If more than one component needs to use this property, we either duplicate this function, or extract it into a shared helper and import it in multiple places -- neither is ideal.
Vuex allows us to define "getters" in the store (think of them as computed properties for stores). Like computed properties, a getter's result is cached based on its dependencies, and will only re-evaluate when some of its dependencies have changed.
Getters receive state as their first argument:
const store = new Vuex.Store({
state: {
todos: [
{ id: 1, text: '...', done: true },
{ id: 2, text: '...', done: false }
]
},
getters: {
doneTodos: state => {
return state.todos.filter(todo => todo.done)
}
}
})
2
3
4
5
6
7
8
9
10
11
12
13
# Property-Style Access
Getters will be exposed on the store.getters object, and you access values as properties:
store.getters.doneTodos // -> [{ id: 1, text: '...', done: true }]
Getters can also accept other getters as the second argument:
getters: {
// ...
doneTodosCount: (state, getters) => {
return getters.doneTodos.length
}
}
store.getters.doneTodosCount // -> 1
2
3
4
5
6
7
We can easily use it inside any component:
computed: {
doneTodosCount () {
return this.$store.getters.doneTodosCount
}
}
2
3
4
5
Note that getters accessed as properties are cached as part of Vue's reactivity system.
# Method-Style Access
You can also pass arguments to getters by making the getter return a function. This is particularly useful when querying an array in the store.
getters: {
// ...
getTodoById: (state) => (id) => {
return state.todos.find(todo => todo.id === id)
}
}
store.getters.getTodoById(2) // -> { id: 2, text: '...', done: false }
2
3
4
5
6
7
Note that getters accessed via methods will run each time they are called, and the result is not cached.
# mapGetters Helper
The mapGetters helper simply maps store getters to local computed properties:
import { mapGetters } from 'vuex'
export default {
// ...
computed: {
// Use the object spread operator to mix getters into the computed object
...mapGetters([
'doneTodosCount',
'anotherGetter',
// ...
])
}
}
2
3
4
5
6
7
8
9
10
11
12
13
If you want to map a getter to a different name, use an object:
...mapGetters({
// Map `this.doneCount` to `this.$store.getters.doneTodosCount`
doneCount: 'doneTodosCount'
})
2
3
4
# Mutation
The only way to change state in a Vuex store is by committing a mutation. Vuex mutations are very similar to events: each mutation has a string event type and a handler function. The handler function is where we perform actual state modifications, and it receives state as the first argument:
const store = new Vuex.Store({
state: {
count: 1
},
mutations: {
increment (state) {
// mutate state
state.count++
}
}
})
2
3
4
5
6
7
8
9
10
11
You cannot directly call a mutation handler. Think of it more like event registration: "When a mutation with type increment is triggered, call this handler." To invoke a mutation handler, you need to call store.commit with its type:
store.commit('increment')
# Commit with Payload
You can pass additional arguments to store.commit, which is the mutation's payload:
// ...
mutations: {
increment (state, n) {
state.count += n
}
}
store.commit('increment', 10)
2
3
4
5
6
7
In most cases, the payload should be an object, which can contain multiple fields and makes the recorded mutation more readable:
// ...
mutations: {
increment (state, payload) {
state.count += payload.amount
}
}
store.commit('increment', {
amount: 10
})
2
3
4
5
6
7
8
9
# Object-Style Commit
An alternative way to commit a mutation is by directly using an object with a type property:
store.commit({
type: 'increment',
amount: 10
})
2
3
4
When using object-style commit, the entire object is passed as the payload to the mutation function, so the handler remains the same:
mutations: {
increment (state, payload) {
state.count += payload.amount
}
}
2
3
4
5
# Mutations Follow Vue's Reactivity Rules
Since the Vuex store's state is reactive, when we mutate the state, Vue components observing the state will update automatically. This also means Vuex mutations need to follow the same caveats as working with Vue:
- Prefer to initialize all needed state properties upfront in your store.
- When adding new properties to an Object, you should either:
Use
Vue.set(obj, 'newProp', 123), orReplace the object with a fresh one. For example, using the object spread operator (opens new window) we can write:
state.obj = { ...state.obj, newProp: 123 }1
# Using Constants for Mutation Types
Using constants for mutation types is a common pattern in various Flux implementations. This allows linter tools to work effectively, and putting these constants in a separate file gives collaborators an at-a-glance view of all mutations in the entire app:
// mutation-types.js
export const SOME_MUTATION = 'SOME_MUTATION'
// store.js
import Vuex from 'vuex'
import { SOME_MUTATION } from './mutation-types'
const store = new Vuex.Store({
state: { ... },
mutations: {
// We can use the ES2015 computed property name feature to use a constant as the function name
[SOME_MUTATION] (state) {
// mutate state
}
}
})
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Whether to use constants is up to you -- it can be helpful in large projects where many people collaborate. But if you don't like them, you are free to skip this.
# Mutations Must Be Synchronous
One important rule is to remember that mutations must be synchronous functions. Why? Consider this example:
mutations: {
someMutation (state) {
api.callAsyncMethod(() => {
state.count++
})
}
}
2
3
4
5
6
7
Now imagine we are debugging an app and looking at the mutation log in devtools. For every mutation logged, devtools needs to capture a "before" and "after" snapshot of the state. However, the asynchronous callback in the mutation above makes this impossible: the callback is not yet called when the mutation is triggered, and devtools has no way of knowing when the callback is actually called -- making any state mutation performed in the callback essentially untrackable.
# Committing Mutations in Components (mapMutations Helper)
You can commit mutations in components with this.$store.commit('xxx'), or use the mapMutations helper to map component methods to store.commit calls (requires injecting store into the root component).
import { mapMutations } from 'vuex'
export default {
// ...
methods: {
...mapMutations([
'increment', // Maps `this.increment()` to `this.$store.commit('increment')`
// `mapMutations` also supports payloads:
'incrementBy' // Maps `this.incrementBy(amount)` to `this.$store.commit('incrementBy', amount)`
]),
...mapMutations({
add: 'increment' // Maps `this.add()` to `this.$store.commit('increment')`
})
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# Next: Action
Mixing asynchronous calls in mutations can make your program very hard to debug. For example, when you call two mutations that both have async callbacks to mutate state, how do you know when they are called and which callback was called first? This is why we separate the two concepts. In Vuex, mutations are synchronous transactions:
store.commit('increment')
// Any state change that "increment" causes should be completed at this moment.
2
To handle asynchronous operations, let's look at Actions (opens new window).
# Action
Actions are similar to mutations, with the differences being:
- Actions commit mutations, rather than directly mutating the state.
- Actions can contain arbitrary asynchronous operations.
Let's register a simple action:
const store = new Vuex.Store({
state: {
count: 0
},
mutations: {
increment (state) {
state.count++
}
},
actions: {
increment (context) {
context.commit('increment')
}
}
})
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Action functions receive a context object with the same methods and properties as the store instance, so you can call context.commit to commit a mutation, or access state and getters via context.state and context.getters. When we introduce Modules (opens new window) later, you'll see why the context object is not the store instance itself.
In practice, we often use ES2015 argument destructuring (opens new window) to simplify the code (especially when we need to call commit many times):
actions: {
increment ({ commit }) {
commit('increment')
}
}
2
3
4
5
# Dispatching Actions
Actions are triggered via the store.dispatch method:
store.dispatch('increment')
This might seem like extra work at first glance -- wouldn't it be more convenient to just dispatch mutations directly? Remember that mutations must be synchronous. Actions don't have that constraint! We can perform asynchronous operations inside an action:
actions: {
incrementAsync ({ commit }) {
setTimeout(() => {
commit('increment')
}, 1000)
}
}
2
3
4
5
6
7
Actions support the same payload format and object-style dispatch:
// Dispatch with a payload
store.dispatch('incrementAsync', {
amount: 10
})
// Dispatch with an object
store.dispatch({
type: 'incrementAsync',
amount: 10
})
2
3
4
5
6
7
8
9
10
A more practical example of a shopping cart, which involves calling async APIs and committing multiple mutations:
actions: {
checkout ({ commit, state }, products) {
// Save the items currently in the cart
const savedCartItems = [...state.cart.added]
// Send checkout request, then optimistically clear the cart
commit(types.CHECKOUT_REQUEST)
// The shop API accepts a success callback and a failure callback
shop.buyProducts(
products,
// Success handler
() => commit(types.CHECKOUT_SUCCESS),
// Failure handler
() => commit(types.CHECKOUT_FAILURE, savedCartItems)
)
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Note that we are performing a series of asynchronous operations and recording the side effects (state mutations) of the action by committing mutations.
# Dispatching Actions in Components (mapActions Helper)
You can dispatch actions in components with this.$store.dispatch('xxx'), or use the mapActions helper to map component methods to store.dispatch calls (requires injecting store into the root component first):
import { mapActions } from 'vuex'
export default {
// ...
methods: {
...mapActions([
'increment', // Maps `this.increment()` to `this.$store.dispatch('increment')`
// `mapActions` also supports payloads:
'incrementBy' // Maps `this.incrementBy(amount)` to `this.$store.dispatch('incrementBy', amount)`
]),
...mapActions({
add: 'increment' // Maps `this.add()` to `this.$store.dispatch('increment')`
})
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# Composing Actions
Actions are often asynchronous, so how do we know when an action is done? And more importantly, how can we compose multiple actions to handle more complex async flows?
First, you need to understand that store.dispatch can handle the Promise returned by the triggered action's handler, and that store.dispatch itself also returns a Promise:
actions: {
actionA ({ commit }) {
return new Promise((resolve, reject) => {
setTimeout(() => {
commit('someMutation')
resolve()
}, 1000)
})
}
}
2
3
4
5
6
7
8
9
10
Now you can do:
store.dispatch('actionA').then(() => {
// ...
})
2
3
And also in another action:
actions: {
// ...
actionB ({ dispatch, commit }) {
return dispatch('actionA').then(() => {
commit('someOtherMutation')
})
}
}
2
3
4
5
6
7
8
Finally, if we use async / await (opens new window), we can compose actions like this:
// Assuming getData() and getOtherData() return Promises
actions: {
async actionA ({ commit }) {
commit('gotData', await getData())
},
async actionB ({ dispatch, commit }) {
await dispatch('actionA') // Wait for actionA to finish
commit('gotOtherData', await getOtherData())
}
}
2
3
4
5
6
7
8
9
10
11
A
store.dispatchcan trigger multiple action functions in different modules. In such a case, the returned Promise will only resolve when all triggered handlers have been resolved.
# More
For more content, see the official documentation: https://vuex.vuejs.org/zh/guide/modules.html (opens new window)