Declarative routing with web components
Tags: JavaScript, Web Components, Router, ES6, Html5 Router

The history API in the browser changed the way how the web application routing was working. Traditionally the routing was happening only on the server-side. With the arrival of history API, we are now able to update the page content and its functionality according to the URL updates. This changed the way single page applications were crafted and now the browser can take care of entire routing once the page is loaded.

Nowadays most of the front end frameworks provide their own routing libraries either with the framework or as separate library provided by the community. These routing libraries work in a way that links the components to the URL and does the update on the application view, state according to the URL changes.

In this article, we are going to look at how we can create a client-side router from scratch using vanilla javascript and custom elements. As we are using custom elements we should be able to create declarative routing elements that are mostly similar to the react-router. If you have ever worked with react-router, the syntax should be familiar to you. The react-router provides custom components to define the route so that we can use JSX tags to define the route. Here let’s use the same concept but with custom elements instead of JSX and react.

The Markup

Create a file called index.html with the below content.

<!DOCTYPE html>

<html>

<head>

<meta charset="utf-8" />

<meta name="author" content="Jasim" />

<meta

name="viewport"

content="width=device-width,minimum-scale=1,initial-scale=1"

/>

<meta name="theme-color" content="#FF00FF" />

<title>Router</title>

</head>

<body>

<wc-router>

<wc-route

path="/"

title="Home"

component="wc-home">

</wc-route>



<wc-route

path="/about"

title="About Us"

component="wc-about">

</wc-route> <wc-route

path="/users"

title="Users"

component="wc-users">

</wc-route> <wc-route

path="/users/:id"

title="User Details"

component="wc-userdetails"

></wc-route> <wc-route

path="*"

title="404"

component="wc-notfound">

</wc-route> <wc-outlet></wc-outlet>

</wc-router>

</body>

</html>

The above code does nothing with the implementation script missing. Let’s check the element syntax before going to the script section. The <wc-router/> tag is the actual router element through which we are going to implement the routing. All routes are defined using <wc-route/> tag. The tag name should comply with the web component standard and you can use any name or prefix instead of these tag names. Each <wc-route /> tag is having three attributes path, title, and component where the path and component are mandatory. The path attribute specifies the URL path that should be matched against the browser history updates. The component attribute specifies the name of the custom element that should be updated when changing the location.

You could see one more tag called <wc-outlet /> inside the <wc-router/> tag. Here, the idea is whenever the history object state is changed the outlet tag content will be cleared and the element corresponding to the current path will be injected to the <wc-outlet/> element. If the new path contains any dynamic parameters, it will be extracted from the URL and will be passed as the attribute to the newly created component.

The browser will never throw an error if the page contains invalid elements. So the above markup should be rendered fine but with no effect. Let’s start writing code for the router element which will take care of the routing in our scenario. We won’t write the code for other custom elements from the above markup as those are used as placeholders and data-structure.

Router element

Create a file called router.js and add the following code to it.

export default class Router extends HTMLElement {

connectedCallback() {

console.log('Router element is rendered');

}

} customElements.define("wc-router", Router);

The above code does nothing other than registering the router element to the custom element registry with tag name wc-router. You can use any prefix and tag name of your choice. Include the script in your HTML file.

<script type="module" src="./router.js"></script>

The attribute type=”module” is important as we are using ES Modules. Use any simple HTTP server to run the file. You can use Node’s http-server module to start a server. Once you start the server and access the HTML file via the localhost, you should be getting the message in your developer tools console.

Building the routes definitions

Each individual route is defined by <wc-router/> tag. It’s the router tag’s responsibility to get all these tags and build the routes definition from it. The router should also watch for route changes to update the corresponding components. Add the following code to the Router class.

export default class Router extends HTMLElement {

// ...

get routes() {

return Array.from(this.querySelectorAll("wc-route"))

.filter(node => node.parentNode === this)

.map(r => ({

path: r.getAttribute("path"),

title: r.getAttribute("title"),

component: r.getAttribute("component")

}));

}

}

The routes property just added is read-only that query all the <wc-route/> element and extracts path and component name into a plain array.

get outlet() {

return this.querySelector("wc-outlet");

}

Add the above code to the Router class. As mentioned above, the DOM change is happening under the <wc-outlet/> tag. So here we have added a property which gives the reference to the outlet element as we have to use the outlet element very frequently.

Navigating with router

Once the page is loaded and the Router is added to the DOM tree, we need to display the initial view or component by matching the current location path name from the window object. So the router needs to check the registered routes to get the suitable route to display it or it should display the fallback scenario like 404 component.

So let's add a new method called navigate. The navigate method should be called once the Router element is loaded so that the default route level component can be updated to the current view.

export default class Router extends HTMLElement {

// ...



navigate(url) {

window.history.pushState(null, null, url);

} connectedCallback() {

this.navigate(window.location.pathname);

}

}

The connectedCallback will be executed whenever the custom element is mounted to the DOM. The history.pushState method is the key player in navigating back and forth through the user’s history. You can watch the URL is updating with the passed URL when you call the pushState method without a page-reload. Moving backward and forward through the user’s history can be done using back(), forward() and go() methods of the history object. Here the pushState method adds a new state to the browser history object.

history.pushState(state, title, url);

The pushState method accepts a simple JavaScript object as the first parameter that can be associated with the new history entry. The url parameter is the pathname that we are going to navigate. For detailed information about pushState method, visit https://developer.mozilla.org/en-US/docs/Web/API/History/pushState

Matching the component

When the history object state is changed, the router needs to look for any registered route then update the view. Create a function called match with the following content.

let paramRe = /^:(.+)/; function segmentize(uri) {

return uri.replace(/(^\/+|\/+$)/g, "").split("/");

} function match(routes, uri) {

let match;

const [uriPathname] = uri.split("?");

const uriSegments = segmentize(uriPathname);

const isRootUri = uriSegments[0] === "/";



for (let i = 0; i < routes.length; i++) {

const route = routes[i];

const routeSegments = segmentize(route.path);

const max = Math.max(uriSegments.length, routeSegments.length);

let index = 0;

let missed = false;

let params = {};

for (; index < max; index++) {

const uriSegment = uriSegments[index];

const routeSegment = routeSegments[index];

const fallback = routeSegment === "*";

if (fallback) {

params["*"] = uriSegments

.slice(index)

.map(decodeURIComponent)

.join("/");

break;

}

if (uriSegment === undefined) {

missed = true;

break;

} let dynamicMatch = paramRe.exec(routeSegment);

if (dynamicMatch && !isRootUri) {

let value = decodeURIComponent(uriSegment);

params[dynamicMatch[1]] = value;

} else if (routeSegment !== uriSegment) {

missed = true;

break;

}

}

if (!missed) {

match = { params, ...route };

break;

}

}



return match || null;

}

The URL processing can be done in various ways. You can use regular-expression to match the URL and extract the params from the URL. Libraries like path-to-regexp can be used to do the basic work for you instead of writing your own implementation. The above code splits the URL with the forward-slash (/) and matches each part individually. If the route definition of a specific part is a dynamic parameter, it can be extracted from the URL easily with this approach. If no match found from the route definition table the method will return the fallback scenario. The method will return a null value if the match is not found and the fallback scenario is not present in the route definition.

The above code does not cover all the use cases that come on a production application. You can refer any routing libraries react-router, reach-router for a detailed implementation code.

Update the navigate method with below code:

navigate(url) {

const matchedRoute = match(this.routes, url);



if (matchedRoute !== null) {

this.activeRoute = matchedRoute;

window.history.pushState(null, null, url); // Update the DOM

this.update();

}

}

The <wc-router/> will update the history state only if a matched state is found from the child <wc-route /> tag. Once the match found, here we are calling the history.pushState method and then update the DOM. Add the update method to the Router.

update() {

const { component, title, params = {} } = this.activeRoute; if (component) {

while (this.outlet.firstChild) {

this.outlet.removeChild(this.outlet.firstChild);

} const view = document.createElement(component); // If the the route has title attribute

// update the document title with title attribute value

document.title = title || document.title; // Pass the dynamica parameters as attribute

// for newly created element for (let key in params) {

if (key !== "*") view.setAttribute(key, params[key]);

} this.outlet.appendChild(view);

}

}

Updating the links

Basic Router element is ready. But when you click on any hyperlink, it will do a full page reload to navigate to the target page. And when the page is reloaded the history state will reset back to the initial state. So we need to block the hyperlink default behavior and use the router’s navigate method instead. Add the following code to the Router class.

updateLinks() {

this.querySelectorAll("a[route]").forEach(link => {

const target = link.getAttribute("route");

link.setAttribute("href", target);

link.onclick = e => {

e.preventDefault();

this.navigate(target);

};

});

}

We don’t want to block all the hyperlinks on the page. Instead, we are blocking only the links that want to update the history and navigate to the new state. The above code looks for all links with the attribute route to block the default behavior and use the router navigate method. We also added href attribute at runtime by copying the route attribute value into it. The updateLinks method should be called once the Router is loaded and for each route change. Call the method inside the connectedCallback:

connectedCallback() {

this.updateLinks();

this.navigate(window.location.pathname);

}

Add the nav links to the top of the page as follows

<nav class="nav">

<ul>

<li><a route="/">Home</a></li>

<li><a route="/about/">About</a></li>

<li><a route="/contact/">Contact</a></li>

<li><a route="/users">Users</a></li>

<li><a route="/misc">Misc</a></li>

</ul>

</nav>

Now the basic Router functionality is ready. Reload the page and click on any link. You should get the elements updated inside the <wc-outlet/> tag in your developer tools element panel.

As we have not defined the elements that need to be placed for each route, there is no change in the page layout. Let’s create elements for each route. Here is an example of how the home element looks like.

export class Home extends HTMLElement {

connectedCallback() {

this.innerHTML = `<div class="page">

<h1>Home Page</h1>

</div>`;

}

} customElements.define("wc-home", Home);

Make sure you have created elements for each component specified in the route definition.

Handling browser back button

Navigation through the links is handled with the above code. But what if the user clicks on the browser back button or back button on their mobile phones? The browser provides an event for this scanario called popstate. Handle this event and navigate to the browser location pathname. Update the Router element class with the following code.

export default class Router extends HTMLElement {

connectedCallback() {

this.updateLinks();

this.navigate(window.location.pathname); window.addEventlistener('popstate', this._handlePopstate);

} _handlePopstate = () => {

this.navigate(window.location.pathname);

} disconnectedCallback() {

window.removeEventlistener('popstate', this._handlePopstate);

} }

The event handle should be removed when the Router element is removed from DOM. The disconnectedCallback lifecycle method will be executed when the element is removed from the DOM.

Lazy loading routes

In order to update the layout, all the code related to the routes should be loaded before. the navigation that can cause some performance issues on your page load especially if your bundles are heavy. With the use of ES Dynamic import, we can load the bundles of particular route only when the user is navigating to that route. Thus our initial resource size can be reduced to increase the page load performance. Let’s add one more route to the <wc-router/> tag.

<wc-route

path="/contact"

title="Contact Us"

component="wc-contact"

resourceUrl="./js/aboutus.js">

</wc-route>

We have added one more attribute called resourceUrl to the tag which is URL of the script file. The Router element’s routes list array does not include this attribute, so let’s add this attribute to the array if it present in the tag. Update the routes property as follows

export default class Router extends HTMLElement {

// ...

get routes() {

return Array.from(this.querySelectorAll("wc-route"))

.filter(node => node.parentNode === this)

.map(r => ({

path: r.getAttribute("path"),

title: r.getAttribute("title"),

component: r.getAttribute("component"),

resourceUrl: r.getAttribute('resourceUrl')

}));

}

}

When the user navigates to this /contact route, the Router should first fetch the script file to update the view. Update the Router’s update method as follows:

update() {

const {

component,

title,

params = {},

resourceUrl = null

} = this.activeRoute; if (component) {

while (this.outlet.firstChild) {

this.outlet.removeChild(this.outlet.firstChild); const updateView = () => {

const view = document.createElement(component);

document.title = title || document.title; for (let key in params) {

if (key !== "*") view.setAttribute(key, params[key]);

} this.outlet.appendChild(view);

this.updateLinks();

}; if (resourceUrl !== null) {

import(resourceUrl).then(updateView);

} else {

updateView();

}

}

}

Summary

The trickiest part of creating the router is URL processing. So it’s always recommended to use any libraries that are already and tested and using in production. The above code just provides a simple insight into the internals of a router. You can extend its scope in various ways as it does not support nested routing, redirect scenarios, etc.

The code for this article can be found GitHub repo: https://github.com/jasimea/router-sample