With this article, we begin a series of publications about Vue.js technology and try to make out the application development and all its components from different practical sides. In this part, we will tell you what the Vuex library is and analyze in detail such components as a store, state, getters, mutations, and actions.
Also, in the second part, we will consider modules, application structure, plugins, strict mode, work with forms, testing and strengths/benefits of Vuex Storage.
What is Vuex, and Where it Used?
VueX is a state management library inspired by Flux, Redux, and Elm architecture, but specially designed and tuned to integrate well with Vue.js and take advantage of Vue’s Reactivity.
What is a state management pattern? Let's start with a simple Vue application that implements a counter. This stand-alone application consists of the following parts:
- State that controls the application;
- The view is a state display specified declaratively;
- Actions are possible ways to change the state of the app in response to users interaction with the view.
Sometimes several components may appear that are based on the same state:
- multiple views may depend on the same part of the application state;
- actions from different views can affect the equal parts of the application state.
Solving the first problem, you will have to transfer the same data with input parameters to deeply embedded components. This is often complicated and tedious, but for neighboring elements this will not work at all. Solving the second problem, you can come to such solutions as referring to parent/child instances or try changing and synchronizing multiple state copies through actions. Both approaches are fragile and quickly lead to the emergence of code that cannot be supported.
So why not take out the overall general state of the application from the components and manage it in a global singleton? At the same time, our component tree becomes one big "view" and any component can access the application state or trigger actions to change the state, regardless of where they are in the tree!
By clearly defining and separating the concepts that arise in state management, and by requiring certain rules that maintain independence between views and states, we better structure the code and make it easier to maintain.
This is the core idea of Vuex, inspired by Flux, Redux, and Elm Architecture. Unlike other patterns, Vuex is implemented as a library designed explicitly for Vue.js to use its reactivity system for efficient updates.
The Main Components and Capabilities of Vuex
Store
At the center of any Vuex application is a store. The store is a container that stores the state of your application. Two points distinguish Vuex store from a simple global object:
- The Vuex store is reactive. When Vue components rely on their state, they will be reactively and efficiently updated if the state of the store changes.
- You cannot directly change the state of the store. The only way to make changes is to cause a mutation explicitly. This ensures that any change in the state leaves a mark and allows the use of tools to better understand the progress of the application.
After installing Vuex, a repository is created. It's quite simple, and you need to specify the initial state object and some actions and mutations.
const store = new Vuex.Store({
state: {
counter: 0 // initial store state
},
actions: {
increment({ commit, dispatch, getters }) {
commit('INCREMENT')
},
decrement({ commit, dispatch, getters }) {
commit('DECREMENT')
}
},
mutations: {
INCREMENT(state) {
state.counter++
},
DECREMENT(state) {
state.counter--
}
},
getters: {
counter(state) {
return state.counter
}
}
})
The reason we are committing a mutation instead of changing store.state.count
directly, is because we want to explicitly track it. This simple convention makes your intention more explicit, so that you can reason about state changes in your app better when reading the code. In addition, this gives us the opportunity to implement tools that can log every mutation, take state snapshots, or even perform time travel debugging.
State. Single state tree
Vuex uses a single state tree when one object contains the entire global state of the application and serves as the only one source. It also means that the app will have only one such storage. A single state tree makes it easy to find the part you need or take snapshots of the current state of the application for debugging purposes.
The data you store in Vuex follows the same rules as the data
in a Vue instance, ie the state object must be plain. So how do we display state inside the store in our Vue components? Since Vuex stores are reactive, the simplest way to "retrieve" state from it is simply returning some store state from within a computed property. Whenever store.state.count
changes, it will cause the computed property to re-evaluate, and trigger associated DOM updates.
This pattern causes the component to rely on the global store singleton. When using a module system, it requires importing the store in every component that uses store state, and also requires mocking when testing the component. Vuex provides a mechanism to "inject" the store into all child components from the root component with the $store
option (enabled by Vue.use(Vuex)
)
export default {
methods: {
incrementCounter() {
this.$store.dispatch('increment')
}
}
}
When a component needs to make use of multiple store state properties or getters, declaring all these computed properties can get repetitive and verbose. To deal with this we can make use of the mapState
helper which generates computed getter functions for us, saving us some keystrokes:
import { mapState } from 'vuex';
export default {
computed: {
...mapState({
counter: state => state.counter
}),
counterSquared() {
return Math.pow(this.counter, 2)
}
}
}
We can also pass a string array to mapState
when the name of a mapped computed property is the same as a state sub tree name.
Note that mapState
returns an object. How do we use it in combination with other local computed properties? Normally, we'd have to use a utility to merge multiple objects into one so that we can pass the final object to computed
. However with the object spread operator (which is a stage-4 ECMAScript proposal), we can greatly simplify the syntax as shown above.
Using Vuex doesn't mean you should put all the state in Vuex. Although putting more state into Vuex makes your state mutations more explicit and debuggable, sometimes it could also make the code more verbose and indirect. If a piece of state strictly belongs to a single component, it could be just fine leaving it as local state. You should weigh the trade-offs and make decisions that fit the development needs of your app.
Getters
Sometimes we may need to compute derived state based on store state, for example filtering through a list of items and counting them.
If more than one component needs to make use of this, we have to either duplicate the function, or extract it into a shared helper and import it in multiple places - both are less than ideal.
Vuex allows us to define "getters" in the store. You can 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.
// In store
getters: {
counter(state) {
return state.counter
},
counterSquared(state) {
return Math.pow(state.counter, 2)
}
}
// In component
import { mapGetters } from 'vuex';
export default {
computed: {
...mapgetters([ 'counter', 'counterSquared' ])
}
}
You can also pass arguments to getters by returning a function. This is particularly useful when you want to query an array in the store. Note that getters accessed via methods will run each time you call them, and the result is not cached.
The mapGetters
helper simply maps store getters to local computed properties.
Mutations
The only way to actually change state in a Vuex store is by committing a mutation. Vuex mutations are very similar to events: each mutation has a string type and a handler. The handler function is where we perform actual state modifications, and it will receive the state as the first argument.
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.
export default {
methods: {
incrementCounter() {
this.$store.commit('INCREMENT')
}
}
}
You can pass an additional argument to store.commit
, which is called the payload for the mutation. In most cases, the payload should be an object so that it can contain multiple fields, and the recorded mutation will also be more descriptive. An alternative way to commit a mutation is by directly using an object that has a type
property. When using object-style commit, the entire object will be passed as the payload to mutation handlers, so the handler remains the same.
Since a Vuex store's state is made reactive by Vue, when we mutate the state, Vue components observing the state will update automatically. This also means Vuex mutations are subject to the same reactivity caveats when working with plain Vue:
- Prefer initializing your store's initial state with all desired fields upfront.
- When adding new properties to an Object, you should either - use
Vue.set(obj, 'newProp', 123)
, or replace that Object with a fresh one. For example, using the object spread syntax.
However, using constants to indicate the types of mutations is completely optional, although this may be useful in large projects.
One important rule to remember is that mutation handler functions must be synchronous. Imagine we are debugging the app and looking at the devtool's mutation logs. For every mutation logged, the devtool will need to capture a "before" and "after" snapshots of the state. However, the asynchronous callback inside the example mutation above makes that impossible: the callback is not called yet when the mutation is committed, and there's no way for the devtool to know when the callback will actually be called - any state mutation performed in the callback is essentially untrackable!
You can commit mutations in components with this.$store.commit('xxx')
, or use the mapMutations
helper which maps component methods to store.commit
calls (requires root $store
injection)
Asynchronicity combined with state mutation can make your program very hard to reason about. For example, when you call two methods both with async callbacks that mutate the state, how do you know when they are called and which callback was called first? This is exactly why to separate the two concepts. In Vuex, mutations are synchronous transactions. To handle asynchronous operations, should descry Actions.
Actions
Actions are similar to mutations with a few differences:
- Instead of mutating the state, actions commit mutations.
- Actions can contain arbitrary asynchronous operations.
actions: {
signIn({ commit }, payload) {
// Show spinner when user submit form
commit('LOGIN_IN_PROGRESS', true);
// axios - Promise based HTTP client for browser and node.js
axios
.post('/api/v1/sign_in', {
email: payload.email
password: payload.password
})
.then((response) => {
const { user, token } = response.data;
commit('SET_AUTH_TOKEN', token);
commit('SET_USER', user);
commit('LOGIN_IN_PROGRESS', false);
})
.catch((error) => {
commit('SET_SIGN_IN_ERROR', error.response.data.reason);
commit('LOGIN_IN_PROGRESS', false);
})
}
}
Asynchronous action on the example of authorization
Action handlers receive a context object which exposes the same set of methods/properties on the store instance, so you can call context.commit
to commit a mutation, or access the state and getters via context.state
and context.getters
. We can even call other actions with context.dispatch
. We will see why this context object is not the store instance itself when we introduce Modules later.
In practice, we often use ES2015 argument destructuring to simplify the code a bit especially when we need to call commit
multiple times. Actions are triggered with the store.dispatch
method. This may look silly at first sight if we want to increment the count, why don't we just call store.commit('increment')
directly? Remember that mutations have to be synchronous? Actions don't. We can perform asynchronous operations inside an action. Actions support the same payload format and object-style dispatch.
A more practical example of real-world actions would be an action to checkout a shopping cart, which involves calling an async API and committing multiple mutations. Performing a flow of asynchronous operations, and recording the side effects (state mutations) of the action by committing them.
You can dispatch actions in components with this.$store.dispatch('xxx')
, or use the mapActions
helper which maps component methods to store.dispatch
calls (requires root $store
injection). Actions are often asynchronous, so how do we know when an action is done? And more importantly, how can we compose multiple actions together to handle more complex async flows?
The first thing to know is that store.dispatch
can handle Promise returned by the triggered action handler and it also returns Promise. It's possible for a store.dispatch
to trigger multiple action handlers in different modules. In such a case the returned value will be a Promise that resolves when all triggered handlers have been resolved.
It is only a small part of what we are going to tell in our next articles about Vue.js and all of its additional tools and benefits. Next, we continue our review of the Vuex library and components.
Also, in our blog section, you can read more about Vue.js tool here https://amoniac.eu/blog/post/why-we-fell-in-love-with-vue-js
Top comments (0)