You really don’t need Vuex
It’s overkill, and much of what goes on can be replaced with observable objects.
#Vue — 20 December 2020
I’ve been working on a new writing/publishing platform to replace Canvas on my site (I’ll explain why in another post), and decided early on that I don’t need Vuex for frontend state-management. For the most part, it’s just overkill – or at least it feels like overkill for such a simple task.
All we want to do is manage state in a central place, and we really don’t need an opinionated package to get that right.
Vue 2.6 introduced observable()
, which is perfect for our needs. By using it, we can manage our state however we want. We can stick to the flux pattern’s spec, or we can roll our own. But the outcome is the same: keep state centralised.
What’s observable
?
For those who are unaware, Vue gave us the ability to create our own observable objects in v2.6. Using the observable
method on the Vue
prototype, we can wrap an object up into a piece of observable state. This gives us all the bells and whistles of reactivity, which we can use outside of the component scope.
This means we can do it outside of Vuex too.
Yes, you read that correctly. And this is why you don’t need Vuex.
Exhibit A
// src/state/counter.js import Vue from 'vue' const state = Vue.observable({ counter: 0})
Easy right?
Yes, I know Vue 3.0 is around the corner (I’d like to think it is, anyway), and there are new helpers for this. But this post is not for Vue 3.0. In fact, this post is not about advertising observable
, but rather how we can leverage the concept to create our own state-stores and use them as we see fit.
Exhibit B
Let’s make this look like Vuex (kinda):
// src/state/counter.js import Vue from 'vue' const state = Vue.observable({ counter: 0}) export const getters = { counter: () => state.counter} export const mutations = { incrementCounter: () => state.counter++, decrementCounter: () => state.counter--,}
You’re already thinking, “wait, but what about the map*
helpers Vuex comes with?“ Well, you don’t need them. We’re getting there…
You’ll also note that state
is not exported. This is intentional – we don’t want to manipulate state from outside the store. Vuex takes this a step further by wrapping this all up in an object and providing state to each mutation when it is invoked via commit
. This is part of where the overkill comes in, for me anyway. If I have a reactive object that I only want to change from a within particular file, then I simply won’t export it. Yes, this means that you need to split your stores out into files – but there’s no harm in that. In fact, it’s a good design pattern. Separation of concerns, remember?
So, we only export what we want, and we have direct access to state within our file. Great. We also have getters
to get access to our state
. In Vuex, I’d argue that we can mapState
and access it directly, but this approach is obviously quite different, and so we need to export the pieces of our state that we need via methods that have access to it.
How do we use this? (Exhibit C)
Simple. Just import what you need directly into your components.
<!-- src/components/Counter.vue --><template> <div> <p>{{ counter }}</p> <button @click="increment">Increment</button> <button @click="decrement">Decrement</button> </div></template> <script>import { getters, mutations } from '@/state/counter' export default { computed: { counter: getters.counter // or: ...getters }, methods: { increment: mutations.incrementCounter, decrement: mutations.decrementCounter, // or: ...mutations } }</script>
Awesome, so it looks like we have accomplished our primary goal. We have state, it’s central, and it’s accessible.
“But, what about actions?”, you ask… It’s really as straight forward as you might think. They’re treated the same way as mutations, with the primary (and only) difference being that they’re conceptually different. What do I mean by that? Actions and mutations can both manipulate state using this pattern, so you could technically merge them together and just call them actions. But, we normally treat the two concepts differently – as independent ideas. Mutations mutate state, and actions do whatever it is you need them to do – and those actions might result in mutating state. In this case, mutations are still a good idea, because we define a single approach to mutating state. If you need to change the way in which state is mutated, simply change the mutation.
With that said, let’s expand on this and add actions. For the purposes of this simple example, we’re going to do a server-call that updates a counter and then mutates state on success.
// src/state/counter.js export const actions = { async incrementCounter() { await post('/counter/increment') mutations.incrementCounter() }), async decrementCounter() { await post('/counter/decrement') mutations.decrementCounter() }),}
Sidebar: I generally use Promises in my actions – it just provides a nice, consistent wrapper should I need to rely on asynchronicity. Also, post
is a pseudo-method to demonstrate a server call – you might use the Fetch API, or you might use Axios.
Then it’s a simple case of swapping out the mutations with the actions in our component, and using them accordingly:
<!-- src/components/Counter.vue --> <template> <div> <p>{{ counter }}</p> <button :disabled="working" @click="increment">Increment</button> <button :disabled="working" @click="decrement">Decrement</button> </div></template> <script>import { getters, actions } from '@/state/counter' export default { data: () => ({ working: false, }), computed: { counter: getters.counter }, methods: { increment() { this.working = true actions.incrementCounter() .catch(() => alert('Whoops')) .finally(() => this.working = false) }, decrement() { this.working = true actions.decrementCounter() .catch(() => alert('Whoops')) .finally(() => this.working = false) } }, }</script>
Instead of dispatching an action, we simply invoke it, let it do its thing, and run any side-effects that might be applicable, such as updating a working
-state or showing an error somewhere.
Helpers, because we all like helpers…
Okay, so that’s all well and good. But, I mentioned before that we might want to using something similar to the map*
helpers that Vuex provides. Why would we want to do this? For one thing, it reduces repetition. If you have a boat-load of actions and/or mutations, you have a few options.
The first is to simply bind everything to your component. Remember, when we import things in JavaScript, we’re importing them by reference, and not by value. So throwing an ...actions
into your component’s methods
isn’t the worst thing in terms of performance, and it will work just fine. But, and there’s always a but, this removes visibility. In order to find out what actions are available, we have to rely on autocomplete or by going and actually looking at the file in question.
The second option is to declare each action at the cost of verbosity. Each declaration is still passed by reference, so there’s no real cost there. However, verbosity killed the cat, apparently.
So what can we do about this? The first thing that came to my mind was a utility that behaves like the map*
helpers provided by Vuex, but doesn’t rely on yet another utility-import in every single component that wants the functionality behind it. The answer was to wrap getters
, mutations
and actions
each in a new instance of a class that provides a helper method or two to import what we want using an array or object syntax.
I called this class StateCollection
, and it looks like this:
// src/foundation/state-collection.js import assign from 'lodash/assign'import pick from 'lodash/pick'import transform from 'lodash/transform'import get from 'lodash/get' export class StateCollection { constructor(items) { assign(this, items) } use() { return pick(this, ...arguments) } useAs(aliases) { if (!aliases instanceof Object) { throw 'usingAs.aliases must be an Object.' } return transform(aliases, (accumulator, using, key) => { accumulator[key] = get(this.use(using), using) }, {}) } }
This class provides two helper methods: use
and useAs
. The first is akin to using map*
with an array, and the second is akin to using it with an object, which you might want to do for several reasons, such as preventing method naming conflicts. These two methods rely on lodash to get things done both quickly, and with added benefits, such as using dot-notation to fetch something nested deeply in the collection. Whilst you might not always find much use for that, lodash.get
inherently provides that cherry-on-top.
In order to take advantage of these helpers, we need to wrap each of our state-object exports into a new StateCollection
.
// src/state/counter.js import { StateCollection } from '@/foundation/state-collection' const state = { /** counter state */} export const getters = new StateCollection({ /** counter getters */}) export const mutations = new StateCollection({ /** counter mutations */}) export const actions = new StateCollection({ /** counter actions */})
That might look silly, but it’s really not that bad, especially when it’s populated. You could always create classes (Getters
, Mutations
, Actions
) that extend StateCollection
, if you wanted it to read better. I do, and I have, but you can see what it looks like when I publish my writing platform.
Now, in our components, we can utilise these helper methods:
<!-- src/components/Counter.vue --> <script>// imports … export default { computed: { ...getters.use([ 'counter' ]) // or … ...getters.useAs({ theCounter: 'counter' }) }, methods: { ...mutations.use([ 'increment', 'decrement', ]) // or … ...mutations.useAs({ incrementCounter: 'increment', decrementCounter: 'decrement', }) } }</script>
There’s only one downside to this, and that is intellisense has no idea what’s going on – but, then again, it also had no clue when you were using map*
helpers. And so, our code is easy on the eye, and easier to understand. The verbosity is gone, and the cat purred.
At this point, you might have noticed that there’s something missing. When we use map*
helpers, we normally reference a namespaced Vuex-module. But, we’re not using Vuex, and there’s no magical namespacing going on here. Hell, we’re not even registering modules of any kind anywhere. So what do we do when a single component needs access to multiple stores?
This, too, is simple. ES6 gifted us with static imports and exports. It also gifted us with the magical as
keyword, which I’ve made reference to before. As such, we can simply alias our imports, and carry on as if nothing ever happened:
import { getters as counterGetters, mutations as counterMutations} from '@/state/counter' import { getters as postGetters, mutations as postMutations} from '@/state/posts'
To me, this makes much more sense than namespacing modules, or even using createNamespacedHelpers
, which I believe to also be overkill. Relying on the language, in this instance, is a far more elegant and straight forward solution.
In conclusion…
I’m not out to hate on Vuex – it really is a great tool, and like most tools I’ve worked with, it’s served me well. It’s served many Vue developers well. But, sometimes you have to ask yourself whether or not there’s a simpler approach to a particular problem.
That question, “do I need to reach for a package to get this done?“ comes to mind. More and more, these days, I find myself preventing that reach from happening. If it can be done with what’s available, then do it that way. And while you’re at it, make it easy to use, and easy to understand. Write once, use everywhere.
Also, and I re-iterate, this post is not meant for nor targetted at the next major release of Vue. Whilst it would very likely work if you use the Composition API (this is optional – Vue 3 will still support the Options API we all know so well), you might find there’s an even better approach. I, for one, have not explored that yet.
Update, 12 May 2020: VueSchool has a neat little article on exploring state management using the Composition API – very much worth the read!