An alternative approach to structuring a vuex store

When using vuex to manage the state of a large-enough Vue project, it can sometimes be difficult to manage, even more so when using modules. Actions being dispatched are namespaced strings. Accessing the state in the store can sometimes be messy (getters are sometimes frown upon). Also, should the business logic be in an "action" or a "mutation" (or, even, in a "getter")?

To try to add a sensible approach to managing vuex stores, here is a proposal:


Modules, no name spaces

First, let's quickly look at the folder structure. The /store will be composed of a /modules folder, which will then host different subsets of state.

Each module will then have its folder (for example, store/modules/user), inside which there will be different files: actions.js, getters.js, mutations.js, state.js, types.js (more on that later), and finally index.js to wrap everything together.

The main difference with a more common setup is that we won't be using name spaces, as this would break the focal point of this approach: types.


Only getters, single mutation

Before looking into types though, another convention of this approach is to only use getters to access the state of the store. This might sound overkill if all the getters do is return a field of the state, but this approach brings consistency in accessing the store and will really shine with, you've guessed it, types!

For the sake of simplicity as well, we will only define a single mutation for each module as follow:

mutations.js

const mutations = {
  update(state, { key, value }) {
    state[key] = value;
  },
};

export default mutations;

All types

It is probably personal preference, but I particularly dislike having hand-written strings all over the code. For one, typos are very easily made, and static analysis tools (such as ESLint) can't really help you. You also need to remember how a particular action, or getter, is named, and that can become difficult to track when working on a large codebase and being part of a team.

For that reason, this whole approach is based on using constant variables instead of strings. Similarly to what I have seen in the redux world, we will be defining types for actions, getters, and keys (more on mutations later).

In practice, that means defining types like follows:

types.js

export const USER_GETTER_CURRENT = "g/user/current";
export const USER_GETTER_FEED = "g/user/feed";
export const USER_GETTER_OVERVIEW = "g/user/overview";

export const USER_ACTION_GET_CURRENT = "a/user/getCurrent";
export const USER_ACTION_GET_FEED = "a/user/getFeed";
export const USER_ACTION_GET_OVERVIEW = "a/user/getOverview";

export const USER_KEY_CURRENT = "k/user/current";
export const USER_KEY_FEED = "k/user/feed";
export const USER_KEY_OVERVIEW = "k/user/overview";
export const USER_KEY_DETAILS = "k/user/details";

Which will then be used in the other files of the module as such:
actions.js

import api from "@/api";

import {
  USER_ACTION_GET_CURRENT,
  USER_ACTION_GET_FEED,
  USER_ACTION_GET_OVERVIEW,
  USER_KEY_CURRENT,
  USER_KEY_FEED,
  USER_KEY_OVERVIEW,
} from "@/store/types";

const actions = {
  [USER_ACTION_GET_CURRENT]({ commit }) {
    return api.get(`/user`).then((res) => {
      commit("update", { key: USER_KEY_CURRENT, value: res.data });
    });
  },
  [USER_ACTION_GET_FEED]({ commit }) {
    return api.get(`/feed`).then((res) => {
      commit("update", { key: USER_KEY_FEED, value: res.data });
    });
  },
  [USER_ACTION_GET_OVERVIEW]({ commit }) {
    return api.get(`/overview`).then((res) => {
      commit("update", { key: USER_KEY_OVERVIEW, value: res.data });
    });
  },
};

export default actions;

getters.js

import {
  USER_GETTER_CURRENT,
  USER_GETTER_FEED,
  USER_GETTER_OVERVIEW,
  USER_KEY_CURRENT,
  USER_KEY_FEED,
  USER_KEY_OVERVIEW,
} from "@/store/types";

const getters = {
  [USER_GETTER_CURRENT](state) {
    return state[USER_KEY_CURRENT];
  },
  [USER_GETTER_FEED](state) {
    return state[USER_KEY_FEED];
  },
  [USER_GETTER_OVERVIEW](state) {
    return state[USER_KEY_OVERVIEW];
  },
};

export default getters;

state.js

import {
  USER_KEY_CURRENT,
  USER_KEY_FEED,
  USER_KEY_OVERVIEW,
  USER_KEY_DETAILS,
} from "@/store/types";

const state = () => ({
  [USER_KEY_CURRENT]: {},
  [USER_KEY_FEED]: [],
  [USER_KEY_OVERVIEW]: [],
  [USER_KEY_DETAILS]: {},
});

export default state;

This might seem like a lot of extra verbosity for an arguably minor problem, but stick with me, as this approach really shines when interacting with the store from components!


Blissful components

Finally, all this hard work leads us to the pay off!

To summarize, we have built our vuex store with the following guidelines:

  • modules, no name spaces
  • only getters, single mutation
  • all types

Now let's see how we might use this in components, and the main benefits of this approach:

App.vue

<template>
  ...
</template>

<script>
import { computed, ref } from "vue";
import { useStore } from "vuex";

import {
  USER_ACTION_GET_CURRENT,
  USER_GETTER_CURRENT,
} from "@/store/types";

...

export default {
  components: {
    ...
  },
  setup() {
    const store = useStore();
    store.dispatch({ type: USER_ACTION_GET_CURRENT });

    ...

    const user = computed(() => store.getters[USER_GETTER_CURRENT]);

    ...

    return {
	  ...
    };
  },
};
</script>

Here we already see all the benefits of this approach:

  • we get strong guarantees that we won't write a type, if using static analysis tools like ESLint (we even get auto-complete in some IDEs).
  • we can see at a glance what actions a component might dispatch, and, because we can only access state through getters, we can also see at a glance which data is being accessed

So, there you have it. There are a bit more blows and whistles to gel all these bits together, so if you're interested in looking at a project that uses that approach, you can check out:

https://github.com/AntonioVdlC/tms