DEV Community

Cover image for Back to the future: Navigation API
Romain Trotard
Romain Trotard

Posted on • Updated on • Originally published at romaintrotard.com

Back to the future: Navigation API

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

Go back to listing page example

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:

Navigation entries example

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:

Problem with 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.


Current entry

Thanks to the navigation.currentEntry we can know where we are:

Current entry example

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");
})
Enter fullscreen mode Exit fullscreen mode

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,
);
Enter fullscreen mode Exit fullscreen mode

Here is a non exhaustive list of the available options:

  • history: defines if it is replace or push 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>
Enter fullscreen mode Exit fullscreen mode

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.

Browser displays loading feedback


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 with committed and finished 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();
Enter fullscreen mode Exit fullscreen mode

Go to the previous page

The new way to go the previous navigation history entry is to use:

const { committed, finished } = navigate.back();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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,
    );
});
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

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();
    }
});
Enter fullscreen mode Exit fullscreen mode

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 and preventDefault: 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
        },
    });
});
Enter fullscreen mode Exit fullscreen mode

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)