In my company's projects, I forbid to migrate our react-router
to the new v6
.
Why?
Because they removed the ability to block the navigation, for example when a form hasn't been saved and the user click on a link.
There is an opened issue [V6] [Feature] Getting usePrompt and useBlocker back in the router , and recently Ryan florence (one of the create of React router and Remix) commented on this and said that they removed the feature because has always had corner case where it will not work. Corner cases that will be fixed thanks to the new Navigation web API.
Let's see the innovation that introduces this new API.
Note: Currently, routing libraries like
React Router
,Vue Router
,TanStack Router
,SvelteKit Routing
, ... use the history API. If you want to know more about this API you can read Let's explore javascript's Location and History API.
New NavigationHistoryEntry
Entry list
Have you ever wanted to get the list of entry in the history? I have!
Here is the use case:
- You are on a article listing page
- You filter by title (because only want article about the Navigation API)
- Clicking on an article on the list to see the detail
- You are redirected to the detail page
- When you are done
- You want to go back to the listing page thanks to a "Go back to listing page" button
- You expect to go back to the listing page with the previous filter on year you made
With the History API it's not possible to easily do that, because you only know the number of items in the history entries.
To do the previous scenario, you have to either:
- keep all the url in a global state
- or only store the latest
search
in a global state
Both strategies suck.
Thanks to the new navigation.entries()
that returns a list of NavigationHistoryEntry
:
Amazing!
But this is not enough for our use case, because we can have multiple entry with the listing page url in our history.
And the user can be at any entry in the history list if playing with the "backward" / "forward" browser buttons.
For example we could have the following history entry list:
So we need to know where we are in the history entries. Fortunately, there is a new property for this. Let's see it.
Each So for example if a user navigates is on a site with the But if the user then goes to Then if the user does some backward navigation and go back to More information
origin
has its own navigation history entries. romaintrotard.com
origin, all entries will be added on the list and will be visible with navigation.entries()
.google.com
, if we relaunch navigation.entries()
, there is only one entry because a brand new list has been created for the google.com
origin.romaintrotard.com
, the navigation history entries will be the previous one, so there will be more than one entry.
Current entry
Thanks to the navigation.currentEntry
we can know where we are:
And now we can get our previous entry corresponding to the listing page. We just have to combine this value with the navigation.entries()
:
const { index } = navigation.currentEntry;
const previousEntries = navigation.entries().slice(0, index);
const matchingEntry = previousEntries.findLast((entry) => {
// We have the `url` in the entry, let's
// extract the `pathname`
const url = new URL(entry.url);
return url.pathname.startsWith("/list");
})
New way to to navigate between pages
With this new API, it will not be necessary to use the history.replaceState
and history.pushState
anymore but just do:
const { committed, finished } = navigation.navigate(
"myUrl",
options,
);
Here is a non exhaustive list of the available options:
-
history
: defines if it isreplace
orpush
mode -
state
: some information to persist in the history entry
You probably wonder "Why is it better than the history
API, because for now it seems to do the same things.".
And you are right.
Let's see 2 differences.
committed
and finished
returned values
It returns an object with two keys that can be useful when working with Single Page App:
-
committed
: a promise that fulfills when the visible url has changed and the new entry is added in the history -
finished
: a promise that fulfills when all the interceptor are fulfilled
Thanks to the finished
promise, you can know if the navigation has been aborted, or if the user is on the right page.
Thanks to that we can display some feedback to the user when changing of page.
<button
type="submit"
onClick={async () => {
setLoading(true);
try {
// Send values to the back
await submit(values);
} catch (e) {
showSnackbar({
message:
"Sorry, an error occured " +
"when submitting your form.",
status: "error",
});
setLoading(false);
return;
}
try {
// Then redirect to the listing page
await navigate("/list", {
history: push,
info: "FromCreationPage",
state: values,
}).finished;
} catch (e) {
showSnackbar({
message:
"Sorry, an error occured " +
"when going to the listing page.",
status: "error",
});
} finally {
setLoading(false);
}
}}
>
Save
</button>
Loading feedback
Another difference with the history navigation is that the browser will display a feedback to the user on its own when the page is changing, like if we were on a Multiple Page Application.
Navigation through the entry list
We are going quickly on the new way to navigate through the NavigationHistoryEntry
list.
Note: All this new methods returned the same object than
navigate
withcommitted
andfinished
promises.
Reload of the page
Until now, you can do it thanks to location.reload()
. Now, the new way will be:
const { committed, finished } = navigation.reload();
Go to the previous page
The new way to go the previous navigation history entry is to use:
const { committed, finished } = navigate.back();
The previous way to do that is with history.back()
.
Go to the next page
You probably already guessed it, you can also go to the next navigation history entry with:
const { committed, finished } = navigation.forward();
The previous way to do that is with history.forward()
.
Go to a more distant entry
Previously to go to another history entry, you will have to know the number of entry to jump. Which is not an easy way to do it, because you don't have a native way to get this information.
So you had to do a mechanism to have this value. For example by maintaining a global state with all the previous url / entry.
And then use:
history.go(delta);
Note: If you use a routing library that overrides the native History list using routing library
history
API, you probably have a way to listen all the navigation:
const myLocationHistory = [];
history.listen((newLocation, action) => {
if (action === "REPLACE") {
myLocationHistory[myLocationHistory.length - 1] =
newLocation;
} else if (action === "PUSH") {
myLocationHistory.push(newLocation);
} else if (action === "POP") {
myLocationHistory.pop();
}
});
With the new Navigation Web API, there is a more straightforward way to do it with:
const { committed, finished } =
navigation.traverseTo(entryKey);
The entryKey
can be deduced thanks to the code in the "Current entry" part. Amazing!
Example of code
const { index } = navigation.currentEntry;
const previousEntries = navigation.entries().slice(0, index);
const matchingEntry = previousEntries.findLast((entry) => {
// We have the `url` in the entry, let's
// extract the `pathname`
const url = new URL(entry.url);
return url.pathname.startsWith("/list");
})
if (matchingEntry) {
navigation.traverseTo(matchingEntry.key);
}
New NavigationEvent
You can subscribe to navigate
event to be notified of all navigation event.
navigation.addEventListener("navigate", (event) => {
console.log(
"The new url will be:",
event.destination.url,
);
});
Note: The event listener can be asynchronous. In this case, the browser loading feedback will be displayed until it fulfills.
The NavigateEvent
is fired for the following cases:
- navigation with the
location
API - navigation with
history
API - navigation with the new
navigation
API - browser back and forward buttons
But will not catch:
- reload of the page with the browser button
- change of page if the user changes the url in the browser
For these two cases you will have to add a beforeunload
event listener:
window.addEventListener("beforeunload", (event) => {
// Do what you want
});
Blocking navigation
One of the interesting things you can do, is blocking the navigation thanks to event.preventDefault()
:
navigation.addEventListener("navigate", (event) => {
if (!hasUserRight(event.destination.url)) {
// The user
event.preventDefault();
}
});
Note: It's thanks to this feature that we will be able to stop the navigation and prompt a modal when there is unsaved changed on a form.
Simulating SPA
You probably know that, routing libraries prevent the default behavior of links thanks to preventDefault
. This is the way we can change the url without having a full page reload.
Note: I have an article to know the difference between
stopPropagation
andpreventDefault
: preventDefault vs stopPropagation.
It's now possible to override this default behavior for every link without preventDefault
.
You just have to add an interceptor to the NavigateEvent
:
navigation.addEventListener("navigate", (event) => {
event.intercept({
handler: () => {
// Do some stuff, it can be async :)
// If async, the browser will be in
// "loading mode" until it fulfills
},
});
});
Conclusion
The Navigation API brings some new ways to handle navigation and be notified of them that should simplified some tricky part in routing libraries. For example, when wanting to block the navigation when there are unsaved changes on a form.
I think this new API will replace the history one that will die slowly.
But watch out, you probably shouldn't use it right now because Firefox and Safari do not support it.
But you can play with Chrome and Edge :)
If you want to know more about it, you can read the specification.
Stay tuned, in a future article I will put all this into practice by implementing a small routing library.
Do not hesitate to comment and if you want to see more, you can follow me on Twitch or go to my Website. And here is a little link if you want to buy me a coffee ☕
Top comments (0)