Deep dive into Vue state management

Written by Filip Rakowski

Every Vue application has its state but managing it simply and predictably can be hard. There are many approaches to state management in modern front-end development and it’s easy to get lost in all the available patterns and libraries.

In this post I’ll try to go deep into state management patterns and tools as well as the reasoning behind their existence. This article is very in-depth but I guarantee you that there is at least one thing that you will learn from it.

What is the application state?

We’re talking a lot about “application state” in different contexts but do we know what it is exactly? In simple words, we can say that state of a certain application is a unique set of data that can be used to recreate how it looks like at a given moment.

Data that is displayed on a screen is application state, opened modal window is application state, even an error is application state. We’re holding all this information in our app and this is what makes it look how it looks at any given moment.

Even though every data is just data, its responsibilities can be very different and because of that, we need to manage it differently. We can divide the application state into two types - local and shared. As we will soon discover the first one is very limiting and the second one is very challenging.

Local State

Local state is something that we naturally use in every Vue component and keep in it’s data property. The value of the local state is declared and scoped to a certain component. One of the most common use cases for the local state is storing information about the user interface. Such as if a dropdown is open or closed, or if the form request is currently loading.

This information is only relevant to the actual component, thus we call it a local state.

// Home.vue
<template>
  <div>
    <p>
        Once upone a time... 
        <button @click="showMore = true">Read more</button>
    </p>
    <p v-show="showMore">...in Vueland!</p>
  </div>
</template>

<script>
export default {
  data () {
    return {
      showMore: false
    }
  }
}
</script>

You could store other types of data as part of the local state as well. For example response from the external API.

// Home.vue
<template>
  <div>
    <h1> Hello {{ user.name }}! Here's a story for you!</h1>
    <p>
        Once upone a time... 
        <button @click="showMore true">Read more</button>
    </p>
    <p v-show="showMore">...in {{ user.location }}!</p>
  </div>
</template>

<script>
import { getUser } from './api'

export default {
  data () {
    return {
      user: {}
      showMore: false
    }
  },
  async created () {
    this.user = await getUser()
  }
}
</script>

As your application grows over time you might find keeping your data scoped only to their components problematic. Sometimes it needs to be distributed outside of it. Otherwise, you’ll start encountering problems.

Two main issues can happen while working only with the state that is scoped only to it’s component.

Issues of local state management

The first one is repetition.

Let’s say that you also have a header component where you want to display the same user data as previously shown in Home.vue. Even though you already have this data in your application. you need to call getUser again because you don’t have access to anything that is outside AppHeader.vue - after all, it’s a local state.

By doing this you’re not only repeating your code but also performing unnecessary network call!

// AppHeader.vue
<template>
  <header>
    <img src="./logo.svg" />
    <div v-if="user">
      Welcome back, {{ user.name }}! 
      <button @click="logOut">Log Out</button>
    </div>
    <div v-else>
      <LogInForm />
    </div>
  </header>
</template>

<script>
import { getUser, logOut } from './api'

export default {
  data () {
    return {
      user: {}
    }
  },
  async created () {
    this.user = await getUser()
  },
  methods: {
   async logOut () {
     await logOut()
     this.user = null
   }
  }
}
</script>

The problems are not stopping there. If a user decides to log out there is no way of telling other components that it happened! Because of that our app could easily hold different versions of the same information. One component will think that the user is logged out - another that it’s logged in.

So it turns out desynchronization, which means that our application state is out of sync, is the second main problem of using only a local state.

Desynchronization is caused by repetition.

If every component has its version of application state they can easily end up holding different versions of the same data entity. If we manage to remove this repetition and keep only a single source of truth about our application state it will always be the same everywhere! All state management libraries are just about keeping this single source of truth!

But how to do this? Fortunately, there is a very simple solution to this problem that doesn’t require any third-party libraries to work!

If two components in Vue have parent-child relationship we can just pass down some data from parent to its children. The data passed via props is reactive and read-only which means we can modify it only in a parent component. This is the desired behavior because the parent component can then serve as a single source of truth for this data!

So it turns out that we can keep our state synchronized by lifting it to the component that is highest in the hierarchy. By doing this we’re making the state accessible to the whole application

Using props is a perfect way of ensuring that our state is synchronized across the whole application. Vue itself is giving us a very powerful tool that can help us manage the application state without any third party libraries!

In the case of the above application App.vue which is its root component will serve as its single source of truth.

// App.vue
<template>
  <div>
    <AppHeader :user="user" @logout="logOut" @login="logIn" />
    <HomePage :user="user" />
  </div>
</template>

<script>
import { getUser } from './api'

export default {
  data () {
    return {
      user: {}
    }
  },
  methods: {
   logOut () {
     this.user = null
   },
   logIn () {
     this.user = await getUser()
   }
  } 
}
</script>

Now you’re probably thinking “Okay but this is not how real applications look like. Real apps almost always have routing” and you’re absolutely right! Thankfully this approach will work also if you’re using vue-router!

Under the hood <router-view> component is just a dynamic Vue component which means that it’s a placeholder for another component. In that case for a route component. Every prop that we will pass into <router-view> will be passed to a route component of current page so if you’re using vue-router your App.vue template could look more or less like this:

<template>
  <div>
    <AppHeader :user="user" @logout="logOut" @login="logIn" />
    <router-view :user="user" />
  </div>
</template>

So it turns out we can centralize our apps state without using any third-party libraries like Vuex! This is great because every new library will always complicate our technology stack and decrease performance.

What could go wrong with passing props down?

Common good practice about SPA architecture is stating that we should divide our components into two groups - smart ones and dumb ones (you probably also heard about presentational/container components - it’s the same thing but different naming). Smart components are the ones that retrieve, keep and modify the data while the dumb ones only accept it from props and send events to their parents but rarely have their own state. Dumb components can work in isolation and have no knowledge about their outside environment. There is a great post by Dan Abramov that goes deep into this topic and justifies why this division correlates to better maintainability of Single Page Apps so I will leave you only with this brief explanation.

If your app is not very complex smart components are usually page components and root component while dumb components are the ones inside them.

In a perfect world, every application would be so simple and well-managed that this pattern would be enough to manage its state. State management in this form is predictable, simple, performance-friendly and what is most important - reliable.

In a perfect world, there also wouldn’t be things like poverty, depression and instagram influencers but we’re not living in a perfect world. Projects are developed by big teams for many years where developers need to catch up with deadlines and constantly changing business goals so we rarely see this “clean” architecture from school books in a real-world applications. What we usually work with is a five-headed monster that spits acid and eats children.

Our application grows over time and gets more complicated. Dumb components are becoming smart, smart components are becoming dumb, we’re getting mad and component hierarchy is as deep as Grand Canyon.

Passing down a single value through 20 components just to fulfill the architectural requirements is not a clean code anymore. Every good practice is good until it isn’t and we should constantly challenge them to make sure that they are still making our lives easier. If following some good practice becomes more and more complicated over time and there is a better solution that is not following our architectural assumptions, it’s a good sign that we should reevaluate them.

Centralized Store

Knowing this disastrous nature of web applications Facebook came out with a solution - Flux architecture. The main assumption of Flux architecture states that application state is centralized in a store and the store can’t be directly mutated (changed). If we want to update our state we can do this only in a certain, previously defined way through a dispatcher. Vuex is a state management library for Vue.js, that is based on the Flux pattern.

Learn Vue.js With Vue School

We have a free Vuex for Everyone course here on Vue School

// direct mutation
const store = { count: 0 }
store.count++

// controlled mutation
const store = { count: 0 }
function increment () { store.count++ }
increment()

In the above code, the increment function is a dispatcher that updates the value of the count property. When we have full control over every state change, we can easily track all of them and make our app much easier to debug. With tools like Vue Devtools we can not only see the state changes over time but even come back to previous ones (time travel).

Znalezione obrazy dla zapytania vue devtools vuex

A Vuex store is not tied to any component and because of that, we can interact with it directly in each one of them instead of passing down values from the root component.

With a Vuex store like this:

const store = {
  state: { count: 0 },
  mutations: {
   increment: function (state) {
     state.count++
   }
 }
}

We can update our state from every component in our app just by calling:

this.$store.commit('increment')

That is a major simplification over passing down props 20 levels down!

Why Vuex might not always be a good choice?

Introduction of Flux architecture was a major step forward in making state management more reliable but it came with a cost - every task became more complex.

Simple tasks required a lot of unnecessary code and understanding how our app works became harder.

Take a look at this innocent counter component that saves counts click to external API:

<template>
  <div>
    <button @click="increment++">You clicked me {{ count }times.</button>
    <button @click="saveCount">Save</button
  </div>
</template>

<script>
export default {
  data () {
    return {
      count: 0
    }
  },
  methods: {
    increment: () { this.count++ )
    getCount () { /* call api, update count*/ },
    saveCount () { /* call api */ }
  },
  async created () {
    this.count = await getCount()
  }
}
</script>

If we would like to centralize the state of this counter in Vuex this is how it could look like

const store = {
  state: { count: 0 },
  mutations: {
   increment: function (state) {
     state.count++
   }
  },
  actions: {
    getCount: function ({ commit }) { /* call api, update stat*/ },
    saveCount: function ({ commit }, payload) { /* call api */ }
  }
}

And our component will look like this:

<template>
  <div>
    <button v-on:click="increment++">You clicked me {{ count }times.</button>
    <button v-on:click="saveCount">Save</button
  </div>
</template>

<script>
export default {
  methods: {
    increment: () { this.$store.commit('increment') )
    saveCount () { this.$store.dispatch('saveCount', count) }
  },
  async created () {
    this.$store.dispatch('getCount')
  },
  computed: {
   count () [
     return this.$store.state.count
   }
  }
}
</script>

Do you see the problem? Even though we moved all the state-related logic into Vuex our component hasn’t got any simpler! It fact it became more complicated because there is more code and to understand what it is doing we need to navigate to another file.

With Vuex it’s also harder to keep all things related to a specific feature in one place. We have modules but we register them in a different place than a component that is using them so if we want to properly benefit from features like tree-shaking and lazy loading we need a little bit of additional stretch which complicates our code even more. Not to mention string-based keys which are extremely prone to typos.

As you can see there are numerous issues with Vuex. Does it mean that it is… useless?

I know many of you that struggled with this tool, would like me to write this, but Vuex is actually pretty awesome!

It’s easy to start bullying a library because of its drawbacks but we’re forgetting that programming is all about tradeoffs. Vuex has its weak points but it’s far from being a bad library. It’s well-integrated with Vue Devtools, offers a lot of useful features like plugins or subscriptions, has well-established best practices and is known by almost every Vue developer which lowers the efforts needed to learn your technology stack by them. Also, if you’re using Server Side Rendering using Vuex is the only simple way of putting your server state into server-side rendered HTML.

Many projects don’t need all the features though. If you need only a small subset of Vuex features it might be not worth using it. Everything comes with a cost - using Vuex can be beneficial but it also complicates your project. Is your state so complex that it needs an additional abstraction layer of it or you just need to organize it better? You need to be aware of the pros and cons of Vuex, as well as project specifications to decide if it’s a good fit or not.

If you’re using Server Side Rendering Vuex is most likely the right choice for you. If you’re not using it you should base your decision mostly on project complexity and complication of the component hierarchy.

We have a free Vuex for Everyone course here on Vue School

Simple alternative to Vuex - Vue observables

So what should you do if you decide that Vuex is too complex for your app? Passing down global props is a very elegant and simple solution but it doesn’t scale well. If your app is meant to grow over time it’s not worth investing it a solution that will need to be removed at some point. It’s much better to find one that can be extended with new functionalities as your business grows. For a long time, there wasn’t an easy and well-adopted way of doing this in the Vue ecosystem which forced many developers to use Vuex even for a simple app.

Before Vue 2.6 we were able to create reactive properties only in Vue components. Because of that we were not able to centralize our app state without dirty hacks like using Vue component as store similarly to what we do to have a global Event Bus. Everything that was outside of our Vue component wasn’t reactive. If they got updated it won’t trigger component re-render.

For example in the below example, after clicking the button that invokes the increment method we will still see 0 on the screen, even though count value has changed. This is because the state is not reactive.

// state.js
export const state = { count: 0 }
<template>
  <div> 
    Count is {{ count }} 
    <button @click="increment">Increment</button>
  <div>
</template>

<script>
import { state } from 'state'

export default {
  computed: {
    count () {
      return state.count
    }
  },
  methods: {
    increment() {
      state.count++
    }
  }
}
<script>

Thankfully Vue 2.6 came out with a revolutionary feature that changed everything - Vue observables! Every variable declared as Vue observable is immediately reactive which means if it changes it will trigger Vue component re-render. Because of that we will always see the newest version of reactive property. The same applies to all computed properties that will be relying on observables - their value will change immediately.

So, all we have to do, to make the above code reactive - is to make the state object into a Vue Observable.

// state.js
import Vue from 'vue'

export const state = Vue.observable({ count: 0 })

And voila! Now when we hit the increment button our component will re-render and we will see 1 on screen, as expected! With Vue observables you can have centralized state management in a single line of code! It’s very simple but it does it’s job and should be sufficient for many applications.

A big advantage of using state object in comparison to Vuex is the fact that instead of using strings that are prone to errors we can benefit from autocompletion, simple switching to state file (with cmd+click) and we’re not polluting the global Vue prototype!

Another benefit of using observables is the fact that we can progressively add new features as our application grows. For example, we can use mutations instead of directly modifying state object and track them in a console.

// state.js
import Vue from 'vue'

export const state = Vue.observable({ count: 0 })

export const mutations = {
  setCount(newVal) {
    console.log('Setting "count": ', newVal)
    state.count = newVal
  }
}
<script>
import { state, mutations } from './state'

export default {
  computed: {
    count () {
      return state.count
    }
  },
  methods: {
    increment() {
      mutations.setCount(state.count+1)
    }
  }
}
<script>

As our state becomes more complex we can add more features like plugins, actions or anything else that will help us manage it easier. The possibilities are endless, and we can make our state management tool perfectly suited for our needs!

The downside of this approach is lack of developer tools integration. It’s straightforward to write your own mutation tracking system but you will still lack the UI. If developer tools integration is something you can’t live without

Next up - state management in Vue 3!

State management is a very complex and interesting topic. The well-managed state can significantly speed up the development and make application easier to understand while wrongly managed can do the exact opposite There is certainly more than one right way of doing this and we should always take into account the flexibility of different methods as well as our project needs and complexity.

In the next article I will take a deep dive into new ways of managing state with Vue 3. I had a chance to play few months with Composition API and I found it favouring a more declarative approach than Vuex. I can’t wait to share my learning with you!

Learn Vue.js With Vue School

Leave a Reply

Your email address will not be published. Required fields are marked *

Up Next:

Optimizing third-party libraries

Optimizing third-party libraries