Virtual URL navigation using vanilla JavaScript
Tags: tutorial, javascript, webdev
Published: 2022-10-08 16:00:23

I came upon an issue on a GitHub project where the maintainer wanted to dynamically change the content of the page and give the feeling of it being a "native" page, without actually creating another HTML file.

He also wanted his website to only use vanilla JS and HTML files so introducing Angular, Svelte or any other framework to him was not an option.

Fortunately, I discovered an interesting API introduced in HTML5 that can solve exactly this kind of issue.

Let's explore it step by step.

## Setup

For our example, we will build a very simple page that can give the feeling of using some kind of routing.

Firstly, we will create our simple HTML page:

```html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>Fake URL navigation</title>
    <meta name="viewport" content="width=device-width, initial-scale=1" />
  </head>
  <body>
    <h1>Fake routing</h1>
    <button id="details">Show details</button>
  </body>
</html>
```

Which does not renders much for now.

## Changing the URL

We now want to change the URL whenever the user clicks on the button.

To do so, we can use the [pushState](https://developer.mozilla.org/en-US/docs/Web/API/History/pushState) function to programmatically add a new entry to the history, which will also result in the URL being modified.

Its usage is fairly simple and made of three parameters:

1. The state that will be pushed along the URL, which can be a light JS object or some kind of structured data
1. An unused parameter, mandatory due to backward compatibility, that we can replace by an empty string
1. The actual URL we want to push

Let's add a small script to change this:

```html
<script>
  document.getElementById("details").onclick = (_event) => history.pushState({}, "", "/details");
</script>
```

And test it:

![URL update](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/y0q1nfq3ttp97kdi38j0.gif)

Wonderful! We may now focus on the content

## Updating the page

In order to update our content, we will need some kind of template and to substitute the current HTML body by it.

Using the native API, this is pretty straightforward:

```diff
<script>
+ const template = "<h1>Details</h1> <p>Some highly useful information</p>";

  document.getElementById("details").onclick = (_event) => {
    history.pushState({}, "", "/details");
+   document.body.innerHTML = template;
  };
</script>
```

And we now have our virtual page!

![Replace content](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/slmevspn6fw90ogy6ajr.gif)

## Fixing the back button

With our current solution, once the details page has been shown, it is not possible to display the index again without having to reload the whole page.

To fix this, we can start by creating a local history by pushing the content of the body element into an stack before removing it:

```diff
<script>
  const template = "<h1>Details</h1> <p>Some highly useful information</p>";

+ const bodyHistory = [];

  document.getElementById("details").onclick = (_event) => {
    history.pushState({}, "", "/details");

+   bodyHistory.push(document.body.innerHTML);
    document.body.innerHTML = template;
  };
</script>
```

Then, we can use the event that is kind of the mirror of `pushState`: [`popstate`](https://developer.mozilla.org/en-US/docs/Web/API/Window/popstate_event).

By listening to it, we will know whenever the back button has been pressed and we will be able to restore the previous content of the page:

```diff
<script>
  const template = "<h1>Details</h1> <p>Some highly useful information</p>";

  const bodyHistory = [];

  document.getElementById("details").onclick = (_event) => {
    history.pushState({}, "", "/details");

    bodyHistory.push(document.body.innerHTML);
    document.body.innerHTML = template;
  };

+ onpopstate = (_event) => {
+   const previousContent = bodyHistory.pop();
+
+   if (previousContent) {
+     document.body.innerHTML = previousContent;
+   }
+ };
</script>
```

At this point going back in the history would work but we won't be able to navigate again because we restored the whole page but the event listener would not have been set again.

A quick way to fix it would be to re-register it inside our `onpopstate` event handler in the same way we did when we first add this logic.

Let's extract this logic into a dedicated function and call it:

```diff
<script>
  const template = "<h1>Details</h1> <p>Some highly useful information</p>";

  const bodyHistory = [];

+ registerHandler();

+ function registerHandler() {
+   document.getElementById("details").onclick = (_event) => {
+     history.pushState({}, "", "/details");
+
+     bodyHistory.push(document.body.innerHTML);
+     document.body.innerHTML = template;
+   };
+ };

  onpopstate = (_event) => {
    const previousContent = bodyHistory.pop();

    if (previousContent) {
      document.body.innerHTML = previousContent;
+     registerHandler();
    }
  };
</script>
```

And we are done!

![With back](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/a67b37pzc1xgb2kn9t58.gif)

---

In this small article we saw how to take advantage of the couple `pushState`/`popState` to give the user the feeling of browsing a different page.

This is a light introduction to some kind of virtual navigation but, in my case, it helped me to successfully implement it into this maintainer's project.

However, please note that reloading the page when the user is on our URL that does not match an actual route will result in an HTTP 404 error.

One solution would be to replace the navigation to our virtual pages by hashes (like `...#details` instead of `.../details`). This way, the browser would still render our root page on reload and we would be able to intercept whatever is after the hash symbol to render our details page instead.

I hope that you learned something useful there!

---

Entire sources used for the demo:

```html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>Fake URL navigation</title>
    <meta name="viewport" content="width=device-width, initial-scale=1" />
  </head>
  <body>
    <h1>Fake routing</h1>
    <button id="details">Show details</button>
  </body>

  <script>
    const template = "<h1>Details</h1> <p>Some highly useful information</p>";

    const bodyHistory = [];

    registerHandler();

    function registerHandler() {
      document.getElementById("details").onclick = (_event) => {
        history.pushState({}, "", "/details");

        bodyHistory.push(document.body.innerHTML);
        document.body.innerHTML = template;
      };
    }

    onpopstate = (_event) => {
      const previousContent = bodyHistory.pop();

      if (previousContent) {
        document.body.innerHTML = previousContent;
        registerHandler();
      }
    };
  </script>
</html>
```