Thanks for visiting my blog!
I’ve been digging into Vue 3 a lot lately. One topic that a lot of people seem to be discussing whether to use Vuex or not in Vue’s Composition API (that is prominent in Vue 3).
After looking and prototyping some of these options, I wanted to share my opinions. In this post, I’ll review different strategies (including Vuex) and talk about the pros and the cons of each.
Baseline
I started out with a simple Vue app, fresh from the Vue CLI. It’s using Vuex and the Router via Vue 3 (RC 9 at the time of writing of this post). You can find the project on Github if you want to play with it:
The goal here was to create a simple piece of code that could be shared that read data from an URL (using the free RestCountries API) and allow you to delete local copies of the data returned. I’ll implement the functionality in the three ways (as a simple factory, as a shared component, and finally in Vuex and talk about the pros and the cons).
Why not Mixins?
Before we get started, why shouldn’t I just use a Mixin? The biggest reason is that Mixins aren’t supported in Vue 3 (or more appropriately in the Composition API). The reason for this is that it’s not necessary. The main goal of the Composition API is to allow you to ‘compose’ your components. The Mixin was a necessary evil to allow for this type of composition.
Why were mixins necessary? It’s all about the magic this pointer. In the Options API (the default in Vue 2), everything worked because part of the magic of Vue was propogaing the data and other parts of the API onto the magic this pointer. For example:
export default {
data: () => {
return {
moniker: ""
};
},
methods: {
setCampFromMoniker() {...},
onCampChange() {
this.setCampFromMoniker(this.moniker);
}
}
};
The data returned from the data part of the options API is then merged to the this pointer inside the method so it can be accessed. By using mixins, you could create your own extensions to add your data/methods to the this pointer:
export default {
mixins: [myMixIn],
data: () => {
return {
moniker: ""
};
},
methods: {
setCampFromMoniker() {...},
onCampChange() {
this.setCampFromMoniker(this.moniker, this.someDataFromMixin);
}
}
};
In the Composition API, this isn’t necessary as we can compose the component in the setup function. The trick here is you can import anything you want and use it since the this pointer is being replaced with closures, it all works:
export default {
setup() {
// Introduce a local variable
const name = ref("Shawn");
function save () {
// Can use the locally scoped variable as it becomes a closure
alert(`Name: ${name.value}`);
};
return {
name,
save
};
},
};
This works essentially by having the save function share the name scope (e.g. via a closure) and it guarantees that it will live as long as the save function is needed. Because of this we don’t need mixins as we can just introduce our objects (via importing them) into the scope. No magic…just closures. And that brings us back to the original discussion…
Factories
I’ve seen factories as a common pattern for composing your functionality. Here is a simple example:
// Factory Pattern
import axios from "axios";
import { ref } from "vue";
export default function () {
const countries = ref([]);
async function load() {
let result = await axios.get("https://restcountries.eu/rest/v2/all");
countries.value.splice(0, countries.value.length, ...result.data);
}
function removeItem(item) {
let index = countries.value.indexOf(item);
if (index > -1) {
countries.value.splice(index, 1);
}
}
return {
countries,
load,
removeItem
};
}
It is then used like so:
// Inside Component
import dataFactory from "../factories/data";
import { onMounted, computed } from "vue";
export default {
setup() {
let { load, removeItem, countries } = dataFactory();
onMounted(async () => await load());
return {
countries,
removeItem
};
},
};
By calling the dataFactory, we’re generating the elements (see the ‘let’ declaration) and introducing them into our scope. We could have a handful if these factories to compose reusable sections of our logic if necessary.
Factories are great, but they or generating a new instance in every case. This is likely what you want, but for sharing data across views, this can be troublesome. If you get the code and run it, you’ll see that if we use two separate instances of the FactoryComponent, that they are not sharing the data whatsoever.
Shared Instances
Another alternative is to use shared instances when you need to share code. For example:
// Shared
import axios from "axios";
import { ref } from "vue";
export let countries = ref([]);
export async function load() {
if (countries.value.length === 0) {
let result = await axios.get("https://restcountries.eu/rest/v2/all");
countries.value.splice(0, countries.value.length, ...result.data);
}
};
export function removeItem(item) {
let index = countries.value.indexOf(item);
if (index > -1) {
countries.value.splice(index, 1);
}
}
export default {
countries,
load,
removeItem
};
You can see were creating instances that are returned when imported (not created like the factory). This works well when you have one or more pieces of data that need to be shared. Using this is similar to the factory method:
// Shared
import { load, removeItem, countries } from "../shared/data";
import { onMounted } from "vue";
export default {
setup() {
onMounted(async () => await load());
return {
countries,
removeItem
};
},
};
This is a simpler version of what Vuex does and it useful for small, discrete parts of your application. But it doesn’t exactly replace Vuex in my opinion…
Vuex
The problem is that Vuex provides several services…you might need them all. The reality is that I use Vuex specifically to be sure that all the changes (e.g. mutations) of state happen on purpose. The ability to turn on strictness for Vuex (and subsequently throw errors if state is changes outside of a mutation) is key to sharing state.
When you’re building a simple applicaiton in Vue, Vuex can be overkill. For example, in our very trivial sample, the Vuex looks like this:
import { createStore } from 'vuex'
import axios from "axios";
export default createStore({
state: {
countries: []
},
mutations: {
setCountries: (state, items) => state.countries.splice(0, state.countries.length, ...items),
removeItem: (state, item) => {
let index = state.countries.indexOf(item);
if (index > -1) {
state.countries.splice(index, 1);
}
},
},
actions: {
load: async ({commit}) => {
let result = await axios.get("https://restcountries.eu/rest/v2/all")
commit("setCountries", result.data);
}
}
})
Needing an action for load and separate mutations does add complexity to the code. No doubt. But if your goal is to just share data, the Shared Component is better. But as your application grows (and binding is more complex) having the strictness of the Vuex I think pays off. Luckily, using Vuex in the Composition API is simpler (no more helpers):
import store from "../store";
import { onMounted, computed } from "vue";
export default {
setup() {
const countries = computed(() => store.state.countries);
onMounted(() => store.dispatch("load"));
const removeItem = (item) => store.commit("removeItem", item);
return {
countries,
removeItem
};
},
};
To use state, typically you would wrap it in a computed value (as shown). Calling the actions and mutations means you need to wrap them (though I think a common pattern or helper library will simplify these). But really, not much code here. Not for the benefit of helping you with app-level (or module-level) state. I wouldn’t expect that there is one and only way to do this, but please don’t throw the Vuex baby out with the bathwater.
If you can poke holes in my logic here, please please do. I would be more than happy to be wrong.