
Shared state and routing in Vue.js
When you start using Vue.js to create a web application, sooner or later you will face two problems: managing shared state and routing.
These problems are most often approached separately. But if you think about it, in most web applications they are very related. In fact, the current route is not just part of the shared state, it’s often the most important part of the state. Changing the state and updating the route also go hand in hand.
Before we can fully understand how state and routing are related, first let’s try to answer these two simple questions: why do we need a shared state, and why do we need routing?
Shared state
Each Vue.js component can have its own state. But complex applications consist of an entire tree of components. You can pass information up using events and down using properties, but this quickly becomes hard to manage. A lot of state is duplicated and a lot of wiring is necessary to let components in different branches communicate. Also it’s not clear which components are responsible for the actual logic, for example requesting data from the server and processing it.
That’s why it’s best to separate the data and logic into a separate layer, independent from the components. This can be achieved using a Vuex store. The store is simply a tree of objects which contain data, and associated methods which implement logic.
The structure of the store doesn’t have to reflect the structure of components in any way. While components represent visual parts of the page, the modules of the store represent different areas of logic. The data in the store can be mapped to components in a flexible way.
Page URL and routing
Imagine a simple web application which displays categories and products. In a traditional website, each category would be a link which contains the ID of the category. The server extracts this ID from the URL, renders the whole page and returns it to the browser. So the URL is used to pass information between pages.
A single-page application works differently. When the user clicks on an image, an action is triggered which loads some data from the server and updates the components. Different information can be displayed without reloading the whole page, which improves performance.
However, if we don’t update the URL while changing the state of the application, the user experience will suffer. The user will not be able to bookmark a specific page — for example a selected category — or share the URL with someone else. Also, the browser’s back and forward buttons cannot be used to quickly navigate between recently visited categories.
So the URL is not just a way to pass information to the server. It’s also a way to uniquely identify the current state, so that the user can return to it later.
To solve this problem, a solution like vue-router is commonly used. It makes it possible to associate the information displayed in a single-page application with an actual URL. It’s a good solution for many use cases, but the problem is that it’s mainly designed to work at the component level. It can be used to easily display one component or another depending on the URL.
However, what we really want is to be able to affect the state of the global store using the URL. To do that, instead of using the <router-view>
component, we could register a custom route change callback using the router.beforeEach
method, and dispatch an action in the Vuex store:
router.beforeEach( ( to, from, next ) => {
store.dispatch( 'navigate', { to } );
next();
} );
However, in order to add basic routing capabilities to a web application, we don’t have to use the vue-router at all. I will show you how this can be done using just a few lines of code.
Creating a simple router
So let’s implement the simplest router ourselves:
import Vue from 'vue'const router = {
get route() {
const href = window.location.href;
const index = href.indexOf( '#' );
if ( index >= 0 )
return href.slice( index + 1 );
return '';
},
push( route ) {
window.location.hash = route;
}
};export default router;
As you can see, I’m using the hash (the part of the URL after the ‘#’) to indicate the current route. That’s the easiest solution because it doesn’t require any special configuration on the server side.
The router simply uses the standard window.location
browser API. The route
property returns the current route. The push()
method changes the route to the given value, in such way that the browser’s back button will return to the current route.
The router also needs to detect when the current route changes. We can use the standard hashchange
event to do that:
const listeners = [];window.addEventListener( 'hashchange', () => {
listeners.forEach( l => l.callback() );
} );
This event occurs as a result of clicking on a link, using the back/forward buttons in the browser, or calling the push()
method. The event is simply passed to an array of listeners.
Now let’s create a very simple mixin that will integrate our router with Vue.js:
Vue.mixin( {
beforeCreate() {
this.$router = router;
if ( this.$options.routeChanged ) {
listeners.push( {
component: this,
callback: this.$options.routeChanged.bind( this )
} );
}
},
destroyed() {
if ( this.$options.routeChanged ) {
const index = listeners.findIndex( l => l.component == this );
if ( index >= 0 )
listeners.splice( index, 1 );
}
}
} );
Since this is a global mixin, the beforeCreate()
method is called for every Vue.js component which is created in our application.
We inject the $router
property to the component instance to make it possible to access the router without having to import it explicitly.
We also check if the component options contain a routeChanged
function. If it’s present, we add the component to our internal array of listeners. This way, the function will be called whenever the current route is changed.
Note that in the beforeCreate()
function, this
refers to the component instance. We need to bind this instance to the routeChanged
function so that it can also use this
.
The destroyed()
function is called when a component is destroyed. We use it to remove the associated listener from the array.
Application component
In order to tie our simple router with the Vuex store, we will add the following two functions to the main App
component:
export default {
// ...
mounted() {
this.$store.dispatch( 'startup' );
},
routeChanged() {
this.$store.dispatch( 'navigate' );
}
}
We call the startup
action to perform initialization when the application component is mounted. We also call the navigate
action when the current route is changed. This way, the store takes the whole responsibility for the routing logic.
At this point you might ask why the router doesn’t simply invoke the navigate
action directly? It’s just a matter of decoupling the implementation: the router doesn’t need to know anything about the store, so it’s more universal.
Store actions
Let’s look at a fragment of an example Vuex store:
import router from './router'const data = {
categoryId = 0;
};const mutations = {
clearSelection( state ) {
state.categoryId = 0;
},
selectCategory( state, { id } ) {
state.categoryId = id;
}
};const actions = {
startup( { dispatch } ) {
// ... perform initialization ...
if ( router.route )
dispatch( 'navigate' );
},
navigate( { commit, dispatch } ) {
const route = router.route;
let params;
if ( route == '' ) {
commit( 'clearSelection' );
} else if ( params = /^\/category\/(\d+)$/.exec( route ) ) {
const id = Number( params[ 1 ] );
commit( 'selectCategory', { id } );
dispatch( 'loadCategory' );
} else {
// ... handle other routes ...
}
}
loadCategory() {
// ... load data from server ...
}
};
The startup
action calls navigate
when the initial route is non-empty. This can happen, for example, when the user opens a bookmarked link.
The navigate
action checks the current route and invokes appropriate mutations and actions to modify the state. In this example, the empty route clears the selected category; a route like /category/123
sets the ID of the category and triggers an action which loads data from the server.
I’m using regular expressions to parse the route and extract parameters. You can also use path-to-regexp, which is used internally by vue-router, to make the code easier to understand. If you have lots of routes, you can store them in an array to avoid large if-else statements.
UI navigation
The UI components generally don’t have to be aware of routing. They simply react to changes in the store. This way the whole logic is kept in once place.
However, the UI components need to be able to change the current route as a reaction to an event. Instead of dispatching store actions directly, we will change the URL to invoke state changes in the store.
Obviously, the simplest way to do that is to use links:
<ul v-for="category in categories">
<li>
<a v-bind:href="'#/category/' + category.id">
<img v-bind:src="category.imageUrl">
</a>
</li>
</ul>
Another way is to attach the click event to a HTML element:
<ul v-for="category in categories">
<li>
<img v-bind:src="category.imageUrl"
v-on:click="click( category.id )">
</li>
</ul>
Then in the click()
method we can simply access the $router
property:
click( id ) {
this.$router.push( '/category/' + id );
}
The second solution is more flexible, because it works with buttons, table rows and other elements which are not links. However, an advantage of plain links is that the user can right-click and copy the link, open it in another tab, etc., so it’s better for usability.
Note that not all state changes are necessarily invoked by routing. For example, expanding or collapsing an element doesn’t require changing the URL. Also, sometimes we might want to perform an action, like saving data to the server, before the actual route is changed. When designing an application, it’s important to create a map of all routes that will be used and associated actions.
I hope that this article helped you understand how routing works and how it’s associated with the shared state. As you can see, routing is very simple and it only requires knowing some basic browser API to get started.