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.
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.
More information
Each origin
has its own navigation history entries.
So for example if a user navigates is on a site with the romaintrotard.com
origin, all entries will be added on the list and will be visible with navigation.entries()
.
But if the user then goes to 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.
Then if the user does some backward navigation and go back to romaintrotard.com
, the navigation history entries will be the previous one, so there will be more than one 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');
});
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
modestate
: 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.
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 historyfinished
: 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>
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.
We are going quickly on the new way to navigate through the NavigationHistoryEntry
list.
Until now, you can do it thanks to location.reload()
. Now, the new way will be:
const { committed, finished } = navigation.reload();
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()
.
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()
.
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);
History list using routing library
Note: If you use a routing library that overrides the native 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);
}
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,
);
});
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
});
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();
}
});
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.
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
},
});
});
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.
You can find me on Twitter if you want to comment this post or just contact me. Feel free to buy me a coffee if you like the content and encourage me.