Single-page application routing in vanilla JavaScript using the power of the History API

George Norberg
6 min readFeb 2, 2018

--

While building a single-page progressive web app, I ran into the History API — a powerful set of methods for page navigation and routing in pure vanilla JavaScript.

If you like building apps without heavy frameworks or routing libraries, this one’s for you.

I had to fiddle with the History API for a while before “getting it”, so hopefully this article will help you understand it faster so you can get back to building sooner.

The History API… or how I learned to stop worrying and love the back button

The main reason I went down this rabbit hole is because users love the browser’s back button.

It even won an award as the most pressed button in Firefox back in the day: http://www.internetnews.com/skerner/2010/07/what-is-the-most-clicked-firef.html

I’m not sure if it still ranks #1, but it’s telling that the back button takes the highly coveted top-left-most real estate on all modern browsers.

So your app should definitely 100% expect that users will try to use the back button everywhere in your app to return to previous views.

View as a function of state

This is where the History API comes in. The History API assumes that your view is a function of state — that is, what is displayed on screen depends on a global state object. This allows the browser to store all your states so that when a user presses the back button, they return to the previous state as expected.

So to use the History API, you must have a global state of some kind that your views depend on. It doesn’t matter if you mutate state (as expected in an object-oriented paradigm) or use immutable state (as expected in a functional programming paradigm).

If you have state that looks like this:

let state = { displayTrophy: false };

Your render function should see that it should not display a trophy, and there should not be a trophy visible on screen. Then if your user does something that rewards a trophy, your state should change to this:

let state = { displayTrophy: true };

Observing a state change, your render function now sees that it should display a trophy, and it does just that — it renders a trophy on the screen for the user to see.

It’s important that you don’t just render things willy-nilly that aren’t tied to state. Otherwise, these changes aren’t captured in the state object that the browser is storing.

The flow looks like this:

user event -> update state -> re-render

Every action should trigger a state change, which triggers a re-render. For those familiar with React, it’s very similar to React’s one-way data flow where a state change triggers a re-render, except React has fancy diffing algorithms that prevent the whole application from being re-rendered.

This means no matter which state object the browser delivers to you, you’ll be able to properly render the view that the user expects.

If that’s not crystal clear yet, don’t worry. We’re about to dig into the mechanics which should elucidate how the whole operation works.

Initial State

To use the browser’s history functionality, you begin by telling the browser to remember your initial state. This requires the replaceState method like so:

window.history.replaceState(state, null, "");

You’ll notice it takes 3 parameters. The first is obviously our state object. What about the other 2? The second is a title that may be used by browsers in the future, so we just set it to null for now. The third is important though — it will become the URL path.

What is the URL path? Essentially it’s all the stuff that comes at the end of the URL. So if we append “trophy” to our base URL https://example.com, then the URL that the user sees in their browser’s address bar would immediately become https://example.com/trophy.

In my application, I intentionally left the path empty. This means that no matter what the user clicks on, they will see the original URL. I did this so that if they refresh, bookmark or share the URL then the application will still load at its entry point.

If you want to have multiple entry points, then you need to customize your server to handle those routes. Otherwise, the user may bookmark a “bad” URL that the server does not understand. For example, if I build a URL like https://example.com/not-on-server and the user tries to load that URL, my server may have no idea what to do with that path. We would need to change our server-side code to handle all possible paths. The minimum logic would be to simply redirect to the original URL, so we achieve the same effect with less code by having no path at all.

If you decide to implement custom paths for your application, be sure to update your server code to handle those paths.

Future State

So you’ve initialized your state. What’s next?

Whenever the user does something that updates state, such as clicking a checkbox or navigating to a different view, you will need to update your state and then store it in the browser. After updating your state, call this:

window.history.pushState(state, null, "");

Well that looks awfully familiar, doesn’t it! It’s identical to our last use of the History API except our method is now pushState as opposed to replaceState.

Note that we push the NEW state that the user has triggered, since the OLD state was already stored.

With every push, the browser gathers more and more states that it can cycle through if the user wants to start cycling back.

Behind the scenes, this uses a Stack data structure. The History API pushes states until it needs to pop them. The pushState method pushes, and the back button pops.

Popping States

It’s nice and all that the browser can pop our states off the Stack one by one, but how do we connect that to our application?

Somewhere in our app, we need to set up an event listener that re-renders our app with the old state, meaning your render function updates some piece of the user interface based on these state changes:

window.onpopstate = function (event) {
if (event.state) {
state = event.state;
}
render(state); // See example render function in summary below
};

The browser’s back button triggers the onpopstate listener, which connects to the History API. Notice that the callback receives an event — this event has a property state that has your old state! Just set your application state to this old state and trigger a re-render to display the old state.

Now when the user presses the back button — voila! They see the old state that you most recently pushed.

That’s pretty much it!

Putting it all together

Let’s summarize by looking at all the code together with an example:

/* Start HTML */
<button>This text will be overwritten</button>
/* End HTML */
/* Start JavaScript */
const button = document.querySelector("button");
let state = {
buttonText: "Initial text"
};
// Change the look of your app based on state
function render() {
button.innerText = state.buttonText;
}
// Set initial state and render app for the first time
(function initialize() {
window.history.replaceState(state, null, "");
render(state);
})();
// Update state, history, and user interface
function handleButtonClick() {
state.buttonText = "I clicked the button!"
window.history.pushState(state, null, "");
render(state);
}
// Connect your button to the handler above to trigger on click
button.addEventListener("click", handleButtonClick);
// Tell your browser to give you old state and re-render on back
window.onpopstate = function (event) {
if (event.state) { state = event.state; }
render(state);
};
/* End JavaScript */

Before reading further, you can play with the above code at https://jsfiddle.net/ax9qLe7r/.

The initialize function is called immediately to store and render the initial state. Then, we set up a button that, upon being clicked, triggers the handleButtonClick function. This function mutates the state, leading to a new state being pushed and rendered. This can be repeated ad infinitum. Then, whenever the user presses the back button, the onpopstate callback triggers to revive the most recent previous state.

I recognize there are many ways of getting started with the History API, and this is just one example. Feel free to leave a comment below if you know a different pattern!

Bonus: Custom backing

I’ll leave you with a bonus tip. If you want to have your own back button somewhere in your application, this setup makes it really easy.

Let’s say you have an HTML button with class back-button that you want to behave the same as the browser’s back button. Here is how you would use the History API to do so:

const backButton = document.querySelector(".back-button");backButton.addEventListener("click", () => window.history.back());

You simply grab the button element and set up a listener. When your app’s back button is clicked, the callback is called and pops the history Stack. If you recall from earlier, this triggers your onpopstate listener, which stores and renders the previous state.

Note that we must manually call window.history.back inside the callback wrapper because of how this works. See this post for details: https://stackoverflow.com/questions/46879422/window-history-back-illegal-invocation

So now you know that the browser’s back button is just calling window.history.back, and you can too!

--

--

George Norberg

Senior engineering manager with 13 years of experience specialized in cultivating exceptional managers and self-operating full-stack development teams