Advertisement
  1. Code
  2. Vue
Code

How to Build Complex, Large-Scale Vue.js Apps With Vuex

by
Difficulty:AdvancedLength:LongLanguages:

It's so easy to learn and use Vue.js that anyone can build a simple application with that framework. Even novices, with the help of Vue's documentation, can do the job. However, when complexity comes into play, things get a bit more serious. The truth is that multiple, deeply nested components with shared state can quickly turn your application into an unmaintainable mess.

The main problem in a complex application is how to manage the state between components without writing spaghetti code or producing side effects. In this tutorial you'll learn how to solve that problem by using Vuex: a state management library for building complex Vue.js applications.

What Is Vuex?

Vuex is a state management library specifically tuned for building complex, large-scale Vue.js applications. It uses a global, centralized store for all the components in an application, taking advantage of its reactivity system for instant updates.

The Vuex store is designed in such a way that it is not possible to change its state from any component. This ensures that the state can only be mutated in a predictable manner. Thus your store becomes a single source of truth: every data element is only stored once and is read-only to prevent the application's components from corrupting the state that is accessed by other components.

Why Do You Need Vuex?

You may ask: Why do I need Vuex in the first place? Can't I just put the shared state in a regular JavaScript file and import it into my Vue.js application?

You can, of course, but compared to a plain global object, the Vuex store has some significant advantages and benefits:

  • The Vuex store is reactive. Once components retrieve a state from it, they will reactively update their views every time the state changes.
  • Components cannot directly mutate the store's state. The only way to change the store's state is by explicitly committing mutations. This ensures every state change leaves a trackable record, which makes the application easier to debug and test.
  • You can easily debug your application thanks to the Vuex integration with Vue's DevTools extension.
  • The Vuex store gives you a bird's eye view of how everything is connected and affected in your application.
  • It's easier to maintain and synchronize the state between multiple components, even if the component hierarchy changes.
  • Vuex makes direct cross-component communication possible.
  • If a component is destroyed, the state in the Vuex store will remain intact.

Getting Started With Vuex

Before we get started, I want to make several things clear. 

First, to follow this tutorial, you need to have a good understanding of Vue.js and its components system, or at least minimal experience with the framework. 

Also, the aim of this tutorial is not to show you how to build an actual complex application; the aim is to focus your attention more on Vuex concepts and how you can use them to build complex applications. For that reason, I'm going to use very plain and simple examples, without any redundant code. Once you fully grasp the Vuex concepts, you will be able to apply them on any level of complexity.

Finally, I'll be using ES2015 syntax. If you are not familiar with it, you can learn it here.

And now, let's get started!

Setting Up a Vuex Project

The first step to get started with Vuex is to have Vue.js and Vuex installed on your machine. There are several ways to do that, but we'll use the easiest one. Just create an HTML file and add the necessary CDN links:

I used some CSS to make the components look nicer, but you don't need to worry about that CSS code. It only helps you to gain a visual notion about what is going on. Just copy and paste the following inside the <head> tag:

Now, let's create some components to work with. Inside the <script> tag, right above the closing </body> tag, put the following Vue code:

Here, we have a Vue instance, a parent component, and two child components. Each component has a heading "Score:" where we'll output the app state.

The last thing you need to do is to put a wrapping <div> with id="app" right after the opening <body>, and then place the parent component inside:

The preparation work is now done, and we're ready to move on.

Exploring Vuex

State Management

In real life, we deal with complexity by using strategies to organize and structure the content we want to use. We group related things together in different sections, categories, etc. It's like a book library, in which the books are categorized and put in different sections so that we can easily find what we are looking for. Vuex arranges the application data and logic related to state in four groups or categories: state, getters, mutations, and actions.

State and mutations are the base for any Vuex store:

  • state is an object that holds the state of the application data.
  • mutations is also an object containing methods which affect the state.

Getters and actions are like logical projections of state and mutations:

  • getters contain methods used to abstract the access to the state, and to do some preprocessing jobs, if needed (data calculating, filtering, etc.).
  • actions are methods used to trigger mutations and execute asynchronous code.

Let's explore the following diagram to make things a bit clearer:

Vuex State Management Workflow Diagram

On the left side, we have an example of a Vuex store, which we'll create later on in this tutorial. On the right side, we have a Vuex workflow diagram, which shows how the different Vuex elements work together and communicate with each other.

In order to change the state, a particular Vue component must commit mutations (e.g. this.$store.commit('increment', 3)), and then, those mutations change the state (score becomes 3). After that, the getters are automatically updated thanks to Vue's reactive system, and they render the updates in the component's view (with this.$store.getters.score). 

Mutations cannot execute asynchronous code, because this would make it impossible to record and track the changes in debug tools like Vue DevTools. To use asynchronous logic, you need to put it in actions. In this case, a component will first dispatch actions (this.$store.dispatch('incrementScore', 3000)) where the asynchronous code is executed, and then those actions will commit mutations, which will mutate the state. 

Create a Vuex Store Skeleton

Now that we've explored how Vuex works, let's create the skeleton for our Vuex store. Put the following code above the ChildB component registration:

To provide global access to the Vuex store from every component, we need to add the store property in the Vue instance:

Now, we can access the store from every component with the this.$store variable.

So far, if you open the project with CodePen in the browser, you should see the following result.

App Skeleton

State Properties

The state object contains all of the shared data in your application. Of course, if needed, each component can have its own private state too.

Imagine that you want to build a game application, and you need a variable to store the game's score. So you put it in the state object:

Now, you can access the state's score directly. Let's go back to the components and reuse the data from the store. In order to be able to reuse reactive data from the store's state, you should use computed properties. So let's create a score() computed property in the parent component:

In the parent component's template, put the {{ score }} expression:

And now, do the same for the two child components.

Vuex is so smart that it will do all the work for us to reactively update the score property whenever the state changes. Try to change the score's value and see how the result updates in all three components.

Creating Getters

It is, of course, good that you can reuse the this.$store.state keyword inside the components, as you saw above. But imagine the following scenarios:

  1. In a large-scale application, where multiple components access the state of the store by using this.$store.state.score, you decide to change the name of score. This means that you have to change the name of the variable inside each and every component that uses it! 
  2. You want to use a computed value of the state. For example, let's say you want to give players a bonus of 10 points when the score reaches 100 points. So, when the score hits 100 points, 10 points bonus are added. This means each component has to contain a function that reuses the score and increments it by 10. You will have repeated code in each component, which is not good at all!

Fortunately, Vuex offers a working solution to handle such situations. Imagine the centralized getter that accesses the store's state and provides a getter function to each of the state's items. If needed, this getter can apply some computation to the state's item. And if you need to change the names of some of the state's properties, you only change them in one place, in this getter. 

Let's create a score() getter:

A getter receives the state as its first argument, and then uses it to access the state's properties.

Note: Getters also receive getters as the second argument. You can use it to access the other getters in the store.

In all components, modify the score() computed property to use the score() getter instead of the state's score directly.

Now, if you decide to change the score to result, you need to update it only in one place: in the score() getter. Try it out in this CodePen!

Creating Mutations

Mutations are the only permissible way to change the state. Triggering changes simply means committing mutations in component methods.

A mutation is pretty much an event handler function that is defined by name. Mutation handler functions receive a state as a first argument. You can pass an additional second argument too, which is called the payload for the mutation. 

Let's create an increment() mutation:

Mutations cannot be called directly! To perform a mutation, you should call the commit() method with the name of the corresponding mutation and possible additional parameters. It might be just one, like the step in our case, or there might be multiple ones wrapped in an object.

Let's use the increment() mutation in the two child components by creating a method named changeScore():

We are committing a mutation instead of changing this.$store.state.score directly, because we want to explicitly track the change made by the mutation. This way, we make our application logic more transparent, traceable, and easy to reason about. In addition, it makes it possible to implement tools, like Vue DevTools or Vuetron, that can log all mutations, take state snapshots, and perform time-travel debugging.

Now, let's put the changeScore() method into use. In each template of the two child components, create a button and add a click event listener to it:

When you click the button, the state will be incremented by 3, and this change will be reflected in all components. Now we have effectively achieved direct cross-component communication, which is not possible with the Vue.js built-in "props down, events up" mechanism. Check it out in our CodePen example.

Creating Actions

An action is just a function that commits a mutation. It changes the state indirectly, which allows for the execution of asynchronous operations. 

Let's create an incrementScore() action:

Actions get the context as the first parameter, which contains all methods and properties from the store. Usually, we just extract the parts we need by using ES2015 argument destructuring. The commit method is one we need very often. Actions also get a second payload argument, just like mutations.

In the ChildB component, modify the changeScore() method:

To call an action, we use the dispatch() method with the name of the corresponding action and additional parameters, just as with mutations.

Now, the Change Score button from the ChildA component will increment the score by 3. The identical button from the ChildB component will do the same, but after a delay of 3 seconds. In the first case, we're executing synchronous code and we use a mutation, but in the second case we're executing asynchronous code, and we need to use an action instead. See how it all works in our CodePen example.

Vuex Mapping Helpers

Vuex offers some useful helpers which can streamline the process of creating state, getters, mutations, and actions. Instead of writing those functions manually, we can tell Vuex to create them for us. Let's see how it works.

Instead of writing the score() computed property like this:

We just use the mapState() helper like this:

And the score() property is created automatically for us.

The same is true for the getters, mutations, and actions. 

To create the score() getter, we use the mapGetters() helper:

To create the changeScore() method, we use the mapMutations() helper like this:

When used for mutations and actions with the payload argument, we must pass that argument in the template where we define the event handler:

If we want changeScore() to use an action instead of a mutation, we use mapActions() like this:

Again, we must define the delay in the event handler:

Note: All mapping helpers return an object. So, if we want to use them in combination with other local computed properties or methods, we need to merge them into one object. Fortunately, with the object spread operator (...), we can do it without using any utility. 

In our CodePen, you can see an example of how all mapping helpers are used in practice.

Making the Store More Modular

It seems that the problem with complexity constantly obstructs our way. We solved it before by creating the Vuex store, where we made the state management and component communication easy. In that store, we have everything in one place, easy to manipulate and easy to reason about. 

However, as our application grows, this easy-to-manage store file becomes larger and larger and, as a result, harder to maintain. Again, we need some strategies and techniques for improving the application structure by returning it to its easy-to-maintain form. In this section, we'll explore several techniques which can help us in this undertaking.

Using Vuex Modules

Vuex allows us to split the store object into separate modules. Each module can contain its own state, mutations, actions, getters, and other nested modules. After we've created the necessary modules, we register them in the store.

Let's see it in action:

In the above example, we created two modules, one for each child component. The modules are just plain objects, which we register as scoreBoard and resultBoard in the modules object inside the store. The code for childA is the same as that in the store from the previous examples. In the code for childB, we add some changes in values and names.

Let's now tweak the ChildB component to reflect the changes in the resultBoard module. 

In the ChildA component, the only thing we need to modify is the changeScore() method:

As you can see, splitting the store into modules makes it much more lightweight and maintainable, while still keeping its great functionality. Check out the updated CodePen to see it in action.

Namespaced Modules

If you want or need to use one and the same name for a particular property or method in your modules, then you should consider namespacing them. Otherwise you may observe some strange side effects, such as executing all the actions with the same names, or getting the wrong state's values. 

To namespace a Vuex module, you just set the namespaced property to true.

In the above example, we made the property and method names the same for the two modules. And now we can use a property or method prefixed with the name of the module. For example, if we want to use the score() getter from the resultBoard module, we type it like this: resultBoard/score. If we want the score() getter from the scoreBoard module, then we type it like this: scoreBoard/score

Let's now modify our components to reflect the changes we made. 

As you can see in our CodePen example, we can now use the method or property we want and get the result we expect.

Splitting the Vuex Store Into Separate Files

In the previous section, we improved the application structure to some extent by separating the store into modules. We made the store cleaner and more organized, but still all of the store code and its modules lie in the same big file. 

So the next logical step is to split the Vuex store into separate files. The idea is to have an individual file for the store itself and one for each of its objects, including the modules. This means having separate files for the state, getters, mutations, actions, and for each individual module (store.jsstate.js, getters.js, etc.) You can see an example of this structure at the end of the next section.

Using Vue Single File Components

We've made the Vuex store as modular as we can. The next thing we can do is to apply the same strategy to the Vue.js components too. We can put each component in a single, self-contained file with a .vue extension. To learn how this works, you can visit the Vue Single File Components documentation page

So, in our case, we'll have three files: Parent.vueChildA.vue, and ChildB.vue

Finally, if we combine all three techniques, we'll end up with the following or similar structure:

In our tutorial GitHub repo, you can see the completed project with the above structure.

Recap

Let's recap some main points you need to remember about Vuex:

Vuex is a state management library that helps us to build complex, large-scale applications. It uses a global, centralized store for all the components in an application. To abstract the state, we use getters. Getters are pretty much like computed properties and are an ideal solution when we need to filter or calculate something on runtime.

The Vuex store is reactive, and components cannot directly mutate the store's state. The only way to mutate the state is by committing mutations, which are synchronous transactions. Each mutation should perform only one action, must be as simple as possible, and is only responsible for updating a piece of the state.

Asynchronous logic should be encapsulated in actions. Each action can commit one or more mutations, and one mutation can be committed by more than one action. Actions can be complex, but they never change the state directly.

Finally, modularity is the key to maintainability. To deal with complexity and make our code modular, we use the "divide and conquer" principle and the code splitting technique.

Conclusion

That's it! You already know the main concepts behind Vuex, and you are ready to start applying them in practice.  

For the sake of brevity and simplicity, I intentionally omitted some details and features of Vuex, so you'll need to read the full Vuex documentation to learn everything about Vuex and its feature set.

Advertisement
Advertisement
Looking for something to help kick start your next project?
Envato Market has a range of items for sale to help get you started.