Introduction

Since the advent of AJAX, the web has changed a lot. Gone are the days when browsers had to request for new resources with a new page load. Today, using AJAX, browsers can dynamically replace the content of a page with the content of another page.

But AJAX doesn't even stand the chance to do all of this alone. It works in a team with other technologies which then makes countless things possible on the web. Members of this team are HTML DOM methods, CSSOM properties; and let's not forget the History API.

The History API with its two methods pushState() and replaceState(), coupled with the event popstate, has over the ages proven to be so much useful that big names out there are using it to power navigations in their web applications today.

Let's understand how to work with History and popstate.

The History API

Before popstate could be understood, it's necessary to first get a good hang of the History API — or more specifically, two methods of the API: pushState() and replaceState().

The methods pushState() and replaceState() both serve to modify the current session's history entries in some way or the other, traversing through which, later on, dispatches the popstate event.

Did this sound confusing?

Well, it was written to be confusing. Let's ignore it for now and unravel the secrets behind pushState() and replaceState() from the very beginning.

They represent extremely easy concepts and should likewise be extremely easy to understand.

Let's begin...

pushState() - add an entry

First, we consider the pushState() method of the history object.

The pushState() method adds a new entry in the current session's history stack i.e. the collection of all the pages visited in the current browser tab.

This is the simplest of all definitions.

The method is called 'push state' because it pushes a new element onto the history stack. In the world of programming, 'push' is more familiar as compared to 'add'. Recall push() method on arrays?

The syntax of pushState() is shown as follows:

history.pushState(state, title[, URL]);
  1. state represents information binded with this new entry (added by calling pushState()). We'll see how to use state as we consider multiple examples of handling popstate.
  2. title represents the title of the new history entry. However, note that almost all web browsers except for Safari don't use this for any purpose at all — it's simply ignored.
  3. URL represents the URL of this new entry, to be displayed in the browser's address bar. If omitted, it defaults to the current URL. By far, this is the most useful parameter of all three. It is what enables to emulate an old-style navigation on the browser by changing the URL and the content on the page. We'll see what this means later on.

One extremely important thing to know is that pushState() changes the URL without ever checking whether it even actually exists or not. This is because the purpose of pushState() is not to load a webpage, but rather to just add a new entry to the history.

That's it!

Let's consider an extremely simple example.

We create a <button> element and assign it a click handler. Inside the handler, we call pushState(). This adds a new entry to the current tab with a different URL than the one of the current page.

<button>Call pushState()</button>
var button = document.querySelector('button');

button.onclick = function() {
    history.pushState(null, '', 'some-page');
}

Once the new entry is pushed (by calling pushState()), we can press the Back button on the browser to go back to our original entry.

Live Example

Perfect! Hopefully, this was easy!

Let's review it once more:

history.pushState() add a new history entry to the current session and takes us to this new entry. Going back from this point takes us to our original entry.

replaceState() - replace the current entry

In addition to pushState(), the History API gives another similar-looking method — replaceState().

replaceState(), as the name suggests, doesn't add a new entry but rather replaces one.

replaceState() method replaces the current entry in the current session's history stack with a new entry.

The length of the history stack (i.e. all the entries in the session's history) doesn't change after calling replaceState() since it doesn't add a new entry onto the history stack.

Syntactically, replaceState() is identical to pushState():

history.replaceState(state, title[, URL]);
  1. state represents information binded with this replacing entry.
  2. title represents the title of the replacing entry. However, as before, almost all web browsers except for Safari don't use this for any purpose at all — it's simply ignored.
  3. URL represents the URL of this replacing entry, to be displayed in the browser address bar. If omitted, it defaults to the current URL.

All the three parameters here work exactly the same way they do with pushState().

Let's consider a quick example demonstrating replaceState().

As before, we have a button with a click handler set. But this time, inside the handler, we call replaceState() to replace current history entry with a new one.

<button>Call replaceState()</button>
var button = document.querySelector('button');

button.onclick = function() {
    history.replaceState(null, '', 'some-page');
}

Once the current entry is replaced, we can press the Back button on the browser to go to the page we were on before that entry.

Live Example

Previously, with pushState(), pressing the back button took us to the initial document we were on. This is because pushState() adds a new entry and then takes us to it. However, with replaceState(), pressing the Back button takes us one step further back to the entry before the initial document we began with.

The following illustration explains this visually:

In the case of using replaceState(), entry2 gets ignored completely. This is because it is replaced by entry3 when the method is called.

And this completes the explanation of replaceState(). Amazing!

With both these methods clear, it's finally time to understand the popstate event.

The popstate event

Precisely describing the exact moment when popstate fires is not as straightforward as it might seem. But, equally, it's not as difficult as it might seem, as well.

Stating it formally,

The popstate event fires whenever the browser is navigated to a page with the same document object as the current page.

Note that 'navigated' here refers to going to a new location, not previously visited in the current session, or traversing to some previously-visited entry in the history stack.

One thing developers often tend to assume is that popstate fires whenever history.pushState() or history.replaceState() is called.

This is NOT the case!

Both these methods, when called, don't constitute a navigation, or history traversal, likewise popstate never fires after they are called.

As stated above, popstate only fires, when we navigate to a new page or traverse up or down the history stack — not in any other case.

Stating is once more: popstate doesn't fire when pushState() or replaceState() is called.

The definition above is OK, but not simple enough to rightaway explain to you when to be sure that popstate would fire.

Can we make it any simpler?

Well, definitely yes!

The next definition that we're about to give would hopefully clear away all the confusions you might have at this point regarding the dispatch of popstate.

Let's get to it:

The popstate event fires when we navigate to a page without a page reload.

'Without a page reload' simply means that the browser doesn't show any sort of loading icon (for example, right next to the title in the top part of the browser) while the page is in the process of being loaded.

Can you think of any case when a navigation is made, but the page isn't reloaded?

Well, the simplest case is when we visit a link with a hash in its href attribute, like the one shown below:

Visiting this link adds a new entry to the history stack of the current session and at the same time doesn't cause a page reload.

So coming back to the definition, there are two things very important for you to understand in it.

  1. The first is 'navigation' — it's crucial for the new entry to be brought about by a navigation such as visiting a new link or traversing up or down the history stack.
  2. The second is 'no page reload' — if you visit a link and the browser performs a reload, then forget about popstate — it won't fire.

If both these prerequisites are met, we could be sure that popstate would fire.

Let's see whether you could figure out whether the event gets dispatched in a couple of different scenarios.

Suppose we are on www.example.com/home, and from here we navigate to www.example2.com/.

Would popstate fire at the end of this navigation on the webpage www.example2.com/?

The document www.example.com/home from where we navigate, also called the current entry, is of a different origin as compared to the document www.example2.com where we navigate to, also called the new entry. Specifically, they have a different domain.

Hence, both the document could never ever have the same document object.

This clearly rules out the possibility of popstate firing. The answer is a clear cut no — popstate won't fire at the end of this navigation.

Suppose we are on www.example.com/home, and from here we navigate to www.example.com/home#s1.

Would popstate fire at the end of this navigation?

When we navigate to www.example.com/home#s1, the webpage isn't reloaded. The document of this new entry is the same document belonging to www.example.com/home.

Since the document of both these locations is the same, and we made a navigation, popstate gets fired. The answer is a clear cut yes.

Suppose we are on www.example.com/home, and execute the following script:

history.pushState(null, '', 'some-page')

Would popstate fire after executing this?

Calling the pushState() statement above will add a new history entry and change the URL in the browser's address bar to www.example.com/some-page. The document of this new entry is indeed the same document as of www.example.com/home.

However, as stated before, pushState() does NOT perform a navigation. It just changes the URL. That's it.

Hence, popstate won't fire in this case.

Suppose we are on www.example.com/home#s1, and from here we navigate to www.example.com/home.

Would popstate fire at the end of this navigation?

When we navigate to www.example.com/home, a browser reload is done. This means that the document of the new entry www.example.com/home doesn't remain the same as that of the initial entry www.example.com/home#s1; likewise popstate won't fire.

The answer is a clear-cut no.

Let's sum up this long discussion.

The popstate event fires when:

  1. The document of the new entry is the same as that of the current entry. With a page reload, this can't ever be the case.
  2. A navigation or history traversal is done.

So now that we know when exactly does the event fire, it's time to handle it for real and consider some practical examples of its usage.

Handling popstate

Akin to the hashchange event, the popstate event only fires on one target and that is window — apart from window, the event fires on no other object.

As with almost all events in JavaScript, to handle popstate, we could either use:

  1. The onpopstate handler property
  2. The addEventListener() method passing it 'popstate' as the first arg

Both these are demonstrated below in their general forms:

Using the property:

window.onpopstate = function() {
    // handle the event
}

Using the addEventListener() method:

window.addEventListener('popstate', function() {
    // handle the event
});

At this point, we are well aware of the fact about as to when is popstate fired. Following from that knowledge, below we'll consider a few examples where we know popstate would fire in some way or other.

It would otherwise become very lengthy if we were to consider even those cases where we would've known for sure that popstate won't fire.

A hash link

To start with, let's consider a document where we have a link with href="#s1" set and the popstate event being handled.

<a href="#s1">Change the hash</a>

<pre id="messages"></pre>
var messagesElem = document.querySelector('#messages');

window.onpopstate = function() {
    messagesElem.innerHTML += '<div>popstate fired.</div>';
}

In the handler, we display a message out on the document, instead of in the console. This is done so that if you're on a mobile device, even then you could get a hang of when popstate fires.

The action begins as soon as we click the link on the following page.

Live Example

Can you describe what happens one-by-one as soon as the link above is clicked?

Firstly, a new history entry is created. Then the URL in the address bar is changed to include the hash. Next, the popstate event is dispatched. Since the event has a corresponding handler set, it is executed and ultimately a message is displayed in the #messages element.

If we go back from the visited hash entry above, popstate would fire again. This is because while going back here, the browser doesn't perform a reload, and additionally this 'going back' action is a history traversal. Since both the prerequisites of popstate are met, the event gets fired.

This completes the explanation for this example. Did you find it easy?

Working with pushState()

Let's move onto our second example — using pushState().

As before, we have a document where the popstate event is being handled. But this time, instead of having a link, we have a button which, when clicked, executes pushState().

<button>Call pushState()</button>

<pre id="messages"></pre>
var buttonElem = document.querySelector('button'),
     messagesElem = document.querySelector('#messages');

buttonElem.onclick = function() {
    history.pushState(null, '', 'some-page');
}

window.onpopstate = function() {
    messagesElem.innerHTML += '<div>popstate fired.</div>';
}

Live Example

Recall that popstate won't fire when we press the button over here. This is because calling pushState() doesn't perform a navigation. However, after we've pressed the button and a new history entry is added, if we now go back to the previous entry, popstate would fire.

Why?

Because while going to the previous entry, the browser doesn't perform a reload, and what's done is a history traversal. In other words, both the conditions for the dispatch of popstate are met, and so very obviously, it gets dispatched.

Here's an interesting question for you?

In the example above, when we press the button, popstate doesn't fire. However, when we go back to the original entry by pressing the Back button on the browser, popstate fires.

From this point on, if we go forward to the pushed entry using the Forward button on the browser, would popstate fire?

A big yes!

When we go forward from our original entry to the pushed entry, a browser reload isn't made, and most importantly what's done is a history traversal.

Both of these are conditions of popstate's dispatch, and since they are met, the event would fire for sure.

The state property

Uptil now we've been seeing the first state parameter of the methods pushState() and replaceState(), without any particular point of interest.

In this section, we shall see how to use it to our benefit.

Let's start with a bit of theory.

The state parameter essentially represents information related to the entry being set (via pushState() or replaceState()).

Whatever is passed to state is stored as part of the respective entry.

When popstate fires an entry for which a state was given to the method producing that entry (pushState() or replaceState()), the event's argument object has its state property set to that value.

It's important to distinguish the state parameter of pushState() and replaceState() from the state property of the popstate event's object. We distinguish both of them by using italics in the parameter's representation i.e. state.

We can retrieve the value of state inside the popstate event's handler and then do actions based on whatever it is equal to.

Let's see an example of how to use state.

The code snippet below is exactly the same as in the last example we saw above, except for two things:

  1. The state argument to pushState() is the object {x: 1} instead of the value null, so that we could interact with it later on when popstate fires.
  2. Inside the handler onpopstate, instead of displaying 'popstate fired.', we display the state property of the event object. JSON.stringify() is used to convert the object into a readable format — without it, the object would be displayed something like [object Object] which would be useless.

Here's the code:

<button>Call pushState()</button>

<pre id="messages"></pre>
var buttonElem = document.querySelector('button'),
     messagesElem = document.querySelector('#messages');

buttonElem.onclick = function() {
history.pushState({x: 1}, '', 'some-page'); } window.onpopstate = function(e) {
messagesElem.innerHTML += '<div>' + JSON.stringify(e.state) + '</div>'; }

Live Example

Once we press the button here, two occasions are of particular importance:

  1. When we go back from the pushed entry to the initial entry.
  2. When we go forward from the initial entry to the pushed entry.

Let's see what would be displayed in either of these...

When we go back to our initial entry (after pushState() has been executed and the URL has been changed), popstate gets fired and the state property of the event object becomes null. This is the default value for every single document, so there's nothing really interesting in it.

From this point on, if we go forward to the pushed entry, popstate would fire again, but this time the state property of the event object would be {x: 1}, since the state of this pushed entry was configured to be {x: 1} at the time of executing pushState().

Simple!

Remember one thing: whenever navigation to an entry x is made, the state property of the event's object would be the same object set for that entry x.

This means that if, for example, you call pushState(), and set its state parameter to {x: 1}, and then go back to the previous entry, the dispatched popstate event's state property won't hold the object {x: 1}.

This is because when you go back, the entry to which you're taken is not the one that was set using pushState(). But from this point, if you go forward to this pushed entry, popstate would fire and its state property would be set to {x: 1}, since the state was set for this specific entry.

'Same' here means that the object would have the same properties. It doesn't mean that both the objects are identical to one another i.e. comparing them both using the == or even using === would return false.

Serialization of state

The pushState() or replaceState() method, when executed, serializes the first argument state and then stores it as part of the respective entry being set.

Then later on, when this entry is brought in and popstate fires, this serialized value is deserialized and assigned to the state property of the event's object.

But what is meant by serialization/deserialization?

Storing an object in memory requires it to be converted into a storable format such as a sequence of bytes. This is called serialization. The reverse process of converting the data in memory into a representable format is called deserialization.

At this point, you might wonder as to why is this serialization/deserialization required — why can't JavaScript store the given object as is, rightaway.

Serialization is necessary because state has to be stored in memory outside the JavaScript engine, and then later on retrieved back when needed. It isn't stored as part of the data model set by JavaScript — rather it's stored as part of the browser.

Likewise, the state data passed to pushState() or replaceState(), is first serialized and then stored as part of its corresponding entry.

Alright, so as we saw above, we could configure state for an entry added to the history stack using pushState() by specifying a value as its first argument.

But how to configure the state for an entry not added using pushState()?

For instance, suppose you visit a page X and then execute pushState() there to go to page Y. After going to page Y, you go back to page X and consequently popstate gets dispatched. The state property of the event object is null, which is the default.

How could have you changed this value?

Well, we could use replaceState() to replace any given entry with a new entry meanwhile also configuring its corresponding state.

An example follows.

Not surprisingly, we have sticked to our old code snippet. The only change here is that when the document is loaded initially, we call replaceState() in order to configure the state of the current entry.

<button>Call pushState()</button>

<pre id="messages"></pre>
var buttonElem = document.querySelector('button'),
     messagesElem = document.querySelector('#messages');

buttonElem.onclick = function() {
     history.pushState({x: 1}, '', 'some-page');
}

window.onpopstate = function(e) {
     messagesElem.innerHTML += '<div>' + JSON.stringify(e.state) + '</div>';
}

history.replaceState({y: 1}, '');

Live Example

Things to note

While working with pushState() and popstate, it's important to be aware of a couple of things.

popstate fires before hashchange

As we stated above, the popstate event fires when we navigate to a hash (in the current URL). Now recall from the previous chapter, this action also constitutes the hashchange event.

The question is: which event fires first?

Well, popstate gets dispatched before hashchange.

This can be confirmed by the code below:

<a href="#s1">Change the hash</a>
window.onhashchange = function() {
     alert('hashchange fired.');
}

window.onpopstate = function(e) {
     alert('popstate fired.');
}

The link has its href set to '#s1', so that when we click it, the URL in the address bar changes to include this hash. Both the handlers onpopstate and onhashchange make a given alert. The order of the alerts made will confirm the order of events.

Let's try this out...

Live Example

In the example above, once the link has been clicked, and popstate and hashchange been fired, subsequent clicks of the link won't fire hashchange. Can you think why?

This is because, on subsequent clicks of the link, the hash doesn't change — it remains the same #s1. And if the hash doesn't change, hashchange won't obviously fire.

popstate fires after the URL has been updated

A useful thing to know regarding the dispatch of popstate is that it fires after the URL in the address bar gets updated.

This means that if, for example, we are on www.example.com/ and then navigate to www.example.com/#menu, the event would fire after the URL has been updated to www.example.com/#menu.

How can this be any useful?

In an AJAX-driven application, we could configure each <a> element to dynamically update the content of the current page with the page pointed by its href attribute, and then execute pushState() to additionally update the URL as well.

Now going back from this pushed entry, the popstate event would get dispatched after which we could retrieve the URL (by accessing window.location) and then dynamically update the content of the current page with that of this URL, again.

This is a bit advanced workflow as you might agree — after all, popstate and history.pushState() are used to power complex navigations in modern-day applications.

Anyways, let's confirm this idea with the help of an example.

<a href="#s1">Change the hash</a>
window.onpopstate = function(e) {
     alert(window.location.href);
}

As before, we have a link with href='#s1'. Inside popstate's handler, we display the current URL of the address bar using location.href, by means of an alert.

Let's try it out...

Live Example

Changing hash using pushState() doesn't fire hashchange

You might think that the following statement would fire hashchange:

history.pushState(null, '', '#s1');

After all, what's changed in the previous and new URL is the hash; hence, hashchange would occur to indicate this change.

This is NOT not the case.

Recall from the discussion above, pushState() doensn't perform a navigation — it merely adds a new entry, updates some state data, and updates the URL; that's it.

Since a navigation isn't performed, hashchange won't ever fire, as it only comes in the algorithm for a browser navigation or history traversal.

Let's try this for real...

In the example below, if a hashchange event fires, the background color of the document would become yellow, otherwise nothing would be changed.

<button>Call pushState()</button>
var button = document.querySelector('button');

button.onclick = function() {
     history.pushState(null, '', '#s1');
}

window.onhashchange = function(e) {
     document.body.style.backgroundColor = 'yellow';
}

Live Example

If you tried executing this example, you'd know that nothing happens as you press the button to execute pushState().

What this confirms is that hashchange doesn't fire upon executing pushState().

Browser support

The popstate event has a relatively decent browser support. The table below illustrates this:

BrowserSupporting versions
Microsoft IE10 - 11
Microsoft Edge12 - 90
Google Chrome5 - 93
Opera11.5 - 75
Safari6 - 14.5
Firefox4 - 90

This data is taken from CanIUse.com. The complete table can be found at https://caniuse.com/mdn-api_window_popstate_event.