Client-side routing done right

Always had a feeling there’s no such thing as ‘done right’ when it comes to routing in web applications. I set out to prove this.

Hajime Yamasaki Vukelic
codeburst

--

Photo by Jack Anstey on Unsplash

Well, in the lead I say ‘prove’. It’s just click-bait, I’m sure you’ve gotten used to that by now. I’m not going to do anything serious and dry like that here. 😀

I’ll just try to amuse you with some code snippets and perhaps a different way of looking at things. I still stand by the statement that routing can’t be done right on the client-side. It’s impossible to do it right, because client-side web applications don’t work like server-side applications. That’s all good, though, because routing is complicated anyway!

Well, call me drama queen, and I couldn’t argue with that. After all, I’ve only finally formulated this pattern a few weeks ago. I prefer excited, though. The reason I confidently share this with you, though, is that the pattern described in this article has improved our application so much that I’m 100% confident it will stick.

What the heck is routing?

If you don’t know what client-side routing is, good for you. No need to learn it. But since you are virtually guaranteed to encounter it, I’ll briefly explain.

The concept of routing existed in the server-side applications long before it hit the client-side. It was the right fit for the purpose and it stuck. If you’ve ever written a server-side app, you know it’s indispensable.

The dictionary definition of routing is to send or forward by a specific route. That’s basically the idea. You map URL patterns to some functions in your code (or components, classes, whatever), and when user hits that route, you take them to that code and the code handles them. Of course, it would be nice if it were that simple, but it’s not. On the client-side, things aren’t as straightforward as on the server-side.

Since I’ve already told you you don’t need to learn it, I’ll spare you the details. In the context of this article, the gist of it is that we have some predefined patterns (could be a regular expression, too) that look like URLs and it all revolves around URLs.

If you see something like:

router.route('/book/:id', BookComponent)

you should run.

Now that we’re done running, let’s talk about how to solve problems routing was supposed to solve.

But what about bookmarked URLs?

By ‘bookmarked URLs’, we mean that whatever URL user chooses to bookmark or paste into the address bar, should initialize the application with an appropriate state. For example, if your application shares information between users, you want to copy the URL and send it to your friend, and you want that friend to see the same thing (more or less).

Being able to bookmark your application state (well, parts thereof) has nothing to do with whether you need client-side routing libraries. In fact, you don’t really. When your application is booting up, you simply need to look at window.location and figure out what the initial state should be. (More on this later, so hold onto those questions.)

What if the user presses the back button?

Users are like that. They inevitably click that back button. I wish they didn’t, but they do. And when they do it, they expect the app to do the right thing.

Well, don’t routing libraries solve this problem? Sure they do. (If they don’t, they can’t qualify as a client-side routing library.) But let’s step back (pun intended) and think about what they are really doing.

All they are doing is handling the popstate event. The popstate event is quite simple: it just lets you know that the URL in the address bar has changed. You then look up window.location and you’ve got all the info you need.

This time it’s a bit different because now you don’t initialize the state but alter it. But so does any event in your application. Keyboard, form, etc. Why would popstate need special treatment, right?

Now, the good stuff

I keep saying “look up window.location.” If you think of URLs as just serialized application state (well, not all of it), you can dramatically alter the way you think about URLs, and realize that, yes, you don’t really need routing.

Some portions of your application state are important for the two reasons we mentioned above (and maybe some others I can’t remember right now). What ‘some portion’ means depends on your app, so I’ll just give you an example from the app I’m working on right now.

In our app, the application is divided into modules (no, I’m not advocating modular code here, it’s just a business concept called ‘module’). These modules are divided into one or more sections. In order to initialize any module-section combination in our app, we need to know what module and section we are talking about, and whatever additional information the module needs. For example, a company module must know the company ID.

To completely describe any view in our app, we have an object that looks like this:

{
module: 'company',
moduleArgs: ['some-id-123'],
section: 'profile',
sectionArgs: {},
}

This is just a plain JavaScript object. Unlike URLs, these can be easily created and manipulated with the tools you already know and use. Just like any other data in your application.

Now, the way we encode this into an URL is by using the following rules:

  1. the first segment of the path is the module
  2. the rest of the segments are module arguments (or parameters)
  3. the view query string parameter is a section name
  4. any other query string parameters are section arguments

The object in the example would translate to an URL that looks like this:

/company/some-id-123?view=profile

These rules are all arbitrary, and they work for our app. Your app may have different rules. It depends on what you need. For example, you may say that the second path segment is a section in your app, because you always have one section. We chose that section cannot be part of the path because it’s optional for single-section modules. You get the point.

We have a module (JavaScript module!, the naming is driving me nuts) that takes care of the conversion between the URL and the object. It’s quite simple, so I won’t bore you with the details. Let’s take a look at some examples of this:

// We are on '/company/some-id-123?view=profile'> loc.toLocation(window.location)
{module: 'Company', moduleArgs: ['some-id-123'],
section: 'profile', sectionArgs: {}}
> loc.toURL({ module: 'lists', moduleArgs: ['id-456'],
... section: undefined, sectionArgs: {} })
'/lists/id-456'
> loc.toURL(loc.toLocation({
... pathname: '/lists/id-456',
... search: ''
... }))
'/lists/id-456'

These two functions are reversible, if you pipe a value through both, you end up with the original value. (Sort of. The toLocation() would take the window.location object, so we get back a string that represents the URL of that location object.) This is very important for reliability and we have tests that specifically confirm this behavior.

This is not all, though. We need to initialize the application state, react to changes in the URL, and also react to changes in the state, to keep the URL and the state in sync.

When the application initializes, we convert window.location to the application-specific data, and we set the initial state up. In Vue, it may look like this:

@Component
class ModuleSelector extends Vue {
data() {
return {
location: loc.toLocation(window.location)
}
}

// ...
}

Now the location property is reactive state that holds the data initialized from the current location.

As user interacts with the application, we need a way to send the user to some other location. For this we have a service that takes care of that (if the code does not look familiar, it’s because it’s custom).

sevrices.goToLocation({ section: 'anotherSection' })

NOTE: There’s some cleverness going on in how the goToLocation() service handles partial location objects so that we don’t need to spell everything out just to change one thing.

What this service does is it converts the location object into the URL, and uses window.history.pushState() to update the address bar. It also broadcasts the application-specific locationChange event which then lets the application update its state. The URL in the address bar and application state are always kept in sync.

When user clicks on the back button during a session, we emit a locationChange event, and the state is again synchronized more or less the same way.

The Vue component you’ve seen before is instrumented like this:

@Component
class ModuleSelector extends Vue {

// ...
@Listen('locationChange')
setLocation(newLocation) {
this.location = newLocation
}
}

And the final bit is how we render an appropriate module:

@Component
class ModuleSelector extends Vue {
render() {
// some JSX magic here
const ModuleComponent = VIEWS[this.location.module]
return <ModuleComponent section={this.location.section} ... />
}
// ...}

The VIEWS object is just a mapping between module names and components that render the view for that module. For example:

const VIEWS = {
company: Company,
lists: Lists,
}

Aaaand… that’s it. Done. I’m sure you got the idea. If you see something you don’t like, that’s because this is from our app and not yours. You can do things differently, and, in fact, you should, because you have to do what’s best for your app. No canned recipes here. That’s kind of the point of this article.

Wait a second, a router can do those!

In more traditional routing, you could technically do some of this. For example, if the router supports capturing anything after the initial match (e.g., /:module/* ). That partially takes care of the mapping from URLs to the application domain. What about the opposite though? Most routers will not provide that, but instead ask you to use URLs to direct the browser to another address. We care about addresses as much as we care about JSON payloads. What we actually care about is data.

So no, routers usually cannot do all of what’s been shown above. The reason is that routers deal in URL patterns and not data. They don’t treat URLs as just vessels for your data, but instead drag URLs into your application.

A bit unrelated, but there are also routing libraries that deal with transitions and whatnot. Not only are they not able to provide functionality that is specific to your app, but they also provide functionality that your view code probably already has!

Show me the code!

Thing is, you don’t need our code. Our code is specific to our application. The generic parts are all browser APIs. You literally go to MDN and you can find everything you need to know.

Whether you will use events to propagate location changes, or you use a Redux store, or Vuex, whatever, it’s all up to you. How you encode the state, and what parts of the state you will encode is also up to you. It’s part of your application, so you are free to do whatever you want to do, and that’s the beauty of it. It’s also quite simple, as you can see (hopefully).

Let’s recap quickly before we wrap up:

  • You need to define what application state is kept in the URL
  • You need to define a format for serializing and deserializing the state
  • You need to initialize the state when the application starts
  • You need to keep state and URL in sync using pushState() calls and popstate events
  • Any code other than browser APIs belong to your application

I hope that this article has shown that for the modern applications centered around application state, the classic routing is somewhat unnecessary. It can do some of what’s described here, but it’s like using a chainsaw to carve a spoon. There’s no right way to carve a spoon with a chainsaw.

✉️ Subscribe to CodeBurst’s once-weekly Email Blast, 🐦 Follow CodeBurst on Twitter, view 🗺️ The 2018 Web Developer Roadmap, and 🕸️ Learn Full Stack Web Development.

--

--

Helping build an inclusive and accessible web. Web developer and writer. Sometimes annoying, but mostly just looking to share knowledge.