Happy Little Monoliths is available for pre-order.

With Vue 3 around the corner, it can seem frivolous to spend time writing a new state management library for Vue 2. Vuex gets the job done well and provides a good foundation for code organization.

I couldn't help but notice however that Vue.observable() became available in Vue 2.6+, and that it can provide much simpler shared reactive state. Instead of relying on manually defined mutations to perform state updates, an object proxied via Vue.observable() will react to property assignments and Array changes just like you'd expect from data() in a Vue component, but we can export it and use it throughout nested components without any issues.

After seeing Filip Rakowski's awesome coverage of Vue 3's new features I'm inclined to just abandon Vue 2 and migrate to Vue 3 as soon as possible. But in reality, most of us will still be maintaining Vue 2 apps for a long time, and I see Vue.observable() as a way of reducing the pain and excessive cruft of some large apps.

Typical Vuex Nightmare

import { SET_A, SET_B } from '~/constants'

export const mutations = {
  [SET_A] (state, a) {
    state.a = a
  },
  [SET_B] (state, b) {
    state.b = b
  }
}

export const actions = {
  doSomethingWithA ({ commit }) {
    // ... do something ...
    commit('SET_A', 'something')
  },
  doSomethingWithB ({ commit }) {
    // ... do something ...
    commit('SET_B', 'something')
  },
}

Most of the Vue apps I encountered to date use some variation of the pattern demonstrated above. In a small, isolated example like this it may not seem like much, but it's very easy to go from that to this:

import {
  SET_A,
  SET_B,
  SET_C,
  SET_D,
  SET_E,
  SET_F,
  SET_G,
} from '~/constants'

And that's just the mutation constant imports.

When facing performance issues in an app that relied heavily on Vuex to do a lot of cross-component updates, I decided to drop a few dozen mutations and give Vue.observable() a try.

To my surprise I found out that it had much better performance. I guess it was a combination of the Vuex bundle being removed and no longer initialized, and the removal of hundreds of LOC creating mutation functions that were refactored into simple assignments.

Introducing VueStator

npm install vue-stator

I still needed a way to organize my code around actions. I never liked deeply nested Vuex modules so I started VueStator with the goal of offering Vuex-like code organization but also with the contraint of allowing top-level modules only. The basic store registration function will make $state, $actions and $getters available globally.

A simple example from the README:

Vue.use(VueStator, {
  state: () => ({
    auth: {
      user: null,
      loggedIn: false
    }
  }),
  actions: {
    auth: {
      login (ctx, state, user) {
        state.user = user
        state.loggedIn = false
      }
    }
  }
})

Unified global state and virtual modules

Since I don't need mutations, that is, all my mutations are automatically registered in the form of Object.defineProperty() via Vue.observable(), all my code is now organized as actions. Still there are times an action will perform an asynchronous request and there are times an action will simply update a bunch of state properties.

Notice how in the example above, login() takes three arguments? That's VueStator signature for actions. The first argument is the global context, where you have access to all global injections ($state, $actions and $getters). The second argument is a convenience reference to the state key that corresponds to the namespace in context. Revisiting the previous snippet:

actions: {
  auth: {
    login (ctx, state, user) {
      state.user = user
      state.loggedIn = false
    }
  }
}

The second parameter, state, is a direct reference to $state.auth, while the global state remains accessible via ctx.$state.

Every top-level object in $state is considered to be a virtual module, meaning you can group actions and getters under a matching key and a reference to it will be automatically passed as second parameter to every action function.

This way, when I have actions that are simply doing a bunch of context state updates, I can have a function defined as follows:

namespace: {
  myAction (_, state, data) {
    // state = $state.namespace
    state.propA = data.propA
    state.propB = data.propB
  }
}

But if I'm doing an axios request, and am also updating other state properties, I could write a function like this:

namespace: {
  async myAsyncAction ({ $axios, $state }, _, data) {
    // $state = global state
    // state = $state.namespace
    const response = await $axios.post(..., data)
    $state.someOtherNamespace.prop = response.data.prop
  }
}

Note how in both examples, I use _ to indicate the parameter is not used. So using this pattern, it's easy to recognize when I have mutation-like actions where nothing other than the contextual state key is accessed, and actions that actually do more than one thing.

Refactoring nuxt/hackernews

To demonstrate how VueStator helps simplifying a Nuxt.js codebase, I've refactored the nuxt/hackernews sample app to use it.

In this commit, you can see I upgrade it to Nuxt 2.10, so that I can very easily disable the default Vuex store and use the store directory safely, I add vue-stator/nuxt to buildModules and finally, move state out of store/index.js and into store/state.js.

vue-stator/nuxt will automatically load state.js, actions.js and getters.js files from the store dir (very much like Nuxt's automated Vuex store). It will also load actions.js and getters.js under store/<module>, where <module> is a key matching a top-level object in state.js.

Alternatively, it will consider store/<module>.js to be the same as store/<module>/actions.js if only the file and not the directory is defined.

Then I proceed to moving actions onto store/actions.js following the updated signature I described earlier, replace all occurrences of $store.xyz with simply $xyz ($state, $actions and so on).

For convenience, VueStator includes mapState(), mapGetters() and mapActions(). But these are largely unnecessary if you adhere to accessing $state, $actions and $getters directly.

And finally all $store.dispatch() calls with direct calls via $actions:

- this.$store
-   .dispatch('FETCH_FEED', {
-     feed: this.feed,
-     page: this.page + 1,
-     prefetch: true
-   })
-   .catch(() => {})
+ this.$actions.fetchFeed({
+   feed: this.feed,
+   page: this.page + 1,
+   prefetch: true
+ })

To summarize:

  • Pros: no more mutations, a clean idiom for calling actions and more convenience injections that can be used in templates. Somewhat improved speed (depending on store size and complexity).

  • Cons: Vue.js devtools becomes useless for debugging your store, so you're left with manually inspecting it on the console. VueStator's mapState() might not update reliably for deeply nested components, but referencing $state directly does.

Huge thanks to Pim for helping maintain this package.

Follow me on X and GitHub.

Go to the index page.