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
hashchangeevent (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
- Location:
hashproperty (MDN) - Window:
hashchangeevent (MDN) decodeURIComponent()(MDN)encodeURIComponent()(MDN)- Percent encoding (Wikipedia)
EventTarget(MDN)hiddenattribute (MDN)
Hints & spoilers
HINT: Converting hashes containing JSON
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
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
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
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...