Introduction
As we learned in my Brief History of Client-Side Routing, we have come a long way from the days of the hash-based router. Our router has been built leveraging the History API, and it has served us well. But, in order to use it, we have to make sure that we put our special Link
class on every single internal link. And, we had to create an entirely separate listener to make sure that popstate events were handled correctly.
Wouldn’t it be great if we could just put down a single event listener and let the browser do the work for us? Well, hooray! The Navigation API is here to do just that. It also smooths out a few additional rough edges that we didn’t encounter in our app. Let’s take a look at how it works.
Warning!
As of the time of this article’s writing, the Navigation API is currently only available in Google Chrome, Microsoft Edge, and Opera browsers. Here’s to hoping that it will be available in all browsers soon!
The navigation
Object
As we learned, all of these web API’s are available on the global window
object. The navigation API is no different, so we can call it by just typing navigation
as long as we know our code will only run on the browser. It has a few events, but the most important of all is the navigate
event. This event is fired whenever any navigation occurs. Let’s see how we can use it.
simple-router
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Navigation API demo 1</title>
</head>
<body>
<main></main>
<p>The external link works if you open this outside the iframe.</p>
<p id="count"></p>
</body>
</html>
<script>
const main = document.querySelector("main");
const count = document.querySelector("#count");
console.log(navigation.currentEntry);
navigation.addEventListener("navigate", (event) => {
console.log(event);
console.log(navigation.entries());
event.intercept({
handler() {
const url = new URL(event.destination.url);
Router(url.pathname);
count.innerHTML = `The history stack now has ${
navigation.entries().length
} entries`;
}
});
});
function Router(path) {
if (path === "/special") {
main.innerHTML = `<h1>This page is special 🎉</h1>
<a href="/boring">Make it boring</a>
<a href="https://developer.mozilla.org">External link #1</a>
`;
} else {
main.innerHTML = `<h1>This page is boring</h1>
<a href="/special">Make it special</a>
<a href="https://www.w3schools.com">External link #2</a>
`;
}
}
Router(location.pathname)
</script>
Check out this pin by Jesse Pence (@jesse-pence) on CodePen.
Around 20 lines of actual code and one event listener later, and we have a fully functional client-side router. It even handles popstate events! So, let’s explore how this works and see what else we need to think about.
The navigate
Event
As soon as the page loads, I log the currentEntry
property to the console. On each navigation, I log both the NavigateEvent
interface and the navigation.entries()
method. If you open the developer tools, you can take a look at them. Respectively, they represent where we are in the browser’s history, where we are trying to go, and all the pages that have been visited in the current session.
Using these three, we can intercept the navigation and do whatever we want. The key
property of each entry is what we can use to go to a specific page in the history. It’s a UUID that is generated by the browser for each instance of each page in the user’s history. The id
property is similar, but it’s a little more likely to change so usually it’s best to use the key
property for our purposes. The navigation.entries
method is our way of seeing the current state of the history stack. Using the key
property, we can go back to any page in this stack.
Finally, the NavigateEvent has a lot of properties, but the one you’ll be checking most often is destination
— specifically, destination.url
. This is the URL that we are trying to load into the browser. With just that and a few other important things, you have all the information you need to hand things off to an internal router. Usually, the best way to do this is with the intercept
method on the event. This method takes an object with a handler
property. This handler is the function that we can use to implement our custom routing logic.
Intercepting Navigation
As you can see, if you open the demo in another window and click the external links, they still work as the intercept method doesn’t prevent the default behavior on its own. Unlike the History API, the Navigation API is not completely dependent upon handicapping the capabilities of the browser. With the History API, things like form submissions and downloads are completely independent of the central routing logic, so you have to create separate listeners for those.
With the Navigation API, all relevant information is included in each NavigateEvent, and you can use this information to decide whether or not you want to intercept the navigation. And, the event.intercept
method allows you to simply augment the default behavior instead of completely replacing it— although you can do that too if you want to. Let’s see how this works.
preventDefault
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>preventDefault vs. intercept</title>
</head>
<body>
<form action="/search">
<input type="text" name="search" placeholder="GET search" />
<button type="submit">Search</button>
</form>
<form method="POST" action="/search">
<input type="text" name="search" placeholder="POST search" />
<button type="submit">Search</button>
</form>
<button id="mode-button"> Current mode: preventDefault</button>
<button id="redirect">Redirect Off</button>
<p id="url"></p>
<a href="https://www.google.com">Google</a>
<a href="/internal">Internal</a>
<h3 id="mainText">
The URL doesn't change when using event.preventDefault(). However, you
cannot leave the page because we are not doing any checks on our listener.
</h3>
<script defer>
const button = document.querySelector("#mode-button")
const urlDisplay = document.querySelector("#url")
const mainText = document.querySelector("#mainText")
const redirect = document.querySelector("#redirect")
if (!urlDisplay) throw new Error("No url display")
urlDisplay.textContent = "current URL: " + location.href
let mode = "preventDefault"
let redirectMode = "off"
redirect?.addEventListener("click", () => {
if (redirectMode === "off") {
redirectMode = "on"
redirect.textContent = "Redirect On"
if (
button?.textContent === "Current mode: neither (redirect off)" &&
mainText
) {
button.textContent = "Current mode: intercept (redirect on)"
mainText.innerHTML = `<h6>Ahh, that's better.</h6>`
}
} else {
redirectMode = "off"
redirect.textContent = "Redirect Off"
if (
button?.textContent === "Current mode: intercept (redirect on)" &&
mainText
) {
button.textContent = "Current mode: neither (redirect off)"
mainText.innerHTML = `<h6>This ain't quite right...</h6>`
}
}
})
button?.addEventListener("click", modeSwap)
window.navigation.addEventListener("navigate", navigationHandler)
function modeSwap() {
if (!button || !mainText) return console.log("no button or mainText")
if (mode === "preventDefault") {
mode = "intercept"
if (redirectMode === "off") {
button.textContent = "Current mode: neither (redirect off)"
mainText.innerHTML = `<h6>This ain't quite right...</h6>`
} else {
button.textContent = "Current mode: intercept"
mainText.innerHTML = `<h6>Okay, you can leave if you want to. But, I'll miss you.</h6>`
}
} else {
mode = "preventDefault"
button.textContent = "Current mode: preventDefault"
mainText.innerHTML = `<h1>I'll never let you leave!</h1>`
}
}
async function navigationHandler(event: any) {
if (!button || !urlDisplay || !mainText) return
const url = new URL(event.destination.url)
const path = url.pathname
const searchParams = url.searchParams
if (mode === "preventDefault") {
event.preventDefault()
const formData = event.formData
if (path === "/search" && searchParams.has("search")) {
const search = searchParams.get("search")
mainText.textContent = `You searched for ${search} with a GET request. That's so cool.`
return
}
if (formData) {
const search = formData.get("search")
mainText.textContent = `You searched for ${search} with a POST request. I love that about you.`
return
}
if (url.origin === location.origin) {
mainText.innerHTML = `<h1>Yeah, just stay here. It's nice here.</h1>`
return
} else {
mainText.innerHTML = `<h1>NO! You can't go to ${url.href}.</h1>
<small>I would miss you too much.</small>`
}
} else {
console.log("intercepting")
if (redirectMode === "off") {
return
}
console.log("handling")
event.intercept({
async handler() {
console.log(event)
if (path === "/internal") {
mainText.innerHTML = `<h1>Yeah, you can stay here... But, really... you can leave now.</h1>`
}
if (path === "/search" && searchParams.has("search")) {
const search = searchParams.get("search")
mainText.textContent = `You searched for ${search} with a GET request. How original.`
}
const formData = event.formData
const search = formData.get("search")
fetch("/search", {
method: "POST",
body: JSON.stringify({ search }),
})
mainText.textContent = `You searched for ${search} with a POST request to /search. Boooring. Just go to Google already.`
await window.navigation.navigate("/differentURL", {
history: "replace",
}).finished
},
})
}
setTimeout(() => {
urlDisplay.textContent = "current URL: " + location.href
})
}
</script>
</body>
</html>
Check out this pin by Jesse Pence (@jesse-pence) on CodePen.
There are a few other times when you might not want to intercept the navigation and just let the browser do its thing. Here’s an example function provided by the Chrome team for how you might want to check.
function shouldNotIntercept(navigationEvent) {
return (
// Cross-origin or cross-document
// So, we can't intercept it anyways.
!navigationEvent.canIntercept ||
// If this is just a hashChange,
// just let the browser handle scrolling to the content.
navigationEvent.hashChange ||
// If this is a download,
// let the browser perform the download.
navigationEvent.downloadRequest ||
// If this is a form submission,
// let that go to the server.
navigationEvent.formData
);
}
The canIntercept property is a boolean that tells us whether or not we’re even allowed to intercept the navigation. As you saw, any “cross-origin or cross-document” (a different website or completely replacing the current document) navigation cannot be intercepted. We check for this to avoid unnecessary errors. The others are just situations where the browser can usually handle things better than we can.
Navigation State
One feature of the History API that I elected not to use in my original implementation was the ability to set the state object. This is a way to store data inside of the browser’s history. While having an in-memory cache that persists between history entries seems like something too good to deny, there are a few reasons why I didn’t use it.
The history state is notoriously fickle, and even something as simple as a single hashChange event can cause it all to be lost. And, if you want events to be exactly like they were at one point in history, you have to be extremely careful about how you set the state. Usually, you end up storing your desired state in a separate variable or a web storage API anyways to protect it. In this next demo, we will explore some of these issues to see how the Navigation API improves upon them.
State Objects
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>State Objects</title>
</head>
<body>
<a id="top" href="#bottom">Bottom</a>
<button id="btn-1"> State 1, URL 1</button>
<button id="btn-3"> State 1, URL 2</button>
<button id="btn-2"> State 2, URL 1</button>
<button id="btn-4"> State 2, URL 2</button>
<p id="state">
With the state object, our app can store data that is not in the URL. This
is useful for storing data that is not relevant to the current page, but
is relevant to the user's experience. This can be anything from user
preferences to the state of a form.
</p>
<p id="url"></p>
<a id="bottom" href="#top">Top</a>
<button id="navigationMode"
>Toggle navigation API mode - Currently Off</button
>
<button id="markStateAsFavorite"> Mark current state as favorite</button>
<button id="goToFavoriteState">Go to favorite state</button>
<button id="back">History Back</button>
<button id="forward">History Forward</button>
<div id="historyEntries"></div>
</body>
</html>
<script>
const button1 = document.querySelector("#btn-1")
const button2 = document.querySelector("#btn-2")
const button3 = document.querySelector("#btn-3")
const button4 = document.querySelector("#btn-4")
const navigationModeButton = document.querySelector("#navigationMode")
const markFavoriteStateButton = document.querySelector("#markStateAsFavorite")
const goToFavoriteStateButton = document.querySelector("#goToFavoriteState")
const back = document.querySelector("#back")
const forward = document.querySelector("#forward")
const historyEntriesDiv = document.querySelector("#historyEntries")
const stateDisplay = document.querySelector("#state")
const urlDisplay = document.querySelector("#url")
const top = document.querySelector("#top")
const bottom = document.querySelector("#bottom")
const state1 = {
cheese: "cheddar",
zombies: "scary",
gardening: "a rewarding experience",
}
const state2 = {
cheese: "gouda",
zombies: "kinda silly",
gardening: "pretty dang boring",
}
let navigationMode = "off"
let favoriteState = {
cheese: "brie",
zombies: "not real",
gardening: "foolin' around in the dirt",
}
urlDisplay!.textContent = "current URL: " + location.href
top?.addEventListener("click", () => {
setTimeout(() => {
updateUI()
}, 10)
})
bottom?.addEventListener("click", () => {
setTimeout(() => {
updateUI()
}, 10)
})
button1?.addEventListener("click", () => {
if (navigationMode === "on") {
// @ts-ignore
navigation.navigate("/ONE", { state: state1 })
} else {
history.pushState(state1, "", "/ONE")
updateUI()
}
})
button2?.addEventListener("click", () => {
if (navigationMode === "on") {
// @ts-ignore
navigation.navigate("/ONE", { state: state2 })
} else {
history.pushState(state2, "", "/ONE")
updateUI()
}
})
button3?.addEventListener("click", () => {
if (navigationMode === "on") {
// @ts-ignore
navigation.navigate("/TWO", { state: state1 })
} else {
history.pushState(state1, "", "/TWO")
updateUI()
}
})
button4?.addEventListener("click", () => {
if (navigationMode === "on") {
// @ts-ignore
navigation.navigate("/TWO", { state: state2 })
} else {
history.pushState(state2, "", "/TWO")
updateUI()
}
})
back?.addEventListener("click", () => {
if (navigationMode === "on") {
// @ts-ignore
if (navigationHandler.canGoBack) {
navigation.back()
} else {
console.log("can't go back")
}
back.textContent = "Navigation Back"
} else {
history.back()
back.textContent = "History Back"
updateUI()
}
})
forward?.addEventListener("click", () => {
if (navigationMode === "on") {
// @ts-ignore
if (navigation.canGoForward) {
navigation.forward()
forward.textContent = "Navigation Forward"
}
} else {
history.forward()
forward.textContent = "History Forward"
updateUI()
}
})
navigationModeButton?.addEventListener("click", () => {
if (navigationMode === "off") {
navigationMode = "on"
navigationModeButton!.textContent =
"Toggle navigation API mode - Currently On"
back.textContent = "Navigation Back"
forward.textContent = "Navigation Forward"
// @ts-ignore
navigation.addEventListener("navigate", navigationHandler)
} else {
navigationMode = "off"
navigationModeButton!.textContent =
"Toggle navigation API mode - Currently Off"
back.textContent = "History Back"
forward.textContent = "History Forward"
// @ts-ignore
navigation.removeEventListener("navigate", navigationHandler)
}
})
function navigationHandler(event: any) {
if (event.hashChange) {
return
}
event.intercept({
handler() {
updateUI()
},
})
}
markFavoriteStateButton?.addEventListener("click", markStateAsFavorite)
goToFavoriteStateButton?.addEventListener("click", goToFavoriteState)
function updateStateDisplay() {
if (navigationMode === "on") {
stateDisplay!.textContent = JSON.stringify(
// @ts-ignore
navigation.currentEntry.getState(),
null,
2
)
} else {
stateDisplay!.textContent = JSON.stringify(history.state, null, 2)
}
}
function updateUI() {
urlDisplay!.textContent = "current URL: " + location.href
updateStateDisplay()
if (navigationMode === "on") {
displayHistoryEntries()
} else {
historyEntriesDiv!.textContent = ""
}
}
function markStateAsFavorite() {
if (navigationMode === "on") {
// @ts-ignore
const key = navigation.currentEntry.key
favoriteState = {
// @ts-ignore
state: navigation.currentEntry.getState(),
key,
}
} else {
const key = history.state
favoriteState = key
}
console.log("marked", favoriteState)
}
async function goToFavoriteState() {
if (navigationMode === "on") {
// @ts-ignore
const entries = navigation.entries()
const entry = entries.find(
(entry: any) => entry.key === favoriteState.key
)
if (!entry) {
stateDisplay!.textContent =
"You have to save a favorite in navigation mode first"
return
}
if (location.href === entry.url) {
console.log("replace state automatically triggered")
// @ts-ignore
navigation.updateCurrentEntry({ state: favoriteState.state })
}
// @ts-ignore
await navigation.traverseTo(entry.key)
// @ts-ignore
console.log(navigation.currentEntry)
updateUI()
} else {
history.replaceState(favoriteState, "", location.href)
updateUI()
}
console.log("updated", favoriteState)
}
function displayHistoryEntries() {
// @ts-ignore
const entries = navigation.entries()
historyEntriesDiv.innerHTML = ""
entries.forEach((entry) => {
const entryButton = document.createElement("button")
entryButton.textContent = `Go to Entry Key: ${entry.key} at ${entry.url}`
if (entry.key === navigation.currentEntry.key) {
entryButton.style.fontWeight = "bold"
entryButton.style.color = "red"
entryButton.style.backgroundColor = "yellow"
}
if (entry.index < navigation.currentEntry.index) {
entryButton.style.backgroundColor = "lightgreen"
entryButton.style.color = "black"
}
if (entry.index > navigation.currentEntry.index) {
entryButton.style.backgroundColor = "lightblue"
entryButton.style.color = "black"
}
entryButton.addEventListener("click", async () => {
try {
// @ts-ignore
await navigation.traverseTo(entry.key)
updateUI()
} catch {
console.error("Unable to navigate to the selected entry.")
}
})
historyEntriesDiv.appendChild(entryButton)
})
}
window.addEventListener("popstate", () => {
// just here for the history API
updateUI()
})
</script>
Check out this pin by Jesse Pence (@jesse-pence) on CodePen.
This demo starts out using the History API. As you can see, it mostly works well enough. The problems only start to arise when you do something like selecting a state and then clicking on the hash links. As you can see, each hashChange counts as a new entry with completely separate state from the previous entry, but hash links are usually on the same page so this is not what we want.
Also, although we can go back to a selected state if we favorite it, it is completely out of context from the decisions made before and after that particular state was selected. We have no access into the history stack other than blindly traversing by a number of entries, so we can’t see exactly when the user wants to go back to. This is where the Navigation API comes in.
Now, switch over to the Navigation API. When you mark a state as your favorite, we are saving the key of that specific entry. Then, we are using the “traverseTo” method to go to that specific entry. Because the Navigation API gives us access to the entire history stack of our current session, we can go back to any specific state in time. When you go to the favorite state, notice how your forwards button on your browser has been filled with all of the entries after you favorited the state. And, it’s even smart enough to know if it should make a new entry or replace the current entry based on the URL!
Navigation Types and iFrames
If you check the console, you can see that each navigation event is categorized into one of four navigationType’s: traverse
, push
, replace
, and reload
. In the History API code, we had to imperatively create a new history entry with each navigation. The Navigation API automatically detects this (although this can be overridden in certain situations.) In summary, a navigation to the same URL is automatically categorized as a replace
navigation, a navigation to a new URL is automatically categorized as a push
navigation, and a navigation to a specific entry in the history stack is categorized as a traverse
navigation. You can guess what reload
means.
One issue with the History API is that a session’s history is shared across all the websites visited. Unfortunately, they did not have the foresight when designing it to consider multiple iFrames on a single page. This next demo is the same as the previous one, but with an iframe of itself inside itself as well as an iframe of a different website. Let’s see how this works.
Nested iFrames
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Nested iFrames</title>
</head>
<body>
<a id="top" href="#bottom">Bottom</a>
<button id="btn-1"> State 1, URL 1</button>
<button id="btn-3"> State 1, URL 2</button>
<button id="btn-2"> State 2, URL 1</button>
<button id="btn-4"> State 2, URL 2</button>
<p id="state">Recursion, Whoa!</p>
<p id="stack">STACK IT UP</p>
<p id="url"></p>
<a id="bottom" href="#top">Top</a>
<button id="navigationMode"
>Toggle navigation API mode - Currently Off</button
>
<button id="markStateAsFavorite"> Mark current state as favorite</button>
<button id="goToFavoriteState">Go to favorite state</button>
<button id="back">History Back</button>
<button id="forward">History Forward</button>
<div id="historyEntries"></div>
<iframe id="self" style="width: 100%; height: 250px;"></iframe>
<iframe src="https://build.chromium.org" style="width: 100%; height: 250px;"
></iframe>
</body>
</html>
<script>
const button1 = document.querySelector("#btn-1")
const button2 = document.querySelector("#btn-2")
const button3 = document.querySelector("#btn-3")
const button4 = document.querySelector("#btn-4")
const navigationModeButton = document.querySelector("#navigationMode")
const markFavoriteStateButton = document.querySelector("#markStateAsFavorite")
const goToFavoriteStateButton = document.querySelector("#goToFavoriteState")
const back = document.querySelector("#back")
const forward = document.querySelector("#forward")
const historyEntriesDiv = document.querySelector("#historyEntries")
const stateDisplay = document.querySelector("#state")
const stackDisplay = document.querySelector("#stack")
const urlDisplay = document.querySelector("#url")
const top = document.querySelector("#top")
const bottom = document.querySelector("#bottom")
const iframe = document.querySelector("#self")
if (!iframe || !(iframe instanceof HTMLIFrameElement))
throw new Error("iframe not found")
iframe.src = location.href
const state1 = {
cheese: "cheddar",
zombies: "scary",
gardening: "a rewarding experience",
}
const state2 = {
cheese: "gouda",
zombies: "kinda silly",
gardening: "pretty dang boring",
}
let navigationMode = "off"
let favoriteState = {
cheese: "brie",
zombies: "servants in my mansion",
gardening: "foolin' around in the dirt",
}
urlDisplay!.textContent = "current URL: " + location.href
top?.addEventListener("click", () => {
setTimeout(() => {
updateUI()
}, 10)
})
bottom?.addEventListener("click", () => {
setTimeout(() => {
updateUI()
}, 10)
})
navigationModeButton?.addEventListener("click", () => {
if (navigationMode === "off") {
navigationMode = "on"
navigationModeButton!.textContent =
"Toggle navigation API mode - Currently On"
back.textContent = "Navigation Back"
forward.textContent = "Navigation Forward"
// @ts-ignore
navigation.addEventListener("navigate", navigationHandler)
} else {
navigationMode = "off"
navigationModeButton!.textContent =
"Toggle navigation API mode - Currently Off"
back.textContent = "History Back"
forward.textContent = "History Forward"
// @ts-ignore
navigation.removeEventListener("navigate", navigationHandler)
}
})
function navigationHandler(event: any) {
if (event.hashChange) {
return
}
event.intercept({
handler() {
updateUI()
console.log(event.navigationType)
},
})
}
button1?.addEventListener("click", () => {
if (navigationMode === "on") {
// @ts-ignore
navigation.navigate("/ONE", { state: state1 })
} else {
history.pushState(state1, "", "/ONE")
updateUI()
}
})
button2?.addEventListener("click", () => {
if (navigationMode === "on") {
// @ts-ignore
navigation.navigate("/ONE", { state: state2 })
} else {
history.pushState(state2, "", "/ONE")
updateUI()
}
})
button3?.addEventListener("click", () => {
if (navigationMode === "on") {
// @ts-ignore
navigation.navigate("/TWO", { state: state1 })
} else {
history.pushState(state1, "", "/TWO")
updateUI()
}
})
button4?.addEventListener("click", () => {
if (navigationMode === "on") {
// @ts-ignore
navigation.navigate("/TWO", { state: state2 })
} else {
history.pushState(state2, "", "/TWO")
updateUI()
}
})
back?.addEventListener("click", () => {
if (navigationMode === "on") {
// @ts-ignore
if (navigation.canGoBack) {
navigation.back()
} else {
console.log("can't go back")
}
} else {
history.back()
updateUI()
}
})
forward?.addEventListener("click", () => {
if (navigationMode === "on") {
// @ts-ignore
if (navigation.canGoForward) {
navigation.forward()
} else {
console.log("can't go forward")
}
} else {
history.forward()
updateUI()
}
})
markFavoriteStateButton?.addEventListener("click", markStateAsFavorite)
goToFavoriteStateButton?.addEventListener("click", goToFavoriteState)
function updateUI() {
urlDisplay!.textContent = "current URL: " + location.href
stackDisplay!.textContent = `Items on the history stack: ${
history.length
}, Items on THIS FRAME's Navigation entries stack: ${
navigation.entries().length
}`
if (navigationMode === "on") {
displayHistoryEntries()
stateDisplay!.textContent = JSON.stringify(
// @ts-ignore
navigation.currentEntry.getState()
)
} else {
historyEntriesDiv!.textContent = ""
stateDisplay!.textContent = JSON.stringify(history.state)
}
}
function displayHistoryEntries() {
// @ts-ignore
const entries = navigation.entries()
historyEntriesDiv.innerHTML = ""
entries.forEach((entry) => {
const entryButton = document.createElement("button")
entryButton.textContent = `Go to Entry Key: ${entry.key} at ${entry.url}`
if (entry.key === navigation.currentEntry.key) {
entryButton.style.fontWeight = "bold"
entryButton.style.color = "red"
entryButton.style.backgroundColor = "yellow"
}
if (entry.index < navigation.currentEntry.index) {
entryButton.style.backgroundColor = "lightgreen"
entryButton.style.color = "black"
}
if (entry.index > navigation.currentEntry.index) {
entryButton.style.backgroundColor = "lightblue"
entryButton.style.color = "black"
}
entryButton.addEventListener("click", async () => {
try {
// @ts-ignore
await navigation.traverseTo(entry.key)
updateUI()
} catch {
console.error("Unable to navigate to the selected entry.")
}
})
historyEntriesDiv.appendChild(entryButton)
})
}
function markStateAsFavorite() {
if (navigationMode === "on") {
// @ts-ignore
const key = navigation.currentEntry.key
favoriteState = {
// @ts-ignore
state: navigation.currentEntry.getState(),
key,
}
} else {
const key = history.state
favoriteState = key
}
console.log("marked", favoriteState)
}
async function goToFavoriteState() {
if (navigationMode === "on") {
// @ts-ignore
const entries = navigation.entries()
const entry = entries.find(
(entry: any) => entry.key === favoriteState.key
)
if (!entry) {
stateDisplay!.textContent =
"You have to save a favorite in navigation mode first"
return
}
if (location.href === entry.url) {
console.log("replace state automatically triggered")
// @ts-ignore
navigation.updateCurrentEntry({ state: favoriteState.state })
}
// @ts-ignore
await navigation.traverseTo(entry.key)
// @ts-ignore
console.log(navigation.currentEntry)
updateUI()
} else {
history.replaceState(favoriteState, "", location.href)
updateUI()
}
}
updateUI()
window.addEventListener("popstate", updateUI)
</script>
Check out this pin by Jesse Pence (@jesse-pence) on CodePen.
Notice how all your interactions between the iframes get put on the same history stack? This is because the history stack is global to the whole browser. With the Navigation API, each iFrame gets a stack of its own that the app developer can inspect at their leisure. Unlike the History API where we can only go backwards and forwards an arbitrary number of entries across one stack, each of these stacks can be independepently traversed. Generally, I haven’t had much of an issue with this, but it is a recurring complaint of more experienced developers than me so I felt the need to mention it.
One easy way to see the improvement is to notice how the navigation stack doesn’t grow when clicking on the same URL when using the app in navigation API mode. This is because it is logging those entries with replace
navigation types. We use the updateCurrentEntry
method to update the state of the current entry in these cases. You will rarely need to do this, but it is useful in situations when you still need to update the state. Multi-step forms with sensitive information come to mind.
Loading and Error States
One issue that has plagued developers when working with the History API is that all changes to the history stack are synchronous. However, since most apps don’t have all of their data loaded into the browser at once, you need to prepare for what happens in between the time the user clicks a link and the time the data is loaded. Not only do you need to know when the data is loaded, but you must know that it has been loaded correctly. And, if a user changes their mind in the middle of stuff happening, you need to be able to cancel the loading process or else you end up with a lot of unnecessary data.
While the AbortController
interface has been introduced to help with this, it’s not always easy to plug it into your existing routing solution. Luckily, the NavigateEvent interface has a signal
property which is an AbortSignal
that you can use to cancel the loading process. We can use this to track whether or not the user has changed their mind while data is loading.
Although it is still synchronous by default, most of the traversal methods provided by the Navigation API return a pair of promises that you can use to track the navigation— committed
and finished
. The committed
promise resolves when the navigation has been initiated and the URL changes in the address bar. The finished
promise resolves when the navigation has been completed and the page has been rendered. Between the two, you can track the loading state of your app. Let’s see how this works in practice.
error-and-loading-states
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Loading and Error States</title>
<style>
.spinner {
width: 40px;
height: 40px;
border-radius: 100%;
border: 5px solid #ccc;
border-top-color: #333;
animation: spinner 0.8s infinite linear;
}
.dark {
border: 10px solid #333;
border-top-color: #c06;
}
@keyframes spinner {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>
</head>
<body>
<div id="message">
<h1>Click the link below to load the page.</h1>
<p>Nothing is happening yet...</p>
</div>
<a id="link">Click me!</a>
<button id="navigate">Navigate</button>
<button id="force-error">Force an error: false</button>
<button id="abort" style="display: none;">Stop</button>
<script>
const message = document.querySelector("#message")
const abort = document.querySelector("#abort")
const link = document.querySelector("#link")
const navigateBtn = document.querySelector("#navigate")
const forceError = document.querySelector("#force-error")
let error = false // Initial value for the error button
link.href = window.location.href // Setting the link's URL to the current page
abort?.addEventListener("click", () => {
// The equivalent of hitting the stop button in your browser
window.stop()
abort.style.display = "none"
})
forceError?.addEventListener("click", () => {
// To show that your error handling can be separated from the abort signal
error = !error
forceError.innerHTML = `Force an error: ${error}`
})
navigateBtn.addEventListener("click", async () => {
if (!message) return
const { committed, finished } = navigation.navigate(link.href)
// Almost all of the navigation functions return two promises
// "committed" is resolved at the beginning of the navigation
// "finished" is resolved when the navigation is finished
// In between, we add some loading state until the finished promise is resolved
console.log(
"this log is right before we await the 'committed' promise-- as far as I can tell, this is kind of like doing something on the 'beforeunload' event except for local navigation"
)
await committed
message.innerHTML = "<h3>Different loading screen...</h3>"
addSpinner("dark")
try {
if (error) throw new Error("Error: YOU DID THIS")
await finished
} catch (e) {
if (e instanceof DOMException) return
message.innerHTML = `<h1>${e.message}</h1>`
}
})
navigation.addEventListener("navigate", navigationHandler)
// // We can do a global error listener, but it's overrules the error listener in the handler function
// navigation.addEventListener("navigateerror", (e) => {
// document.body.textContent = `Could not load ${location.href}: ${e.error}, ${e.message}`
// })
function navigationHandler(event: any) {
if (!message) return
const signal = event.signal
signal.onabort = () => {
console.log(
"This log is coming from the 'onabort' event that is fired by the abort signal. You can do anything you want in this function-- including updating the DOM. I just chose to use the catch block to show you all your options."
)
}
event.intercept({
async handler() {
message.innerHTML = "<h1>The page is loading...</h1>"
!error ? (abort.style.display = "block") : null
addSpinner()
try {
await delay(3000, { signal })
} catch (error) {
console.log(signal)
signal.aborted
? (message.innerHTML = `<h1>${signal.reason}</h1>
<h4>You can click the link again if you want</h4>`)
: (message.innerHTML = `<h1>${error.message}</h1>`)
return
}
message.innerHTML = `<h1>Page loaded! You can click the link again.</h1`
abort.style.display = "none"
},
})
}
// I stole the meat of this function from https://gigantic-honored-octagon.glitch.me/
// Thanks to the Google Chrome team
function delay(ms: number, { signal = null } = {}) {
signal?.throwIfAborted()
return new Promise((resolve, reject) => {
const id = setTimeout(resolve, ms)
error && reject(new Error("Error: ALL YOUR FAULT"))
signal.addEventListener("abort", () => {
clearTimeout(id)
reject(new DOMException("Aborted", "AbortError"))
})
})
}
function addSpinner(style) {
const spinner = document.createElement("div")
spinner.classList.add("spinner")
style && spinner.classList.add(style)
message?.appendChild(spinner)
}
</script>
</body>
</html>
Check out this pin by Jesse Pence (@jesse-pence) on CodePen.
So, as usual, we’re using the intercept method to handle the navigation. Our handler function just delays everything by three seconds to simulate a slow loading process. You can do whatever you want in the handler function.
I included the “navigate” button to show that doing something extra on top of the core loading strategy is really easy. I gave this a different loading spinner which I explicitly put between the committed
and finished
promises. These promises are handy for when the timing of your loading and cleanup operations are important, but you can take advantage of this transition without explicitly naming them. Take a look at the main intercept handler function for an example of that.
The navigate button employs a try/catch block. Deferring to the global abort signal is managed in the error handling here with the line if (e instanceof DOMException) return
. A DOMException is rarely thrown, so we can safely assume it will be caught by our global abort signal. If you look at the console.log’s, you can see that an abort signal is fired every time that you click on a link while the finished
promise is still resolving. This shows that we are not loading any unnecessary pages as we reject any extra, unresolved promises.
Converting our App
Now that we have explored some simple demonstrations of these core concepts, let’s put them to work in our application. Unlike the previous chapters in this series, this time we will actually be removing files thanks to the Navigation API. Specifically, we’ll be saying goodbye to Link.js
because we no longer need to specifically target internal links.
As we’ve seen, all I really need to do is add this event listener and we’re done.
navigation.addEventListener("navigate", (event) =>
event.intercept({
async handler() {
const url = event.destination.url
const newUrl = new URL(url)
Router(newUrl.pathname)
},
})
)
But, that wouldn’t be a very exciting tutorial if that was all we did. And, we’re not really taking full advantage of the asychronous nature of the Navigation API this way. So, let’s add some more functionality to our app.
As it is, even though we’re now fetching our products from a remote API, we aren’t presenting any loading state to the user. And, when users click on multiple things, we’re loading everything even though we only show the last thing that was clicked. All of this can be easily fixed by adding a few things to our central event listener. Here’s the updated version:
import { Route } from "./Routes.js"
import { searchHandler, search } from "../features/search.js"
export const main = document.querySelector(".main")
export default function Router(navigateEvent) {
if (shouldNotIntercept(navigateEvent)) return
// Get URL
const url = navigateEvent ? navigateEvent.destination.url : location.href
const newURL = new URL(url)
const path = newURL.pathname
const searchParams = new URLSearchParams(newURL.search)
const searchValue = searchParams.get("search")
if (navigateEvent && path === location.pathname) {
navigateEvent.preventDefault()
main.scrollTo({
top: 0,
behavior: "smooth",
})
return
}
// Start Spinner
const spinner = createSpinner()
const mainHTML = main.innerHTML
checkAndReplaceHTML(mainHTML, spinner, 300)
// Check for search
if (searchValue) {
Route(path)
search.value = searchValue
return searchHandler()
}
// Route
navigateEvent
? navigateEvent.intercept({
async handler() {
navigateEvent.signal.onabort = () => {
console.log("aborted")
main.innerHTML = mainHTML
}
try {
await Route(path)
} catch (error) {
console.log("error", error)
main.innerHTML = Nope("error", error)
}
},
})
: Route(path)
}
navigation.addEventListener("navigate", Router)
function checkAndReplaceHTML(mainHTML, spinner, time) {
setTimeout(() => {
if (mainHTML === main.innerHTML) {
main.innerHTML = spinner.outerHTML
}
}, time)
}
function shouldNotIntercept(navigateEvent) {
if (!navigateEvent) return
return (
!navigateEvent.canIntercept ||
navigateEvent.hashChange ||
navigateEvent.downloadRequest ||
navigateEvent.formData
)
}
function createSpinner() {
const spinner = document.createElement("div")
// Nothing crazy, you can check the CSS for this
spinner.classList.add("spinner")
return spinner
}
So, instead of creating a separate function for our event listener, we simply have it call our central Router. In the router, we check to see if it is the first load by checking for the presence of a NavigateEvent. We just need to know where to get our URL, because the rest of the logic is the same from there.
Instead of an entirely separate function to check for the presence of a search query, we just check for it in the Router. If it is present, we call the search handler and return. Otherwise, we continue to our main logic.
Like I said, you don’t need to explicitly name the {committed, finished}
promise pair to take advantage of asychronous loading. Here, I am simply replacing the main content with a spinner as part of this loading process. However, it would be jarring to see this spinner on every navigation.
So, I created a function to determine how long the main content has been loading by simply checking to see if the HTML has changed. If it has been loading for more than 300ms, then we replace the main content with the spinner. Otherwise, we just leave it alone. This was inspired by this tweet conversation.
The last thing we need to do is add a little bit of error handling. If the user clicks on a link while the previous page is still loading, we want to abort the previous page. For this, I opted to use the onabort
event of the included AbortSignal. If the signal is aborted, we simply replace the main content with the original HTML of the previous page. If it is a real error, we call the 404 page and embed the error message.
As for our dynamic routes and 404 handling, I moved all of that to our Route
function because it makes sense to keep all of this logic near our source of truth: the Routes array. This is what it looks like now:
import Nope from "../pages/Nope.js"
const Routes = [
{ path: "/", component: "Home" },
{ path: "/about", component: "About" },
{ path: "/products", component: "Products" },
{ path: "/product", component: "ProductPage", dynamic: true },
{ path: "/cart", component: "Cart" },
{ path: "/checkout", component: "Checkout" },
]
export async function Route(path) {
const route = Routes.find(
(route) => route.path.split("/")[1] === path.split("/")[1]
)
if (!route) return Nope()
const component = await import(`../pages/${route.component}.js`)
if (route.dynamic) {
const id = path.split("/")[path.split("/").length - 1]
return component.default(id)
}
return component.default()
}
4-navigation-api
Conclusion
The Navigation API has been carefully crafted to build on top of the foundation laid by the History API. As I repeatedly demonstrated, all that you have to do is place a single event listener, and all of the previous tricks still work. This was intentional to support gradual adoption.
While the most attractive feature may be that everything is routed through a central event listener, that initial appeal is hiding a whole host of other quality-of-life improvements. Being able to detect the presence of formData, download requests, and hash changes is a huge win, and the ability to inspect each history entry is a game-changer. It is much easier to create conditional routing logic when all of the information is in one place.
Although this is only available in Chromium-based browsers, I think you can see that I’m really excited about the potential of this API— especially when combined with the topic of our next chapter: the View Transitions API. As a parting note, here is one more demo that I couldn’t find an excuse to include in the article.
This cat picture gallery is inspired by the idea of intercepting routes— a feature recently introduced in Next.JS. While I understand the concept, I don’t personally like that sharing a link provides a different experience than navigating to it.
So, in my version, the only way to see a full-screen cat picture on load with the base URL is if you refresh the page while the picture is active, or leave then navigate back to it in the back/forwards cache. Alternatively, you can share the link to a full-screen picture by pressing the button that gives you a URL with a search query.
See you next time!
Kitten Gallery
<!DOCTYPE html>
<html>
<head>
<title>Gallery</title>
<style>
body {
margin: 0;
padding: 0;
background: #eee;
}
main {
max-width: 100%;
margin: auto;
padding: 1rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100dvh;
}
.gallery {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
max-width: 100%;
height: 100%;
margin: auto;
}
.imgcontainer {
position: relative;
text-align: center;
}
.imgcontainer span {
position: absolute;
top: 0;
left: 0;
background: #88f;
border-radius: 1rem;
padding: 0.5rem;
color: #fff;
}
.gallery img {
width: 200px;
height: 150px;
object-fit: contain;
cursor: pointer;
}
.gallery img.active {
width: 100%;
height: 100%;
max-width: 100%;
max-height: 100%;
position: fixed;
top: 0;
left: 0;
aspect-ratio: 1/1;
z-index: 1;
}
body:has(img.active) {
background: rgba(0, 0, 0, 0.8);
}
.imgcontainer span.active {
position: fixed;
top: 0;
left: 0;
z-index: 3;
font-size: 2rem;
}
form,
button,
p,
#favorite {
position: fixed;
background: white;
padding: 1rem;
border-bottom: 3px solid #88f;
max-width: 40%;
opacity: 0.8;
margin: 0;
padding: 0.25rem;
border-radius: 0.5rem;
}
button {
bottom: 0;
right: 0;
border: 2px dotted #88f;
cursor: pointer;
z-index: 2;
}
form {
bottom: 0;
left: 0;
z-index: 2;
}
label {
text-decoration: underline;
font-weight: bold;
}
p {
top: 0;
left: 0;
z-index: 1;
}
#favorite {
top: 0;
right: 0;
font-size: 0.75rem;
font-weight: bold;
text-align: right;
max-width: fit-content;
}
#link {
right: 100px;
}
#link,
#close {
display: none;
}
input[type="radio"] {
cursor: pointer;
appearance: none;
width: 20px;
height: 20px;
border-radius: 1px;
border: 1px solid #88f;
transition: 0.5s;
margin: 0 0 0 1rem;
}
input[type="radio"]:hover {
background-color: #ccc;
}
input[type="radio"]:checked {
background-color: #88f;
}
@media (max-width: 700px) {
body {
font-size: 0.75rem;
}
}
</style>
</head>
<body>
<main>
<p>
Which kitty is your favorite? You can click on each image to take a
closer look.
</p>
<form>
<label for="kitty">Favorite Kitty:</label>
<br />
<input type="radio" name="kitty" value="Meowzer" /> Meowzer
<br />
<input type="radio" name="kitty" value="Dingle Dan" /> Dingle Dan
<br />
<input type="radio" name="kitty" value="Fluffernutter" /> Fluffernutter
</form>
<div class="gallery">
<div class="imgcontainer">
<span>Meowzer</span>
<img
src="https://placekitten.com/800/801"
alt="Image 1"
id="image1"
/>
</div>
<div class="imgcontainer">
<span>Dingle Dan</span>
<img
src="https://placekitten.com/700/700"
alt="Image 2"
id="image2"
/>
</div>
<div class="imgcontainer">
<span>Fluffernutter</span>
<img
src="https://placekitten.com/600/600"
alt="Image 3"
id="image3"
/>
</div>
</div>
<button id="link">Link to Image</button>
<button id="close">Close</button>
<span id="favorite"></span>
</main>
</body>
<script>
const gallery = document.querySelector(".gallery")
const images = gallery?.querySelectorAll(
"img"
) as NodeListOf<HTMLImageElement>
const params = new URLSearchParams(window.location.search)
const linkButton = document.getElementById("link") as HTMLButtonElement
const closeButton = document.getElementById("close") as HTMLButtonElement
const form = document.querySelector("form") as HTMLFormElement
const favorite = document.getElementById("favorite") as HTMLSpanElement
// @ts-ignore
let favoriteState =
window.navigation.currentEntry?.getState()?.favorite || "all de cats"
if (favoriteState) {
favorite.textContent = `Your favorite kitty is ${favoriteState}`
if (favoriteState !== "all de cats") {
const favoriteInput = document.querySelector(
`input[value="${favoriteState}"]`
) as HTMLInputElement
favoriteInput.checked = true
}
}
let imageIndex =
params.get("image") ||
// @ts-ignore
navigation.currentEntry.getState()?.imageIndex ||
null
closeButton.addEventListener("click", closeImage)
linkButton.addEventListener("click", linkImage)
form?.addEventListener("change", kittyUpdate)
document.addEventListener("click", onClickOutside)
if (imageIndex) {
openImage(imageIndex)
}
function openImage(index: string) {
images.forEach((image) => image.classList.remove("active"))
images[parseInt(index)].classList.add("active")
const span = images[parseInt(index)]
.previousElementSibling as HTMLSpanElement
span.classList.add("active")
closeButton.style.display = "block"
linkButton.style.display = "block"
}
function closeImage() {
images.forEach((image) => image.classList.remove("active"))
closeButton.style.display = "none"
linkButton.style.display = "none"
const span = document.querySelector(".imgcontainer span.active")
if (span) span.classList.remove("active")
navigation.updateCurrentEntry({
state: { ...navigation.currentEntry.getState(), imageIndex: null },
})
}
function linkImage() {
imageIndex = navigation.currentEntry.getState().imageIndex
console.log(imageIndex, "imageIndex")
if (imageIndex === -1) return
const url = new URL(window.location.href)
url.searchParams.set("image", imageIndex || "0")
navigator.clipboard.writeText(url.toString())
alert("Link copied to clipboard!")
}
function onImageClick(event: MouseEvent) {
const image = event.currentTarget as HTMLImageElement
const index = Array.from(images).indexOf(image)
openImage(index.toString())
navigation.navigate(window.location.pathname, {
state: { ...navigation.currentEntry.getState(), imageIndex: index },
})
}
function onClickOutside(event: MouseEvent) {
const target = event.target as HTMLElement
if (target.closest(".gallery")) return
closeImage()
}
function kittyUpdate(event: any) {
const target = event.target as HTMLInputElement
favorite.textContent = `Your favorite kitty is ${target.value}`
window.navigation.updateCurrentEntry({
state: {
...navigation.currentEntry.getState(),
favorite: target.value,
},
})
}
images.forEach((image) => {
image.addEventListener("click", onImageClick)
})
window.navigation.addEventListener("navigate", (event) => {
console.log(event, "event")
event.intercept({
handler() {
const state = event.currentTarget.currentEntry.getState()
if (state?.imageIndex) {
openImage(state.imageIndex.toString())
}
if (state?.favorite) {
favorite.textContent = `Your favorite kitty is ${state.favorite}`
}
},
})
})
</script>
</html>
Check out this pin by Jesse Pence (@jesse-pence) on CodePen.
Additional Resources
-
Sam Thorogood and Jake Archibald - Modern client-side routing: the Navigation API
-
The WICG proposal - Navigation API
-
HTTP 203 - The history API is dead. Love live the navigation API
-
Romain Trotard - Back to the Future: Navigation API