Writing a JavaScript Framework - Client-Side Routing

This is the last chapter of the Writing a JavaScript framework series. In this chapter, I am going to discuss how client-side routing in JavaScript differs from server-side routing and why should it be treated differently.

The series is about an open-source client-side framework, called NX. During the series, I explain the main difficulties I had to overcome while writing the framework. If you are interested in NX please visit the home page.

The series includes the following chapters:

  1. Project structuring
  2. Execution timing
  3. Sandboxed code evaluation
  4. Data binding introduction
  5. Data Binding with ES6 Proxies
  6. Custom elements
  7. Client-side routing (current chapter)

Routing on the web

Web pages are either server-side rendered, client-side rendered or they use a mix of both. Either way, a semi-complex web page has to deal with routing.

For server-rendered pages routing is handled on the backend. A new page is served when the URL path or the query parameters change, which is perfect for traditional web pages. However, web applications usually keep state about the current user, which would be hard to maintain between the myriad of server-rendered pages.

Client-side frameworks solve these issues by prefetching the app and switching between the stored pages without losing the state. Front-end routing can be implemented very similarly to its server-side counterpart. The only difference is that it fetches the resources straight from the client instead of the server. In this article, I will explain why I think the two should be handled a bit differently, though.

Backend inspired routing

A lot of front-end routing libraries are inspired by the server-side.

They simply run the appropriate route handler on URL changes, which boots and renders the required component. The structure is similar on both ends of the web, the only difference is what the handler functions do.

To demonstrate the similarities, you can find the same routing snippet in the server-side Express framework, the client-side page.js router and React below.

// Express
app.get('/login', sendLoginPage)
app.get('/app/:user/:account', sendApp)
// Page.js
page('/login', renderLoginPage)
page('/app/:user/:account', renderApp)
<!-- React -->
<Router>
<Route path="/login" component={Login}/>
<Route path="/app/:user/:account" component={App}/>
</Router>

React hides the logic behind some JSX, but they all do the same, and they all work perfectly until dynamic parameters are introduced.

In the above examples, a single user may have multiple accounts and the current account can be freely changed. If the account is changed in the App page, the appropriate handler reboots or resends the same App component for the new account - while it might be enough to update some data in the existing component.

This is not a big issue for VDOM based solutions - since they diff the DOM and update the needed parts only - but for traditional frameworks, it can mean a lot of unnecessary work.

Dealing with dynamic parameters

Rerendering the whole page on parameter changes is something I wanted to avoid. To tackle the problem I separated the route from the dynamic parameters first.

In NX, the route determines which component or view is displayed, and it goes into the URL pathname. The dynamic parameters control what data is displayed in the current page, and they are always in the query parameters.

This means that the /app/:user/:account route would transform into /app?user=userId&account=accountId. It is slightly more verbose but it is clearer, and it allowed me to separate client-side routing into page routing and parameter routing. The former navigates in the app shell, while the latter navigates in the data shell.

The app shell

You might be familiar with the app shell model, which was popularized by Google together with Progressive Web Apps.

The app shell is the minimal HTML, CSS and JavaScript required to power the user interface.

In NX, the path routing is responsible for navigating in the app shell. A simple routing structure looks like this.

<router-comp>
<h2 route="login"/>Login page</h2>
<h2 route="app"/>The app</h2>
</router-comp>

It is similar to the previous examples - especially the React one - but there is one major difference. It doesn't deal with the user and account parameters. Instead, it simply navigates in the empty app shell.

This makes it a dead simple tree walking problem. The router tree is walked - based on the URL pathname - and it displays the components it finds in its way.

 

The above diagram explains how the current view is determined for the /settings/profile URL. You can find the accompanying code below.

nx.components.router()
.register('router-comp')
<a iref="home">Home</a>
<a iref="settings">Settings</a>
<router-comp>
<h2 route="home" default-route>Home page</h2>
<div route="settings">
<h2>Settings page</h2>
<a iref="./profile">Profile</a>
<a iref="./privacy">Privacy</a>
<router-comp>
<h3 route="profile" default-route>Profile settings</h3>
<h3 route="privacy">Privacy settings</h3>
</router-comp>
</div>
</router-comp>

This example demonstrates a nested router structure with default and relative routes. As you can see, it is simple enough to be configured by HTML only and it works similarly to most file systems. You can navigate inside it with absolute (home) and relative (./privacy) links. The routing snippet looks like below in action.

 

This simple structure can be abused to create powerful patterns. One example is parallel routing, where multiple router trees are walked at the same time. The side menu and the content in the NX docs page works this way. It has two parallel nested routers, which change the side navigation's and the page's content simultaneously.

The data shell

Unlike the app shell, the 'data shell' is not a hyped term. In fact, it is used only by me, and it refers to the pool of dynamic parameters, which drives the data flow. Rather than changing the current page, it only changes the data inside the page. Changing the current page usually changes the parameter pool, but changing a parameter in the pool does not cause a page reboot.

Typically the data shell is formed by a set of primitive values and - together with the current page - it represents the state of the application. As such, it can be used to save, load or share the state. In order to do this, it must be reflected in the URL, the local storage or the browser history - which makes it inherently global.

The NX control component - among many others - can hook into the parameter pool with a declarative config, which determines how the parameters should interact with the component's state, the URL, the history and the web storage.

nx.components.control({
template: require('./view.html'),
params: {
name: { history: true, url: true, default: 'World' }
}
}).register('greeting-comp')
<p>Name: <input type="text" name="name" bind/></p>
<p>Hello @{name}</p>

The above example creates a component, which keeps its nameproperty in sync with the URL and the browser history. You can see it in action below.

 

Thanks to the ES6 Proxy based transparent reactivity, the synchronization is seamless. You can write vanilla JavaScript, and things will two-way synchronize in the background when needed. The below diagram gives a high-level overview of this.

 

The simple, declarative syntax encourages developers to spend a few minutes with designing the web integration of the page before coding. Not all parameters should go into the URL or add a new history item on change. There are plenty of different use cases, and each should be configured appropriately.

  • A simple text filter should be a url parameter as it should be shareable with other users.

  • An account id should be a url and history parameter, as the current account should be shareable and changing it is drastic enough to add a new history item.

  • A visual preference should be a durable parameter (saved in the local storage) as it should be persisted for each user and it shouldn't be shared.

These are just some of the possible settings. With a minimal effort you can really get the parameters to fit your use case perfectly.

Putting it together

Path routing and parameter routing are independent of each other, but they are designed to work nicely together. Path routing navigates to the desired page in the app shell, then parameter routing takes over and manages the state and the data shell.

The parameter pool may differ between pages, so there is an explicit API for changing the current page and parameters in both JavaScript and HTML.

<a iref="newPage" $iref-params="{ newParam: 'value' }"></a>
comp.$route({
to: 'newPage',
params: { newParam: 'value' }
})

On top of this, NX automatically adds an active CSS class to active links, and you can configure all of the common routing features - like parameter inheritance and router events - with the optionsconfig.

Check the routing docs for more about these features.

A Client-Side Routing Example

The below example demonstrates parameter routing combined with a reactive data flow. It is a fully working NX app. Just copy the code into an empty HTML file and open it in a modern browser to try it out.

<script src="https://www.nx-framework.com/downloads/nx-beta.2.0.0.js"></script>

<script>
nx.components.app({
params: {
title: { history: true, url: true, default: 'Gladiator' }
}
}).use(setup).register('movie-plotter')

function setup (comp, state) {
comp.$observe(() => {
fetch('http://www.omdbapi.com/?r=json&t=' + state.title)
.then(response => response.json())
.then(data => state.plot = data.Plot || 'No plot found')
})
}
</script>

<movie-plotter>
<h2>Movie plotter</h2>
<p>Title: <input type="text" name="title" bind /></p>
<p>Plot: @{plot}</p>
</movie-plotter>

The state's title property is automatically kept in sync with the URL and the browser history. The function passed the comp.$observe is observed, and it automatically fetches the appropriate movie plot whenever the title changes. This creates a powerful reactive data flow which integrates perfectly with the browser.

 

This app doesn't demonstrate path routing. For some more complete examples please check the intro app, the NX Hacker News clone or the path routing and parameter routing docs pages. Both have editable examples.

Conclusion

If you are interested in the NX framework, please visit the home page. Adventurous readers can find the NX source code in this Github organization - split between many repos.

The Writing a JavaScript Framework series is complete with this article, thanks for reading! If you have any thoughts on the topic, please share them in the comments.

Source: https://blog.risingstack.com/writing-a-jav...