During the recent Vue.js Amsterdam conference, Evan You gave a talk that mentioned the possible roadmap for Vuex:
At the 25-minute mark, we see, “Simplify concepts by merging mutations & actions.” So now is a good time to discuss what actions and mutations are really for and what this change could mean.
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. Before the API call starts, a loading flag is set; then, when the call returns (asynchronously using a promise), it will commit the response data and then commit stopLoading, which most likely unsets the loading flag.
A design choice worth noting: the code above uses two mutations where one could suffice. The startLoading/stopLoading mutations could be replaced by a single mutation (setLoading) with a boolean payload, then stopLoading could 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 a mutation, set, 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.
Atomic and transactional means traceable
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 chronology of mutations applied to the single global state.
Let’s take the above loadBooks example. Late on a Friday evening, a developer, 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');
});
},
},
A few quick-fire developer tests, and Alex is happy it works and deploys to staging. The next day, a bug report comes in 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:
Alex uses time-travel debugger to cycle through the past mutations and return to the state that causes the visual glitch.
Alex realizes that the simple boolean loading flag isn’t going to work for multiple asynchronous requests; the history clearly shows that the two actions had 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.
Another aspect of mutations that contributes to their transactional nature is that they are intended to be pure functions. More than a few devs at some point have asked…
Why can’t mutations have access to getters?
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 shown also, just 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
Alex now has 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.
Not a serious anti-pattern, but 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 name, the setName example from the top of this article will be how a lot of store code looks, and devs will be frustrated.
Battling boilerplate
Back to the setName example, 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. 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. How could it be reduced? Well, one very neat solution is the one 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 style mutations directly from the state, which certainly removes boilerplate, but also removes any value the mutation layer might have.
Benefits of 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:
- Actions can be scoped to a module, both when dispatching them and also in the context they have available
- Actions can be intercepted via subscribeAction store API
- 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?
Well, as mentioned in the talk, these can be used right now for actions. Here is what the loadBooks example looks like with async/await:
actions: {
async loadBooks({ commit }) {
commit('startLoading');
const response = await get('/api/books');
commit('setBooks', response.data.books);
commit('stopLoading');
},
},
But this isn’t functionally equivalent — there is a subtle difference. This 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. This means the promise returned by the action is waiting on the inner promise to finish. This is hinted at in the talk regarding the detection of the start and end of an action.
The non-async/await version of the action, which doesn’t return the inner promise, gives no way for the calling code to detect its end. The inner promise is still working away asynchronously when the action has already returned with nothing.
Mutation granularity
If most (not all) mutations are one-liner functions, then maybe the atomic, transactional mutation can simply be a single mutating statement (e.g., assignment). So 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.
Hinted at in the video was that the devtools view would include actions, something not done currently. What if the above mutations could be shown in chronological sequence (and traversable for time-travel debugging), but grouped under the action that triggered them?
Tying mutations to actions
Here is what our new 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;
},
}
So assuming that, under the hood, mutating the value of state.loading will create some log entry in the devtools, how do we ensure it is associated with the action?
Some 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, 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:
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 v3 will use the Proxy class for more complete 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 when considering 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 to hand.
Here is an example that emulates the Vuex store. It involves Alex’s loadBooks and lookAuthor actions, in this case written as mutactions.
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.
Sure, a beautiful chart visualization is missing here, but it would be possible. 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 (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 to be 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, which, by default, 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
Mutactions offer simplicity, yet traceability; less boilerplate, yet flexibility and composition. They could be added to Vuex while still maintaining backwards compatibility, for incremental adoption.
Plug: LogRocket, a DVR for web apps
LogRocket is a frontend logging tool that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.
In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page apps.
The post Vuex showdown: Mutations vs. actions appeared first on LogRocket Blog.
Top comments (0)