JavaScript: how to create a basic SPA (Single Page App) with the History API

JavaScript: how to create a basic SPA (Single Page App) with the History API

In this article we're going to create a basic SPA (Single Page App) by using the JavaScript History API.

In this article we're going to create a basic SPA (Single Page App) by using the JavaScript History API.

This API allows us to add new entries in the browser's history and manage the state transitions with JavaScript events. Imagine this scenario: you want to navigate from your home page (represented by the path /) to another page (e.g. /posts/9) without performing a new GET request. Here the History API comes into play by pushing a new entry in the browser's history object so that you will see the new URL in the browser's address bar but you're still on the main page of your application (e.g. index.html).

Before continuing with our implementation, you need to know that in order to get it working on the server, you have to redirect all the GET requests to the main HTML file, excluding static assets from the redirect rule. Otherwise, when you reload a path created with the History API, you'll get a 404 HTTP error because in this specific case you're actually performing a GET request to the server.

Let's now start with our implementation. We want to display a list of blog posts and also the single post details on a separate page. The first thing to do is to create a function that makes an AJAX request to a remote endpoint, fetches a JSON array containing our posts and then saves this array in the web storage so that we don't need to make new requests on every page refresh.

const getLatestPosts = () => {
        const posts = localStorage.getItem(getSessionKey());
        if (posts === null) {
            fetch('https://api.tld/v1/posts.json')
                .then(response => response.json())
                .then(posts => {
                    savePosts(posts);
                    insertPosts();
                });
        } else {
            insertPosts();
        }
    };

The getSessionKey() creates a unique key in the localStorage and is implemented as follows:

const getSessionKey = (prefix = 'posts') => {
        const hostHash = window.btoa(window.location.host);
        return `${prefix}-${hostHash}`;
};

This function turns the host name into a Base64 encoded string and returns a string prefixed with the parameter we pass to it.

savePosts(), as its name suggests, saves our posts array into the web storage.

const savePosts = posts => {
    return localStorage.setItem(getSessionKey(), JSON.stringify(posts));
};

insertPosts() loops through the JSON array and creates an HTML string containing the list of our posts.

const insertPosts = () => {
        const postsData = localStorage.getItem(getSessionKey());
        if (postsData === null) {
            return;
        }
        const posts = JSON.parse(postsData);
        const container = document.querySelector('.app-content');

        let html = '<div class="posts">';

        for(const post of posts) {
           // ... Adds content to the string
        }
        html += '</div>';
        container.innerHTML = html;
}        

Now let's take a look at the HTML structure of our main file:

<main class="app">
    <section class="container">
        <header class="app-header">
            <h1 class="app-title">Latest posts</h1>
        </header>
        <section class="app-content"></section>
    </section>
</main>

.app-content is the element where we want to insert our main contents whene we navigate from the home page to the single post page and vice-versa. Since we have the .app-header element in the initial DOM structure, we need to hide it when we are on the details page.

At this point, we have to add an event handler to all the links in our list that point to the details page. This handler will take care of pushing a new entry in the browser's history, fetching the single post from our array and displaying it on the page.

const getPost = url => {
        const postsData = localStorage.getItem(getSessionKey());
        if (postsData === null) {
            return;
        }
        const posts = JSON.parse(postsData);
        const postid = url.split('/')[1];
        const post = posts.find(post => post.id === postId);
        return post ? post : null;
};

const displayPost = post => {
        document.title = post.title;
        document.querySelector('.app-header').classList.add('hide');
        const container = document.querySelector('.app-content');
        container.innerHTML = `HTML content here`;
};

const navigateTo = url => {
        window.history.pushState(null, null, url);
        const post = getPost(url);
        if (post !== null) {
            displayPost(post);
        }
};

const handleNavigation = event => {
        event.preventDefault();
        const url = event.target.getAttribute('href');
        navigateTo(url);
    };

    const handlePostLinks = () => {
        document.addEventListener('click', event => {
            if (event.target.matches('.read-more')) {
                handleNavigation(event);
            }
        }, false);
    };

When a user clicks on a link with class read-more, we prevent browsers from performing a new GET request to the server by using the preventDefault() method of the event object. Instead, we push a new entry in the browser's history by calling the pushState() method with the path obtained from the href attribute of each link.

Each path is in the format /p/:id, so in order to display a single post we need to extract the post ID from the path and use it with the find() method in order to get the related post. Our navigation handler also changes dynamically the title element of the page and hides the header when we're viewing a single post.

Now it's time to handle the browser's Back button:

const getBasePostURL = url => {
        return url.replace(`${window.location.protocol}//${window.location.host}`, '');
    };

    const togglePageDisplay = () => {
        const url = getBasePostURL(window.location.href);
        const post = getPost(url);
        if (post !== null) {
            displayPost(post);
        } else {
            document.querySelector('.app-header').classList.remove('hide');
            insertPosts();
        }
    };

    const handleHistoryNavigation = () => {
        window.addEventListener('popstate', () => {
            togglePageDisplay();
        }, false);
    };

We're using the popstate event of the History API to handle the Back button. By getting the current URL from the location object, we can check whether there's a single post to display or our list of posts.

The last thing to do is to handle the page reload on the single post by adding our latest function to the list of actions to be performed when the DOM is loaded.

document.addEventListener('DOMContentLoaded', () => {
        handleHistoryNavigation();
        handlePostLinks();
      getLatestPosts();
      togglePageDisplay();
    }, false);

Demo

JavaScript: History App