Go to main content
December 19, 2022
Cover image

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

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.


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');
});

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

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>

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


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.