Hajime, the duck guy

Client-side scripting kata
On this page
Level: Important

Simple client-side routing

Implement a simple event-based client-side routing using hashes

The way of kata

Kata is an exercise for muscle memory. It's not intended to fill your brain with information but train your fingers to react. The information is there to give you the why, but your fingers need to learn the how.

The material on this page is presented in a specific order — from least specific to highly technical. You will learn the most by jumping in as soon as you have some idea of what you should do. Once you're done, read the rest of the material and check your solution.

All katas are designed to be doable without using 3rd party libraries (and, in fact, the point is to also learn how to do what these libraries do).

To make the best of katas, observe the following rules.

  • Don't rush.
  • When stuck, take a break and do something unrelated.
  • Do not copy/paste code. Always retype everything.
  • Do not use AI tools to generate code.
  • Try to do something that wasn't in the instructions, experiment.
  • Repeat the kata from time to time, even if you think you've got it.
  • You have mastered the kata once you are able to complete it without thinking too much.

Remember, the goal is not to get it done, but to get some practice.

Introduction

In this exercise, you will implement simple client-side routing using the hashchange event. This is a useful pattern for SPA (single-page applications) that does not integrate with SSR (server-side rendering).

Skills you will acquire

  • Client-side routing
  • Working with events
  • Serializing and deserializing state

Materials

Objective

Implement client-side routing that maps hashes to various sections of the page. The routing should allow you to:

  • Hide or reveal different parts of the page based on the hash (fragment identifier)
  • Use the back button in the browser to return to the previous state
  • Go to a specific state by pasting an URL in the address bar
  • Perform initialization when a section is revealed
  • Perform cleanup with a section is hidden

The page you will be working on is provided in the materials section.

Check your solution

  • One of the section is shown by default when the page loads with no fragment identifier
  • Only one section is shown at any given moment
  • There is a navigation menu that lets you switch between sections
  • The link matching the currently shown section should be marked as active
  • When a section is revealed, a message should be logged in the console
  • When a section is hidden, a message should be logged in the console
  • You are able to change the mapping between the fragment identifier and sections freely without touching the section code
  • Title of the page changes when the route changes

Keep in mind

The page already includes a <script> tag that lets you include a JavaScript file in the page.

Routing means deciding what code path(s) to execute based on the "route". The route is some kind of identifier — some piece of text — that the client specified (e.g., URL in the address bar, or, in our case, a fragment identifier, a.k.a. hash).

Although the format of the identifier may be restricted by the platform — there are rules about what URLs and fragment identifiers can contain — how we interpret the identifier is up to use. We can represent as much or as little data with them as we like. In other words, we might need some translation between what's in the raw hash text and what we want our application to know based on that text.

For instance, a fragment identifier can be used to simply identify a part of a HTML page to jump to:

#main

We can also store JSON data in the fragment identifier:

#{"route":"main"}

However, the above example is not a valid hash, so some characters must be replaced (URI-encoded) by sequences that can later be converted back (URI-decoded) into their original form:

#%7B%22route%22%3A%22main%22%7D

Or use URL-encoded form data:

#route=main

By default, the browser will want to jump to any element on the page that has an id attribute matching the fragment identifier. Although the first option seems like the simplest solution, you should keep in mind that it is more likely to trigger this behavior unintentionally. Try different options to see which ones work the best.

To implement a router, there are two parts that we care about:

  • The exact timing of the route change
  • The contents of the hash

The timing of the route change is provided to us by two things:

  • Page load (this is the starting point which also needs to be handled)
  • The hashchange event (this is fired every time the hash changes)

We will use the term 'routing event' to refer to both of those situations. The code that handles all the routing events will be called the 'router', and the code that implements the behavior of the specific route will be called 'route handler'.

The raw content of the hash is provided via the location.hash property.

We have a two options for handling route events. We can either have the router invoke the individual route handlers, or we can have the router relay the routing event so that route handlers can handle it themselves.

The first option is implemented simply by having the router call the appropriate handler function.

The second option can be implemented by using a custom event bus (e.g., by using an EventTarget object). The route handlers would subscribe to the events on this custom bus, while the router emits events on the bus.

When switching between routes, we usually need to do some setup. For example, fetch some data, or prepare the UI in some way. When moving away from a route, we may also want to do some clean-up. Reset UI state, for example, or empty cached data. In this example, you will log messages to the console in addition to toggling appropriate sections on and off. Feel free to experiment with other things you can do when setting or cleaning up the sections.

This exercise deliberately does not use any CSS. Toggling the sections on and off can be done without it. For example, we can use the hidden attribute to turn sections on and off. Consider the initial state of each section so that the correct one can be revealed regardless of what the initial route (i.e., the initial value of the hash) is when page loads.

Reading list

Hints & spoilers

HINT: Converting hashes containing JSON
You can deserialize JSON-containing hashes by using `JSON.parse()` like so:
var data = JSON.parse(decodeURIComponent(location.hash).slice(1) || '{}'))

We slice off the initial # and we also provide a '{}' string to handle the case where the hash is empty. We must call decodeURIComponent() on the hash before parsing it as some characters used in the JSON syntax is not compatible with hashes and will be escaped.

The data variable now contains an object that was represented by the JSON string in the hash.

To put a JSON object in the hash, we use JSON.stringify() like so:

var data = {route: 'main'}
var hash = '#' + encodeURIComponent(JSON.stringify(data))

If you want to use a hard-coded value in an <a> tag in the HTML, you can convert the JSON data in the developer tools and paste the result into the href attribute.

HINT: Converting hashes containing URL-encoded data
You can deserialize hashes with URL-encoded data by using the `URLSearchParams` constructor.
var data = new URLSearchParams(location.hash.slice(1))

We slice off the initial #. The data object now represents an URLSearchParams object that was represented by the contents of the hash. Unlike JSON values, there is no need to decode the hash because URLSearchParams does it for us.

To create a URLSearchParams() object and put it in the hash, we write code like this:

var data = new URLSearchParams()
data.set('route', 'main')
var hash = '#' + data.toString()

To use a hard-coded value in an <a> tag in HTML, you can do this conversion in the developer tools and then paste the result into its href tag.

Hint: Letting the router map events to handlers
In the scenario where we are letting the router handle the mapping between the hash and route handlers, we might implement it similar to this:
function onRouteChange() {
    var targetRoute = 'main' /* obtain target route from location.hash */
    switch (targetRoute) {
        case 'main':
            setupMain()
            break
        case 'about':
            setupAbout()
            break
        // ....
    }
}

Alternatively, we may move the mapping outside of the router function to make it more obvious:

var routeHandlers = {
    main: setupMain,
    about: setupAbout,
    // etc...
}

function onRouteChange() {
    var targetRoute = 'main' /* obtain target route from location.hash */
    var handler = routeHandlers[targetRoute]
    handler()
}

The advantage of this code is:

  • Direct mapping within the router gives us more control over the mapping
  • The code is overall simpler

The disadvantages include:

  • The router/mapping code has to be modified in order to add handlers

With this approach, the cleanup code can be implemented such that each route handler returns its own cleanup function:

var routeHandlers = {
    // etc...
}
var cleanup

function onRouteChange() {
    if (cleanup) cleanup()
    var targetRoute = 'main' /* obtain target route from location.hash */
    var handler = routeHandlers[targetRoute]
    cleanup = handler()
}
Hint: Letting handlers map themselves
We can implement a router so that handlers map themselves (or even not at all). This can be implemented using an event bus to which the router would announce routing events.
var bus = new EventTarget()

function onRouteChange() {
    var targetRoute = 'main' /* obtain target route from location.hash */
    bus.dispatchEvent(new Event('route:' + targetEvent))
}

bus.addEventListener('route:main', setupMain)
bus.addEventListener('route:about', setupAbout)
// etc...

This approach has several advantages including:

  • The flexibility of being able to add the route handlers without specifically modifying the code related to the mapping

If incurs the following disadvantages in exchange:

  • Potentially not having a clear picture of what mappings exist without searching through the codebase
  • Additional complexity of introducing an event bus.

To implement the cleanup in this case, we can emit a cleanup event before emitting a route event. The setup function for each route can then set up a one-time event listener for the cleanup event.

var bus = new EventTarget()

function onRouteChange() {
    bus.dispatchEvent(new Event('cleanup'))
    var targetRoute = 'main' /* obtain target route from location.hash */
    bus.dispatchEvent(new Event('route:' + targetEvent))
}

bus.addEventListener('route:main', function (ev) {
    ev.target.addEventListener('cleanup', cleanupMain, {once: true})
    // do the setup
})
// etc...

Want more?

Back to top