Instance-Aware Vuex Modules - Part 2
Note: This is part 2 of a 3-part series on "Instance Aware Vuex Modules". In Part 1 we covered a basic introduction to Vuex and the use of namespaced modules. In this post, we'll explore using dynamically-namespaced Vuex modules alongside dynamic routes in vue-router, and some interesting challenges presented.
Dynamic Vuex Modules per Route
Now that we have a better understanding of how Vuex modules work (from part 1), we can start exploring the main reason for this series. If you happened to also read my Component Driven Performance Patterns article, you may remember how we talked about registering Vuex modules dynamically as we go to a new route, in order to keep that module code off the critical path and keep our apps fast. Specifically, in the Dynamic Vuex Modules section we talk about how we would dynamically register a route-level module for a Homepage and an About page on our website.
Let's change that up a bit here and work with a bit more complicated example where our route has dynamic parameters. Consider again an e-commerce site where we can view different products via URLs such as /product/red-dress
and /product/blue-dress
. In vue-router we can set that up as follows:
import Router from 'vue-router';
import ProductRouteComponent from 'ProductRouteComponent.vue';
const router = new Router({
routes: [{
path: '/product/:slug',
component: ProductRouteComponent,
}],
})
Now, when we render our ProductRouteComponent
via a url such as /product/red-dress
, we will be able to access the red-dress
slug via this.route.params.slug
.
Data Fetching
Once we have our router passing along the proper slug, we have to handle fetching the data for the given slug. There are two main ways to approach this concept of loading async-data along with a route transition.
Option 1: Fetch data, then route
In this approach, when a user clicks on a link, we fetch the data for the destination route while still on the current route. Once we have the data, we can route to the destination. This allows us to route/animate into a fully-rendered destination route. If we were to encounter errors during the data fetch, we can show some form of in-context error on the current page.
Option 2: Route, then fetch data
In this approach, when a user clicks on a link we immediately route them to the destination route, and then start the data fetch. Because we don't have the data available, the destination route component needs to be able to kick off it's own data fetching as well as show some sort of skeleton view or loading state during the fetch. If we encounter any errors during the data load, our destination page needs to show some form of error state to the user.
Note: This was a common enough decision that the Vue SSR docs have an entire section for it, which used to include both of the above approaches. However, with the introduction of the serverPrefetch
hook in Vue 2.6, they seem to now only document the second approach because it aligns with the serverPrefetch
hook. Personally I think both options are viable depending on your UX, so I do wish they still included documentation on the first approach.
For this post we are going to choose option 1 above, but the concept of Instance-Aware Vuex Modules applies in both cases.
Vuex Module Registration
The idea is to use a dynamic Vuex module per-route, so we need a way to:
- Let a route-level component define a Vuex module to be used
- Let a route-level component define how to fetch the data required for that route
- Hook into all routing operations so we can register the Vuex module and fetch the data for the destination route
For performance reasons, we'd like to include all logic related to a given route behind the route-level async component so that all of the associated code is bundled with the webpack chunk for that route.
First, we have to define a Vuex module that can handle the data for our route. Here is what a simple product page module might look like. Note that we're using a function for our state
here so we can re-use this module across multiple routes.
// product-module.js
export {
namespaced: true,
state: () => ({
productData: null,
}),
mutations: {
setData(state, data) {
state.productData = data;
},
},
actions: {
async loadData({ commit }, slug) {
const data = await fetchProductDataForSlug(slug);
commit('setData', data);
},
},
};
Then, we can add some new properties to our route-level component for (1) and (2) above to inform it of our module and how to fetch the data required for our route:
// ProductRouteComponent.vue
import productModule from './product-module';
export default {
name: 'ProductRouteComponent',
// This property doesn't mean anything to Vue, this is a special property we
// will be using to tell our router hook about the dynamic Vuex module we want
// to use
vuex: {
moduleName: 'product',
module: productModule,
},
// Another property that doesn't mean anything to Vue, but that we'll be calling
// from our route guard to fetch the data required before routing to this route
fetchData(route, store) {
return store.dispatch('product/loadData', route.params.slug);
},
}
As you can see, we've added a new vuex
property to our component that allows us to define the Vuex module and namespace we'd like to use for these routes. Then, we've added a new fetchData
method that, given the route and the Vuex store instance, will use the provided Vuex module and the route slug to fetch the appropriate data for the route.
With this, we now need to solve (3) above and hook into our routing lifecycle so we can leverage these two new properties on our components. To hook into a routing operation, we can use the vue-router
beforeResolve
navigation guard. This allows us to perform async operations and call next
to continue with the routing operation. The following example is based on the now-removed approach in the Vue SSR docs.
Note: This is a simplistic approach for example purposes. In a real-world app you will need to do some comparisons between prior routes and avoid double-registering modules
// router.js
router.beforeResolve(async (to, from, next) => {
// Find out the components that match our destination route. This will
// only be more than one component if you are using nested routes
const matched = router.getMatchedComponents(to);
try {
// Register Vuex modules for each destination component
matched
.filter(cmp => cmp.vuex)
.forEach(cmp => store.registerModule(cmp.vuex.moduleName, cmp.vuex.module));
// Execute fetchData for each destination component
const promises = matched
.filter(cmp => cmp.fetchData)
.map(cmp => cmp.fetchData(to, store));
await Promise.all(promises);
// All data is now loaded, call next() to continue the routing
// operation
next();
} catch (e) {
// We encountered an error while fetching data, call next(e) to
// abort the routing operation
next(e);
}
});
Cool - now we have a way to automatically load data into a namespaced Vuex module for every routing operation! Below is a codepen showing this in action. The product page in this case is mapping the product slug in from it's namespaced Vuex store so it can render the slug in the template.
See the Pen Vuex Module/Vue Router Animation Issue by Matt Brophy (@brophdawg11) on CodePen.
So, we're done, right?
So that's it? We can take this approach and run with it. We can use namespaced modules for all of our routes and everything just works?
...not quite. Did you try clicking between /product/red-dress
and /product/blue-dress
above? And if so do you see the bug? Go back and look closely...
...
OK, hopefully you saw it - when we click from one product route to another, the current route that is being animated out updates with the destination route slug. This happens because we're using the same product
namespace for all ProductRouteComponent
instances. So when we load /product/red-dress
we populate the state.product
store with data from the red-dress
. But when we click to the blue-dress
, during our beforeResolve
hook we update the same state.product
store with the blue-dress
data, and because Vue's computed properties are reactive, our current view (very helpfully) re-renders the new data for us while it's being animated out.
Keep in mind that this is really only a problem if you intend to animate route transitions between the same Route-level components. If this isn't an issue in your UX, then you may be just fine with the current approach.
But, if we need to support this - what are our options? There's no way to tell a Vue component to just stop being reactive (as far as I know). So we need to find a way to actually support having two route-level components rendered on the screen, at the same time, from two different Vuex modules.
Namespacing to the rescue
Namespacing seems like an obvious solution to this issue, but we're already namespacing, so what gives? The problem is that we haven't made our namespace specific enough. We chose product
, which is specific to our route-entry, but not specific enough for each potential instance of that route-entry. Because our route of /product/:slug
is slug-specific - we need to also ensure our Vuex namespace is slug-specific.
So, lets take a look at what that might mean to our above setup. We don't need to change our vuex module at all - we simply need to adjust the namespace we register it with. So what if instead of a static string for the namespace, we provided a function that could generate a namespace given a component instance?
// ProductRouteComponent.vue
import productModule from './product-module';
// Function that will return us a slug-specific namespace based on
// the current route
const getNamespace = cmp => `product--${cmp.$route.params.slug}`;
export default {
name: 'ProductRouteComponent',
vuex: {
moduleName: getNamespace, // Specify a function instead of a string
module: productModule,
},
fetchData(route, store) {
// Since we don't yet have a component instance yet, we can fake
// it and provide an object with the $route property we require
const fakeCmp = { $route: route };
const namespace = getNamespace(fakeCmp);
return store.dispatch(`${namespace}/loadData`, route.params.slug);
},
}
This type of change will allow us to create a store that looks like the following. It will allow us to have both red-dress
and blue-dress
components rendered simultaneously from their own respective Vuex stores. And we'll also have mutations/actions/getters that are specific to the individual stores.
store.state = {
`product--red-dress`: {
productData: { ... },
},
`product--blue-dress`: {
productData: { ... },
},
}
Now if we update our example to use these dynamic namespaces based on the route slug, we can see that we no longer have an issue during our animations - because we are working off of two different sub-modules:
See the Pen Vuex Module/Vue Router Animation Issue by Matt Brophy (@brophdawg11) on CodePen.
map* helpers
This above setup works quite well from my experience, but it does have one fairly annoying "issue" in that it does not play nicely with the awesome map*
helpers provided by Vuex because they only accept a static string for the namespace. There is no way to dynamically determine the namespace at runtime based on the component instance.
// ProductRouteComponent.vue
export {
methods: {
// This works with a static namespace, but there is no way to map
// these actions such that they differentiate between
// `product--red-dress/loadData` and `product--blue-dress/loadData`
...mapActions('product', {
loadData: 'loadData',
}),
},
}
This functionality has been requested but the Vuex team has decided not to implement it because there are some ways to workaround it, although I don't personally like the workarounds due to the amount of additional boilerplate code they require. Here's an example of the workaround proposed by the Vuex authors:
// ProductRouteComponent.vue
const getNamespace = cmp => `product--${cmp.$route.params.slug}`;
export {
methods: {
...mapActions({
loadData(dispatch, payload) {
return dispatch(getNamespace(this) + '/loadData', payload);
},
}),
},
}
While this works fine, it doesn't really scale very well as you map more and more state/mutations/actions/getters into your component. Let's look at a more realistic scenario for a product page where your Vue store exposes a lot of various actions and getters:
// ProductRouteComponent.vue
const getNamespace = cmp => `product--${cmp.$route.params.slug}`;
export {
computed: {
...mapState({
productName: state => state[this.namespace].productName,
productPrice: state => state[this.namespace].productPrice,
productColors: state => state[this.namespace].productColors,
productSizes: state => state[this.namespace].productSizes,
}),
namespace() {
return getNamespace(this);
},
},
methods: {
...mapActions({
loadData(dispatch, payload) {
return dispatch(this.namespace + '/loadData', payload);
},
addToCart(dispatch, payload) {
return dispatch(this.namespace + '/addToCart', payload);
},
}),
},
}
To me, this is a lot of repetitive boilerplate just to support namespaced properties. It's also worth noting that this simple approach above doesn't work for nested module state where the namespace is more than one level deep. I would instead like to see something that looks almost identical to how we'd use the map*
helpers with static namespaces. Something like the following is what we're striving for:
export {
computed: {
...mapInstanceState(getNamespace, {
productName: state => state.productName,
productPrice: state => state.productPrice,
productColors: state => state.productColors,
productSizes: state => state.productSizes,
]),
},
methods: {
...mapInstanceActions(getNamespace, {
loadData: 'loadData',
addToCart: 'addToCart',
}),
},
}
Creating Instance-Aware Helpers
Thankfully, we're not totally stuck with the overly-verbose workaround proposed by he Vuex authors. It's possible to write our own little wrapper utilities that will abstract away the boilerplate and get us back to the lean approach we'd have with static namespaces.
Let's look at how we could write a mapInstanceState
wrapper around mapState
that allowed for namespace functions instead of strings.
First, let's look at what the mapState
usage looks like for a static namespace:
computed: {
...mapState('product', {
productName: state => state.productName,
}),
},
// This effectively becomes the following at runtime:
computed: {
productName() {
const moduleState = this.$store.state.product;
return moduleState.productName;
},
},
For dynamic namespaces, we could write our transformation such that it still just uses mapState
under the hood, so consider the following transformation:
const getNamespace = cmp => cmp.$route.params.slug;
// We would need to transform this:
computed: {
...mapInstanceState(getNamespace, {
productName: state => state.productName,
}),
},
// Into something like this using the regular mapState function:
computed: {
...mapState({
productName() {
const namespace = getNamespace(this);
const moduleState = this.$store.state[namespace];
return moduleState.productName;
},
}),
},
So, we can leverage mapState
against the root store, and we just need to enhance the provider "mapper" functions to determine the namespace and provide the namespaced module state to the mapper.
Let's see at what this looks like:
function mapInstanceState(getModuleNameFn, mappers) {
// Create an object of the same shape but with namespaced mapper functions
const namespacedMappers = {};
Object.entries(mappers).forEach((entry) => {
const [name, mapper] = entry;
// Note: Do not use an arrow function because we do _not_ want to
// capture the `this` value at this point, we need the runtime
// component instance to be the `this` value
namespacedMappers[name] = function (state) {
// Determine the namespaced module state
const namespace = getModuleNameFn(this);
// "Walk" the state tree step by step,m just in case we have a
// deeply nested namespace
const moduleState = namespace.split('/')
.reduce((acc, p) => acc[p], state);
// Call the original mapper function with the moduleState
return mapper.call(this, moduleState);
};
});
// Pass through our namespaced mappers to the normal mapState function
return mapState(namespacedMappers);
}
What we're doing here is going through all of the values of the object passed as the second argument to mapInstanceState
(i.e., the mapper functions) and wrapping them in little outer functions that will determine the proper namespace, then the namespaced module state, and then call the existing mapper function with the module state. Then we take this newly created object and pass it along to mapState
.
I should note that the function above doesn't yet support all of the same usages as the mapState
function. For example, it cannot use the array shorthand, nor does it pass through module getters
as the second argument, but those are not terribly complex changes to add if required.
We should also note that similar mapInstanceMutations
, mapInstanceActions
and mapInstanceGetters
can be written using very similar approaches. We've been using this type of approach a lot over at URBN and hopefully we'll open source our versions of these utilities in the near future.
Thanks for reading, and stay tuned for part 3 of this series where we will look into some other use cases for instance-aware Vuex components beyond strictly route-level components.
Update: Interested in using this approach? We've open-sourced these helpers functions we're using at URBN as the @urbn/vuex-helpers
package on npm
.