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.
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.
push()
method on arrays?The syntax of pushState()
is shown as follows:
history.pushState(state, title[, URL]);
state
represents information binded with this new entry (added by callingpushState()
). We'll see how to usestate
as we consider multiple examples of handlingpopstate
.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.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.
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]);
state
represents information binded with this replacing entry.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.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.
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,
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.
popstate
doesn't fire when pushState()
or replaceState()
is called.The definition above is OK, but not simple enough to right away 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:
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.
- 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.
- 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:
- 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. - 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:
- The
onpopstate
handler property - 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.
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>';
}
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.
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.
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:
- The
state
argument topushState()
is the object{x: 1}
instead of the valuenull
, so that we could interact with it later on whenpopstate
fires. - Inside the handler
onpopstate
, instead of displaying'popstate fired.'
, we display thestate
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>';
}
Once we press the button here, two occasions are of particular importance:
- When we go back from the pushed entry to the initial entry.
- 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.
==
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, right away.
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}, '');
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...
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...
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';
}
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:
Browser | Supporting versions |
---|---|
Microsoft IE | 10 - 11 |
Microsoft Edge | 12 - 90 |
Google Chrome | 5 - 93 |
Opera | 11.5 - 75 |
Safari | 6 - 14.5 |
Firefox | 4 - 90 |
This data is taken from CanIUse.com. The complete table can be found at https://caniuse.com/mdn-api_window_popstate_event.