Michael Gallagher Living in the vibrant city of Buenos Aires, developing project management software with Teamwork. Modern JS is my passion; Vue, my tool of choice.

Vuex showdown: Mutations vs. actions

8 min read 2512

Vue Logo With DNA Strand Mutation

Editor’s note: This post was reviewed and updated in September 2021 to provide updated information and a greater deal of clarity on the use of mutations and actions in Vuex.

Mutations and actions are at the core of Vuex. They not only allow you to change your state, but also serve an organizational and structural role in Vuex as a whole.

The only problem with Vuex mutations and actions is that it can be confusing to determine when and how they should be used. This can lead to unnecessary boilerplate, using anti-patterns, and other undesired consequences.

So, let’s take an in-depth look at both mutations and actions to see when exactly you should use them!

Mutations and actions in Vuex

In Vuex, mutations are the only means through which you can change a store’s state. They’re relatively simple and well-known to all Vuex users.

The confusion starts when you throw actions into the mix. When learning Vuex, it can take a while for the difference between actions and mutations to become clear. Often, devs might end up looking at this code:

mutations: {
  setName(state, name) {
    state.name = name;
  },
},
actions: {
  setName({ commit }, name) {
    commit('setName', name);
  },
},

And think: why all the boilerplate?

The Vuex docs say:

Actions are similar to mutations, the differences being that:

  • Instead of mutating the state, actions commit mutations
  • Actions can contain arbitrary asynchronous operations

So in many examples, we see an API call in an action, which results in a commit of a mutation:

actions: {
  loadBooks({ commit }) {
    commit('startLoading');
    get('/api/books').then((response) => {
      commit('setBooks', response.data.books);
      commit('stopLoading');
    });
  },
},

Without looking at any mutations, it should still be fairly clear what is happening:

We made a custom demo for .
No really. Click here to check it out.

  1. Before the API call starts, a loading flag is set
  2. Then, the call returns asynchronously using a promise
  3. Then, the call will commit the response data, followed by stopLoading, which most likely unsets the loading flag

There is a design choice made in the code above that is worth noting: it uses two mutations where one could suffice. The startLoading/stopLoading mutations could be replaced by a single mutation (setLoading) with a boolean payload, and then stopLoading could instead be commit(‘setLoading’, false).

The above example requires two mutations, which means more code to maintain. This reasoning is the same as the recommendation that CSS classes not be named for the style they apply, but rather the meaning of the style  —  i.e., don’t call it redAndBold, but rather activeMenuItem.

By calling the mutation set<Property>, it means the interface abstracts nothing; any change to the implementation will probably mean changes to the interface. We’ll look at an example shortly where mutation abstraction pays off.

Keeping mutations focused only on particular tasks is a good practice. It becomes a great advantage when debugging your global state in search for bugs thanks to history tracking and Vue DevTools.

Vuex history tracking and time-travel debugging

One of the driving requirements of modern state management tools is traceability. In previous generations of state management, when the system got into an inconsistent state, figuring out how it got that way could be difficult.

Using the Vue DevTools, it is possible to see a clear history of mutations applied to the single global state.

Let’s take the above loadBooks example. Late on a Friday evening, a developer, let’s call them Alex, starts work on functionality to load and display authors alongside books. As a starting point, they copy and paste the existing action with minor changes.

actions: {
  loadBooks({ commit }) {
    commit('startLoading');
    get('/api/books').then((response) => {
      commit('setBooks', response.data.books);
      commit('stopLoading');
    });
  },
  loadAuthors({ commit }) {
    commit('startLoading');
    get('/api/authors').then((response) => {
      commit('setAuthors', response.data.authors);
      commit('stopLoading');
    });
  },
},

After a few quick-fire developer tests, it works and Alex happily deploys to staging. The next day, a bug report comes in stating that, on the page this data is used, a spinner is seen at first, but then it disappears, showing a blank screen that is misaligned. Then, a few seconds later, the content appears and everything is fine.

Alex tries to recreate this issue, which is unfortunately sporadic. After several attempts, the problem is reproduced, and Vue DevTools shows the following:

Vuex tab in Vue DevTools

Alex uses time-travel debugging to cycle through the past mutations and return to the state that causes the visual glitch. They realize that a simple boolean loading flag isn’t going to work for multiple asynchronous requests; the history clearly shows that the two actions have interlaced mutations.

Whether you believe it is an error you would have spotted in the code or not, certainly the time-travel debugging offered by Vuex is an extremely powerful tracing tool. It can provide a meaningful sequence of state modification events, thanks to its concept of mutations.

Why can’t mutations have access to getters?

Another aspect of mutations that contributes to their transactional nature is that they are intended to be pure functions. Mutations are intended to receive input only via their payload and to not produce side effects elsewhere. While actions get a full context to work with, mutations only have the state and the payload.

While debugging in Vue DevTools, the payload for the mutation is also shown in case the list of mutations doesn’t give a clue as to the source of the problem. This is possible because they are pure functions.

An abstracted fix

Let’s get back to Alex’s problem. We have to make some changes to the code to support the multiple concurrent API requests. Here are what the relevant mutations look like now:

state: { loading: false },
mutations: {
  startLoading(state) {
    state.loading = true;
  },
  stopLoading(state) {
    state.loading = false;
  },
},

Here is a solution that doesn’t require any changes to the actions:

state: { loading: 0 },
mutations: {
  startLoading(state) {
    state.loading += 1;
  },
  stopLoading(state) {
    state.loading -= 1;
  },
},

If the interface of this mutation had been setLoading, as mentioned earlier, it would likely have meant the fix would have had to alter the committing code within the actions, or else put up with an interface that obfuscates the underlying functionality.

This may not be a serious anti-pattern, but it’s worth pointing out that if a dev treats mutations as a layer without abstraction, it reduces the responsibility of the layer and is much more likely to represent pure boilerplate rather than anything of value. If each mutation is a single assignment with a set<Property> name, the setName example from the top of this article will be how a lot of store code looks, and devs will be frustrated.

To ensure this doesn’t happen, always remember that actions should also serve a functional role. If what your action does can be done with a simple mutation, keep it like that. Only use actions when your state-changing code:

  • Outgrows a mutation
  • Requires committing a few mutations in certain order
  • Deals with async code

Finding the right amount of mutation abstraction

Consider the setName example from the beginning of this post:

mutations: {
  setName(state, name) {
    state.name = name;
  },
},
actions: {
  setName({ commit }, name) {
    commit('setName', name);
  },
},

One of the questions that comes up when starting with Vuex is, “Should mutations be wrapped in actions?” What’s the benefit? Firstly, the store provides an external commit API, and using it does not negate the benefit mutations have within the DevTools. So why wrap them?

As mentioned, mutations are pure functions and synchronous. Just because the task needed right now can be handled via mutations doesn’t mean next month’s feature won’t need more than a mutation can provide. Wrapping mutations in actions is a practice that allows room for future development without a need to change all the calling code  —  much the same concept as the mutation abstraction in Alex’s fix.

Of course, knowing why it is there doesn’t remove the frustration boilerplate code causes devs.

Reducing the boilerplate

One very neat solution for this boilerplate is what Vuex Pathify offers: it attempts to create a store using the least amount of code possible, a concise API that takes a convention-over-configuration approach many devs swear by. One of the most striking statements in the intro is:

make.mutations(state)

This autogenerates the set<Property> style mutations directly from the state, which certainly removes boilerplate, but also removes any value the mutation layer might have.

Benefits of Vuex actions

Actions are a very open, logical layer; there’s nothing done in actions that couldn’t be done outside the store, simply that actions are centralized in the store.

Some differences between actions and any kind of function you might declare outside of the store:

  1. Actions can be scoped to a module, both when they are dispatched and also in the context they have available
  2. Actions can be intercepted via subscribeAction store API
  3. Actions are promisified by default, in much the same way an async function is

Most of this functionality falls into the area of convenience and convention.

Where does async/await fit in here?

Async/await works perfectly with actions. Here is what the previous loadBooks example would look like, implemented with async/await:

actions: {
  async loadBooks({ commit }) {
    commit('startLoading');
    const response = await get('/api/books');
    commit('setBooks', response.data.books);
    commit('stopLoading');
  },
},

However this wouldn’t be functionally equivalent ; there is a subtle difference. Rather, the above is functionally equivalent to the following:

actions: {
  loadBooks({ commit }) {
    commit('startLoading');
    return get('/api/books').then((response) => {
      commit('setBooks', response.data.books);
      commit('stopLoading');
    });
  },
}

The key thing to notice is the return statement. This means the promise returned by the action is waiting for the inner promise to finish. Down the line, it also detects when the returned promise fulfills, and use it on dispatch call.

store.dispatch('loadBooks').then(() => {
  // ...
})

Mutation granularity

If most (but not all) mutations are one-liner functions, then maybe the atomic, transactional mutation can simply be a single mutating statement (e.g., assignment). The trail of mutations in the DevTools might look like this:

state.loading = true;
state.loading = true;
state.books = […];
state.loading = false;
state.authors = […];
state.loading = false;

However, with a large volume of actions running in parallel, this may be confusing, and without the meaningful names that mutations currently provide, it may be difficult to debug.

The recent Vue DevTools addition for viewing actions in the DevTools timeline, right alongside mutations, would certainly be helpful in this scenario.

Merging these concepts: An experiment

As a thought-provoking experiment, let’s try to merge mutations and actions into one. This can give us a better understanding of why the two concepts should (or maybe should not) be kept separate.

Here is what our new creation, or “mutaction,” might look like:

mutactions: {
  async loadBooks({ state }) {
    state.loading += 1;
    const response = await get('/api/books');
    state.books = response.data.books;
    state.loading -= 1;
  },
}

Without a separate action, a single mutation (a.k.a., “mutaction”) is responsible for managing state and async request. While this might work just fine on the surface, we’ll need some additional tinkering to adapt this concept to work with Vue DevTools.

Leveraging reactivity magic

It’s always nice to leverage reactivity to do something clever — can it be done here? Actions are not normally reactive. In the Vue ecosystem, only the following are reactive functions:

  • Render of a component
  • A watcher
  • A computed property
  • A store getter

They will be “recorded” each time they are run and “played back” if their dependencies fire. Reactivity is like a mousetrap, which is set and springs.

The recording phase of reactivity might be a model for us to follow. But there is a big challenge here that may not be immediately apparent: reactivity recording is synchronous.

What does that mean? Well, here’s a CodePen to put it to the test:

See the Pen
Async Watcher
by Michael Gallagher (@mikeapr4)
on CodePen.

Above are two watchers on some reactive data. Both watchers are the same, except one has an asynchronous getter. As you can observe, this watcher doesn’t fire, while the same synchronous watcher does. Why?

Reactivity currently works based on a global stack of dependent functions. If you are curious, you can look over /observer/dep.js to see it. For this to work, reactivity has to be synchronous.

Some proxy magic?

Vue 3 uses the Proxy class for more transparent reactivity. Does that functionality give us anything we can use to accomplish our asynchronous recording?

Well, firstly, let’s put aside performance concerns for a moment as we consider that a developer will be running DevTools, not a user. An increase in resources and a dip in performance is allowed if there are more debugging options at hand.

Here is an example that emulates the Vuex store. It involves Alex’s loadBooks and lookAuthor actions merged together with mutations into a single “mutaction”.

See the Pen
Mutactions
by Arek Nawo (@areknawo)
on CodePen.


Here in the console logs are the basic beginnings of traceability for low-granularity mutations, which are grouped by the action that calls them. In addition, the start and end of the action are chronologically logged, too.

So what’s going on in the code?

As mentioned, it is not possible for us to globally track an asynchronous stack, and there aren’t many options for accessing the call stack at the moment of mutation — you can either throw and catch an error, or use the deprecated/forbidden arguments.caller.

However, at the time we pass the state object to the action, we know the “mutaction,” and we know all mutations will be via that object. Therefore, we wrap the state (a global single instance) in a special custom Proxy with a reference to the “mutaction.”

The proxy self-propagates if child properties are read, and ultimately will trigger a log for any writes. This sample code is obviously written for simple, happy path functionality, but it proves the concept. There is a memory overhead here, but these custom proxies will live as long as the “mutaction” execution does.

The “mutactions” use async/await and must await all asynchronous functionality, ensuring the returned promise will resolve/reject only when the action has truly finished. There may be one caveat here for Promise.all() rejections, which will not wait for all the underlying promises to finish.

Time travel

The downside of such granular mutations is that if time-travel debugging steps continue for each mutation, the overhead of saving the entire state each time would be pretty extreme.

However, reactivity can provide an example to follow here because, by default, it waits for the nextTick before triggering watchers. If the DevTools did the same before storing a snapshot of state, it means the steps would likely group around today’s concept of mutations.

The display will only re-render once per tick, so providing a lower-granularity time travel step doesn’t make much sense.

Conclusion

Mutations offer simplicity and traceability, less boilerplate, flexibility, and composition. They could be added to Vuex while still maintaining backwards compatibility, for incremental adoption.

Experience your Vue apps exactly how a user does

Debugging Vue.js applications can be difficult, especially when there are dozens, if not hundreds of mutations during a user session. If you’re interested in monitoring and tracking Vue mutations for all of your users in production, try LogRocket. https://logrocket.com/signup/

LogRocket is like a DVR for web apps, recording literally everything that happens in your Vue apps including network requests, JavaScript errors, performance problems, and much more. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred.

The LogRocket Vuex plugin logs Vuex mutations to the LogRocket console, giving you context around what led to an error, and what state the application was in when an issue occurred.

Modernize how you debug your Vue apps - .

Michael Gallagher Living in the vibrant city of Buenos Aires, developing project management software with Teamwork. Modern JS is my passion; Vue, my tool of choice.

2 Replies to “Vuex showdown: Mutations vs. actions”

  1. Hi Michael, thanks for this nice article! Vuex is a great tool but I think we are lacking some guidelines to follow to get it right.

    I often read that mutations should be one-liners , or at least remain really simple… Why? The doc describes a mutation as a transaction, this term makes me think that we could write as much code as needed in a single mutation to enforce state coherency.

    Let’s take a simple example: an image with its width and height stored in the state, and a boolean to keep aspect ratio when changing the width or height. Would you advise to write the keep aspect ratio logic in the ´setWidth’ and ´setHeight’ mutations, or move it into the actions?

    Thanks!

  2. Hey Jonathan,

    Glad you like the article, I’ve tried my best not to be overly opinionated about it. I normally use actions, even if it is just wrapping a mutation. The question for me in your example is whether there are 1 or 2 actions/mutations. You could have the 2 you mention, or maybe `setDimensions` which would set both? The reason I say it is that, to me, the width and height will probably always be set together, considering them as separate transactions for either mutations or actions, doesn’t give any additional benefit to traceability, readability or reusability. And this would cover your aspect ratio point also.

    Cheers
    Mike

Leave a Reply