Vuex Plugins and Outlining Complete Components
The above codesandbox contains a progression of concepts starting at a simple implementation of a Vuex Plugin, starting at intercepting calls to actions
and mutations
which then call dispatch
es appropriately.
Conceptually the benefit is to abstract simple concepts from complex action
calls. It also follows a more "domain driven" model, so that all the logic related to a single concept is together rather than spread across the codebase.
This will allow for faster and more direct refactoring, plus a simplicity in conceptual knowledge.
The Vuex documentation on Plugins is (like usual) very good:
- https://vuex.vuejs.org/guide/plugins.html
- https://vuex.vuejs.org/api/#subscribe
- https://vuex.vuejs.org/api/#subscribeaction
1: Implementing a Simple Intercept
The first level of implementing a Vuex plugin is for it to act as a simple interceptor.
We start with the Vue file:
<template>
<div>
<p>Loading State First: {{ loadingStatuses.first }}</p>
<p>
<button @click="fetchSomething('first')">Fetch First</button>
</p>
<p>Loading State Second: {{ loadingStatuses.second }}</p>
<p>
<button @click="fetchSomething('second')">Fetch Second</button>
</p>
<p>Loading State Third: {{ loadingStatuses.third }}</p>
<p>
<button @click="fetchSomething('third')">Fetch Third</button>
</p>
Then in our store file:
state: {
loadingStatuses: {
first: false,
second: false,
third: false
},
values: {
first: undefined,
second: undefined,
third: undefined
}
}
actions: {
fetchSomething: ({ commit }, item) =>
setTimeout(
() => commit("SET_ITEM_IN_STATE", { item, value: "test" }),
1000
),
setLoadingStatus: ({ commit }, loadingItemAndValue) =>
commit("SET_LOADING_STATUS", loadingItemAndValue)
}
mutations: {
SET_ITEM_IN_STATE: (state, { item, value }) => (state.values[item] = value),
SET_LOADING_STATUS: (state, { item, isLoading }) =>
(state.loadingStatuses[item] = isLoading)
}
This should look pretty typical to what would normally be written when intertwining loading with a call out to an API endpoint (which is mocked with the setTimeout
in fetchSomething
).
But what's missing is fetchSomething
dispatching setLoadingStatus
before the call to true
and after to false
, which typically looks something like:
fetchSomething: ({ commit, dispatch }, item) => {
dispatch("setLoadingStatus", { item, true })
setTimeout(() => {
commit("SET_ITEM_IN_STATE", { item, value: "test" })
dispatch("setLoadingStatus", { item, false })
}, 1000)
},
This is where the Vuex plugin comes in. By intercepting calls based on specific hooks, such as an action
or mutation
, these loading statuses can abstracted out to a single location.
First is to create a new plugin file, for this example you can see how it's handled in loadingComponentPlugin.js
, which is imported into your Vuex index.js
file, then registered on the root Vuex.Store
as plugins: [loadingComponentPlugin]
.
Then in the Vuex plugin file, you add this code:
export default (store) => {
store.subscribeAction(({ type, payload }, state) => {
if (type === "fetchSomething") {
console.log({ type, payload });
store.dispatch("setLoadingStatus", {
item: payload,
isLoading: true
});
}
});
store.subscribe(({ type, payload }, state) => {
if (type === "SET_ITEM_IN_STATE") {
console.log({ type, payload });
store.dispatch("setLoadingStatus", {
item: payload.item,
isLoading: false
});
}
});
};
The first part of the Vuex plugin is subscribeAction
, which will catch any action that is called throughout the app. type
refers to the path of the actions, payload
to what is being called with it, and the final argument state
being the app's state
.
The second hook is basically the same thing, except that the subscription is to mutations
.
As the call on the action is made, a call to set isLoading
to true
is called, then as it goes to set the response in state (and therefore the response from the endpoint is complete), it returns isLoading
to false
.
While this is a simple example, an includes
logic could easily be used in the if
statement, which then would allow this logic to be passed between several different action calls, etc.
2: Implementing a Conceptual Store File
The first level in this is very handy for abstracting code, but this started me moving into a direction of abstracting store logic out of page stores, and again more in the "domain" direction.
The next step was creating a store file dedicated to handling the logic of "loading" that was not conceptually connected to a page or even component. Instead it's taking the idea of "loading" and moving it into it's own dedicated area, to be reused where necessary.
For example, let's say there are three different store files that need to handle loading states from API end-point calls. Typical cases will have each one of those pages having their own series of loading state handling for each one of those calls.
Let's just say each page has 3 loading states to handle, that means there's going to be a total of 9 various loading states to manage.
But with this conceptual store concept, those 9 areas that need handling can be reduced to just one.
We can register a new store file called loading
as this:
state: {
isLoding: false
},
actions: {
setLoadingStatus: ({ commit }, isLoading) =>
commit("SET_LOADING_STATUS", isLoading)
},
mutations: {
SET_LOADING_STATUS: (state, isLoading) => {
state.isLoading = isLoading;
}
}
Then a Vuex plugin can be registered like this (that in our example is `itemizedLoadingPlugin):
export default (store) => {
store.subscribeAction(({ type, payload }, state) => {
if (type === "fetchSomethingForComponent") {
store.dispatch("loading/setLoadingStatus", true, { root: true });
}
});
store.subscribe(({ type, payload }, state) => {
if (type === "SET_ITEM_IN_STATE_FOR_COMPONENT") {
store.dispatch("loading/setLoadingStatus", false, { root: true });
}
});
};
This is copied from the codesandbox example, but there's another way to view this.
Let's say you have three files, and each of them need to have one (more on this) loading state to worry about.
Then the Vuex plugin file can be used like this:
const actionsThatTriggerLoading = [
'fileOne/fetch',
'fileTwo/fetch',
'fileThree/fetch'
]
const mutationsThatTriggerLoading = [
'fileOne/SET_DATA',
'fileTwo/SET_DATA',
'fileThree/SET_DATA'
]
export default (store) => {
store.subscribeAction(({ type, payload }, state) => {
if (actionsThatTriggerLoading.includes(type)) {
store.dispatch("loading/setLoadingStatus", true, { root: true });
}
});
store.subscribe(({ type, payload }, state) => {
if (mutationsThatTriggerLoading.includes(type)) {
store.dispatch("loading/setLoadingStatus", false, { root: true });
}
});
};
Then in each of the files that are hooked into the loading state can have a simple mapState
(or mapGetters
) that deal with loading:
mapState({
isLoading: (state) => state.loading.isLoading
})
3-a: (Experimental) Multiple Instances in One Page - i.e. Complete Components
As you may have noticed in the previous section, there is an issue when it comes to having multiple loading statuses with a single page.
For example, let's say we have two areas in a page, each making separate calls to fetch data from various API endpoints:
+----------------+
| |
| CALL 1 |
| |
+----------------+
| |
| CALL 2 |
| |
+----------------+
If both areas are relying on a single loading
mapState item, then when CALL 1
is made, then CALL 2
will fall into a loading state as well as CALL 1
and vice-versa.
Since the store file is now driven by "domain" there is a possibility of repetition on the "consumer" side.
This is where a "complete component" comes into play. There are two possible solutions to this issue, I'll go in-depth about the first, then point to the other solution that was used by my former frontend lead at Tithely, Aaron Maurice.
First, create a new component, in the example above I named it loadingComplete
.
<template>
<div class="loading-complete">
Loading Complete: {{ loading }}
<div>{{ loadingState }}</div>
</div>
</template>
loading
refers to the specific component instance's loading state
, while loadingStore
refers to the complete state of the entire state of the loadingStore
store
file (this will be apparent in a second).
Then create a loadingModule
store file, which builds on what was created in the loading
store in the previous section:
import Vue from "vue";
const defaultState = {
isLoading: false
};
export const state = {};
export const getters = {
getThisState: (state) => (uid) => state[uid]
};
export const actions = {
mountNewLoading: ({ commit }, uid) => {
commit("CREATE_NEW_INSTANCE", uid);
},
setLoading: ({ commit }, { uid, isLoading }) =>
commit("SET_LOADING", { uid, isLoading })
};
export const mutations = {
CREATE_NEW_INSTANCE: (state, uid) => Vue.set(state, uid, defaultState),
SET_LOADING: (state, { uid, isLoading }) => {
state[uid].isLoading = isLoading;
}
};
First off, notice that there is a defaultState
variable, which is not merged into state
on initiation. If you peek ahead to actions.mountNewLoading
then mutations.CREATE_NEW_INSTANCE
you'll see the reason why is that there will be a hash registry of defaultStates
on state
by a argument called uid
- a unique identifier.
Looked back at the loadingComponent
file, you'll see a prop for uid
:
props: {
uid: {
type: String,
default() {
return this._uid.toString();
},
},
},
This leverages Vue's internal component registration id, but could also be a uuid
or something else to will allow for there to be a uniquely associated identifer with each instance of the component.
Then, we'll use the mountNewLoading
action to register the new instance in the mounted()
hook (note: a _uid is not present until after mounted):
mounted() {
this.mountNewLoading(this.uid);
},
methods: {
...mapActions("loadingModule", ["mountNewLoading"]),
Returning to the above example with the two calls:
+----------------+
| |
| CALL 1 |
| |
+----------------+
| |
| CALL 2 |
| |
+----------------+
If you were to use a v-if="loadingCallOne
and v-if="loadingCallTwo
type of logic, with the v-else
referring to the loadingComponent
, then it would look something like:
+---------------------------------+
| |
| v-if="!loading1" CALL 1/ |
| v-else LOADING uid-1 |
| |
+---------------------------------+
| |
| v-if="!loading1" CALL 2/ |
| v-else LOADING uid-2 |
| |
+---------------------------------+
Examining the loadingModule
store file, the state
would look something like:
{
"1": { "isLoading": false },
"2": { "isLoading": false },
}
Looking back at two particular calls in the loadingModule
store file:
export const actions = {
setLoading: ({ commit }, { uid, isLoading }) =>
commit("SET_LOADING", { uid, isLoading })
};
export const mutations = {
SET_LOADING: (state, { uid, isLoading }) => {
state[uid].isLoading = isLoading;
}
};
You'll see that the action
and mutation
for setting the loading status requires the uid
to make reference to the specific state
item in which to change the loading state.
And since each loading state is nested within a unique state item in the state, we can leverage a getter
to retrieve the state
of that specific instance:
export const getters = {
getThisState: (state) => (uid) => state[uid]
};
Which in the loadingComponent
looks like a computed
property this:
loading() {
return this.getThisState(this.uid)?.isLoading || false;
},
Referencing a Module Externally
Now that the concept of loading is wrapped up in a paired store
and component
file, how can these be used externally. For example, currently there is no way on how to inform a Vuex plugin on how which loading states to trigger on the page-level API calls.
Here, we'll introduce the ability to use Vue's built in $refs
to pass along uid
s from a components instance.
Within the loadingComplete
component, we'll introduce a couple of helper methods:
methods: {
getUid() {
return this._uid;
},
isLoading() {
return this.loading;
},
},
Then in an external page, in the codesandbox example is Foo.vue
, we add a ref
attribute to mounted component, to access the internal methods:
<LoadingComplete ref="loading" />
<p>
<button @click="getLoadingStateOfModule">
Fetch Loading State of Module
</button>
</p>
And then use them like so:
getLoadingStateOfModule() {
const _uid = this.$refs.loading.getUid();
console.log(this.getThisState(_uid));
},
And then being able to retrieve the instances uid
, it can then be used to call actions
:
<LoadingComplete ref="loading" />
<p>
<button @click="handleFetchForModule">Fetch For Module</button>
</p>
methods: {
...mapActions([
"fetchSomethingForModule",
]),
handleFetchForModule() {
const moduleId = this.$refs.loading.getUid();
this.fetchSomethingForModule(moduleId);
},
Which in the page-level store file looks like:
fetchSomethingForModule: ({ commit }, uid) =>
setTimeout(
() =>
commit("SET_ITEM_IN_STATE_FOR_MODULE", {
item: "module",
uid,
value: "test"
}),
1000
And then combined with a Vuex plugin:
export default (store) => {
store.subscribeAction(({ type, payload }, state) => {
if (type === "fetchSomethingForModule") {
store.dispatch("loadingModule/setLoading", {
uid: payload,
isLoading: true
});
}
});
store.subscribe(({ type, payload }, state) => {
if (type === "SET_ITEM_IN_STATE_FOR_MODULE") {
store.dispatch("loadingModule/setLoading", {
uid: payload.uid,
isLoading: false
});
}
});
};
3-b: (Experimental) Multiple Instances in One Page - i.e. Complete Components - Alternate
Everything is the same in this approach, except how to handle creating the multiple state instances.
Referring back to Vuex's docs, there's another method called registerModule
: https://vuex.vuejs.org/guide/modules.html#dynamic-module-registration , especially noting the Module Reuse section.
This method could be cleaner, and leveraging functionality already within Vuex, but conceptually still holds the same on what's trying to be accomplished.