Master the Art of Navigation: Building a Library with the Navigation API
Tags: tutorial, javascript, webdev, architecture
Published: 2023-02-08 19:36:00

In my previous article we have seen what is the [new Navigation Web API](https://dev.to/romaintrotard/back-to-the-future-navigation-api-4pil).

> **Note:** I strongly recommend you to read this article if your are not up to date on this API.

The goal is to make a really simple routing library which will be agnostic of any library/framework. And we will connect it to React in a next article.

The application we are going to build together in this series of article is the following one:


![Demo of the final application](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/uf0z07h50xqypwepr7ao.gif)


Let's get our hands dirty.

----

## Specifications

The features we want to implement are the following:
- Single Page Application
- Being able to do some navigation
- Implement loader data (like **React Router v6** and **Tanstack Router**)
- Prompt a modal when leaving a page with unsaved changes

----

## How to keep things agnostic?

The idea is to implement all the logic in a `createBrowserRouter` function and return methods:
- `navigate`: to do navigation inside the app
- `registerBlockingRoute`: to block navigation (for the unsaved form use case)
- `state`: the state of the router that will contains the **matching route**, the **current location**, the **loader data**, ...
- `subscribe`: to subscribe to state changes. This is the key point of the application. Every time **matching route**, **loader data**, ... will change, then we notify subscribers of this change.

> **Note:** We implement the observer pattern again like we have seen in the [React Hook Form: a unique implementation article](https://dev.to/romaintrotard/react-hook-form-a-unique-implementation-3oll).

----

## Backbone code

The first step I propose you is to implement the subscription:

```ts
type RouterState = {
    // Things to define later
};

type RouterStateSubscriber = (
    newState: RouterState,
) => void;

function createBrowserRouter() {
    let subscribers: RouterStateSubscriber[] = [];

    const subscribe = (
        subscriber: RouterStateSubscriber,
    ) => {
        subscribers.push(subscriber);

        // unsubscribe callback
        return () => {
            subscribers = subscribers.filter(
                (sub) => sub !== subscriber,
            );
        };
    };

    return {
        subscribe,
    };
}
```

From this, we can start implementing our features :)

----

## Single Page Application

Simulating an SPA is really simple thanks to the new API. Because we can register a custom interceptor. 

Watch out not to intercept the event if:
- not possible to intercept
- hash has changed
- downloading a file
- send form data

> **Note:** As seen previously we put this in `createBrowserRouter` function.

```js
// Code from 
// https://developer.chrome.com/docs/web-platform/navigation-api/#deciding-how-to-handle-a-navigation
function shouldNotIntercept(
    navigationEvent: NavigateEvent,
) {
    return (
        !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
    );
}

function createBrowserRouter() {
    // Previous subscription code

    window.navigation.addEventListener(
        "navigate",
        (event) => {
            if (shouldNotIntercept(event)) {
                return;
            }

            event.intercept({
                handler: () => {
                    // Code to implement here
                },
            });
        },
    );
}
```

Now that we simulate an SPA, let's implement the navigation inside the application.

----


## Navigation inside the app

Thanks to the our previous **navigation interceptor** you can directly use `a` tag to do navigation :)

```html
<a href="/otherAppPage">Go to other app page</a>
```

> **Note:** As you can see, there is no more need to `preventDefault` the default behavior of the browser. Because our interceptor will prevent it by default if we stay in the internal application.

_What if we want to do some imperative navigation?_

There are some use case where imperative navigation is needed. For example after creating an entity you probably want to go back to the listing page. 

To do that we are going to implement a `navigate` function:

```ts
function createBrowserRouter() {
    // Previous implemented code

    const navigate = (
        url: string,
        {
            replaceMode = false,
            info,
        }: { replaceMode?: boolean; info?: any } = {},
    ) => {
        window.navigation.navigate(url, {
            history: replaceMode ? "replace" : "push",
            info,
        });
    };

    return {
        subscribe,
        navigate,
    };
}
```

And here we go. We rely on the `navigation.navigate` method and nothing else.


We now how to navigate between pages, it's now time to empower our `createBrowserRouter` by passing our **routes**.

----

## Routes implementation

We want to pass our **routes** to the `createBrowserRoute` and calculate the matching route each time we change of location.

For the moment, a `Route` will have the following shape:

```ts
type Route = {
    // The pathname of the route
    path: string;
    // The content for the route
    component: JSX.Element;
};
```

Regarding the route matching the location, I'm not gonna handle nested routing and dynamic segments.

```ts
function getMatchingRoute(
    routes: Routes,
    pathname: string,
) {
    return (
        routes.find((route) => route.path === pathname) ??
        null
    );
}
```

The resulting matching route will be put in the `RouterState` as well as the current `pathname`:

```ts
type RouterState = {
    // The route matching the pathname
    matchingRoute: Route | null;
    // The current pathname
    pathname: string;
};
```

> **Note:** In reality, it should be the current location instead of the pathname. With dynamic parameters but for the simplicity of the article I don't implement it here.

And the `createBrowserRouter` becomes:

```ts
function createBrowserRouter({
    routes,
}: { routes: Route[] }) {
    const initialPathname = window.location.pathname;

    // Previous code here

    // Easier matching method.
    // This does not handle nested routing but not 
    // the subject of the article.
    // See my `React router v6` implem article for that.
    const initialMatchingRoute = getMatchingRoute(
        routes,
        initialPathname,
    );

    let state = {
        matchingRoute: initialMatchingRoute,
        pathname: initialPathname,
    };

    return {
        // Other previous returned values
        get state() {
            return state;
        },
    };
}
```

> **Note:** You've probably seen the **getter** of `state` that makes it dynamic.

We calculate the right route to display at first load of the page. But when the user is changing the location we do not handle it yet. Let's do this.

If you think we should put this code in the **interceptor handler**, you are totally right.


```ts
function createBrowserRouter() {
    // Previous code

    const updateState = (
        newState: Partial<RouterState>,
    ) => {
        state = { ...state, ...newState };

        // Notify to all the subscribers of the changes
        subscribers.forEach((subscriber) =>
            subscriber(state),
        );
    };

    const completeNavigation = (url: string) => {
        const { pathname } = new URL(url);

        const newMatchingRoute = getMatchingRoute(
            routes,
            pathname,
        );

        updateState({
            pathname,
            matchingRoute: newMatchingRoute,
        });
    };

    const listener = (event: NavigateEvent) => {
        if (shouldNotIntercept(event)) {
            return;
        }

        event.intercept({
            handler() {
                completeNavigation(event.destination.url);
            },
        });
    };

    // Add our interceptor
    window.navigation.addEventListener(
        "navigate",
        listener,
    );

    // Previous code
}
```

It's now time to implement the `loaderData` feature.

----


## `loaderData` feature

Most of the time when you go on a page you will have to fetch some data from your backend.

In function of the framework you use, you have different pattern to do that. For example with **React** you can find:
- **fetch-on-render**: fetch calls are made in `useEffect`
- **fetch-then-render**: before rendering a page, fetch all of your data and then render the entire page at once.
- **render-as-you-fetch**: it's like the previous pattern but render every component that has already their data available. And display a placeholder with `Suspense` for ones they have not.

In this article, we are going to implement the **fetch-then-render** strategy.

> **Note:** It's the default behavior of **Remix**. But you can do **render-as-you-fetch** thanks to **defer** feature combined with **Await** and **Suspense**.

The idea is to fetch the data of the page before navigating to the new page. To do that we add a `loader` callback to our `Route` type:

```ts
type Route = {
    // The pathname of the route
    path: string;
    // The content for the route
    component: JSX.Element;
    // Your page may not need data
    loader?: () => any | Promise<any>;
};
```

The result of the `loader` will be stored in our `RouterState`:

```ts
type RouterState = {
    // The route matching the pathname
    matchingRoute: Route | null;
    // The current pathname
    pathname: string;
    // The data returned by `loader` callback
    loaderData: unknown;
};
```

Let's change a little bit our `completeNavigation` function to:

```ts
const noop = () => {};

const completeNavigation = async (url: string) => {
    const { pathname } = new URL(url);

    const newMatchingRoute = getMatchingRoute(
        routes,
        pathname,
    );

    const data =
        (await newMatchingRoute?.loader?.()) ?? noop();

    updateState({
        pathname,
        loaderData: data,
        matchingRoute: newMatchingRoute,
    });
};
```

_And that's all?_

Almost! We need to handle the initial `loaderData`. To do that, let's just initialize our first `loaderData` to `undefined` and call the `completeNavigation` function directly in the `createBrowserRouter`.

```ts
function createBrowserRouter() {
    let state: RouterState = {
        pathname: initialPathname,
        matchingRoute: initialMatchingRoute,
        loaderData: undefined,
    };

    // Previous code

    // Simulate a navigation for the first pathname.
    completeNavigation(window.location.href);

    // Next code
}
``` 

{% details Click here to see full code %} 

```ts
type Route = {
    // The pathname of the route
    path: string;
    // The content for the route
    component: JSX.Element;
    // Your page may not need data
    loader?: () => Promise<any> | any;
};

export type Routes = Route[];

const noop = () => undefined;

// Code from https://developer.chrome.com/docs/web-platform/navigation-api/#deciding-how-to-handle-a-navigation
function shouldNotIntercept(
    navigationEvent: NavigateEvent,
) {
    return (
        !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
    );
}

export type RouterState = {
    // In reality should put the location.
    // With dynamic parameters, but not handle in this
    // example of implementation.
    pathname: string;
    // The route matching the pathname
    matchingRoute: Route | null;
    // The data returned by `loader` callback
    loaderData: any;
};

type RouterStateSubscriber = (
    newState: RouterState,
) => void;

function getMatchingRoute(
    routes: Routes,
    pathname: string,
) {
    return (
        routes.find((route) => route.path === pathname) ??
        null
    );
}

/**
 * This function will create the browser router state
 * It has to be called outside of any component.
 */
export default function createBrowserRouter({
    routes,
}: { routes: Routes }) {
    const initialPathname = window.location.pathname;
    let subscribers: RouterStateSubscriber[] = [];

    // Simpler matching method.
    // This does not handle nested routing but not the subject of the article.
    // See my `React router v6` implem article for that.
    const initialMatchingRoute = getMatchingRoute(
        routes,
        initialPathname,
    );

    const subscribe = (
        subscriber: RouterStateSubscriber,
    ) => {
        subscribers.push(subscriber);

        // unsubscribe callback
        return () => {
            subscribers = subscribers.filter(
                (sub) => sub !== subscriber,
            );
        };
    };

    let state: RouterState = {
        pathname: initialPathname,
        matchingRoute: initialMatchingRoute,
        loaderData: undefined,
    };

    const updateState = (
        newState: Partial<RouterState>,
    ) => {
        state = { ...state, ...newState };

        // Notify to all the subscribers of the changes
        subscribers.forEach((subscriber) =>
            subscriber(state),
        );
    };

    const completeNavigation = async (url: string) => {
        const { pathname } = new URL(url);

        const newMatchingRoute = getMatchingRoute(
            routes,
            pathname,
        );

        const data =
            (await newMatchingRoute?.loader?.()) ?? noop();

        updateState({
            pathname,
            loaderData: data,
            matchingRoute: newMatchingRoute,
        });
    };

    const listener = (event: NavigateEvent) => {
        if (shouldNotIntercept(event)) {
            return;
        }

        event.intercept({
            async handler() {
                updateState({
                    navigationInProgress: true,
                });

                await completeNavigation(
                    event.destination.url,
                );
            },
        });
    };

    // Add our interceptor
    window.navigation.addEventListener(
        "navigate",
        listener,
    );

    // Simulate a navigation for the first pathname.
    completeNavigation(window.location.href);

    const navigate = (
        url: string,
        {
            replaceMode = false,
            info,
        }: { replaceMode?: boolean; info?: any } = {},
    ) => {
        window.navigation.navigate(url, {
            history: replaceMode ? "replace" : "push",
            info,
        });
    };

    return {
        get state() {
            return state;
        },
        subscribe,
        navigate,
    };
}
```

{% enddetails %}

When you have page with `loader`, if the fetching of data is long, it can take some time to change the page. Fortunately, there is the spinner of the browser that indicates that the navigation is running.

But if you want to display, your own loading state like in my application. We have to track the `navigationInProgress` and `initialized` state.

> **Note:** The `initialized` state is to track when the first page is ready to be displayed. (i.e. when the data is fetched)

Of course these values are in the `RouterState`:

```ts
type RouterState = {
    // The route matching the pathname
    matchingRoute: Route | null;
    // The current pathname
    pathname: string;
    // The data returned by `loader` callback
    loaderData: unknown;
    // Is the first page ready to be displayed?
    initialized: boolean;
    // Is a navigation in progress?
    navigationInProgress: boolean;
};
```

The implementation is really easy:

```ts
function createBrowserRouter() {
    let state: RouterState = {
        pathname: initialPathname,
        matchingRoute: initialMatchingRoute,
        loaderData: undefined,
        initialized: !initialMatchingRoute?.loader,
        navigationInProgress: false,
    };

    // Previous code

    const completeNavigation = async (url: string) => {
        // Previous code

        updateState({
            pathname,
            loaderData: data,
            matchingRoute: newMatchingRoute,
            initialized: true,
            navigationInProgress: false,
        });
    };

    const listener = (event: NavigateEvent) => {
        if (shouldNotIntercept(event)) {
            return;
        }

        event.intercept({
            async handler() {
                updateState({
                    navigationInProgress: true,
                });

                await completeNavigation(
                    event.destination.url,
                );
            },
        });
    };

    // Next code
}
```

And here we go thanks to that, we gonna the best user experience ever :)

Let's implement our last feature: **prompt before leave modal**.

----

## Prompt before leave feature

This feature is really important for the user experience. It's the feature that saves you minutes of work ruined by a miss click on a link navigation / wrong shortcut...

The use case is really simple:
- the user fills a form
- he doesn't save the form
- go to another page
- before going to the "another page" we want to prompt a modal to the user to be sure he want to leave the page without saving its form.

To implement we need two informations coming from the application:
- `shouldPrompt`: a callback to know if we need to prompt a modal.
- `customPromptBeforeLeaveModal`: a callback that triggers the display of a modal and return a `Promise<boolean` with the response of the user.

And the implementation is pretty simple:
- every time an interceptable navigation is detected evaluate the `shouldPrompt` callback
- if it's false then do nothing. Otherwise, do next steps
- prevent the navigation
- display the modal / execute the `customPromptBeforeLeaveModal` callback and wait for the response
- if the result is false then we don't continue the navigation. Otherwise, let's continue the navigation with `navigation.navigate` function.

> **Note:** We need to put an `info` while continuing the navigation to know that we must not intercept the navigation:

```ts
window.navigation.navigate(event.destination.url, {
    history: "push",
    state: event.destination.state,
    info: { forceNavigate: true, ...event.info },
});
```

This listener will only catch in app navigation. We need to handle:
- reload of the page
- going to another origin
- closing tab
thanks to a `beforeunload` listener.

Too much talk, let's see some code:

```ts
const registerBlockingRoute = ({
    shouldPrompt,
    customPromptBeforeLeaveModal,
    message = "Are you sure you want to leave? You will lose unsaved changes",
}: {
    shouldPrompt: () => boolean;
    customPromptBeforeLeaveModal: () => Promise<boolean>;
    message?: string;
}) => {
    const insideAppListener = async (
        event: NavigateEvent,
    ) => {
        // We do not intercept the navigation if:
        // - we should not
        // - if the navigation has already been catched 
        // `forceNavigate` to true in the `info`
        // - we do not should prompt
        if (
            !shouldNotIntercept(event) &&
            !event.info?.forceNavigate &&
            shouldPrompt()
        ) {
            event.preventDefault();
            const shouldContinue =
                await customPromptBeforeLeaveModal();

            // If the user wants to continue the navigation
            // and consequently loses the form data
            // let's do this
            if (shouldContinue) {
                window.navigation.navigate(
                    event.destination.url,
                    {
                        history: "push",
                        state: event.destination.state,
                        info: {
                            forceNavigate: true,
                            ...event.info,
                        },
                    },
                );
            }
        }
    };

    window.navigation.addEventListener(
        "navigate",
        insideAppListener,
    );

    const outsideAppListener = (
        event: BeforeUnloadEvent,
    ) => {
        if (shouldPrompt()) {
            event.preventDefault();
            return (event.returnValue = message);
        }
    };

    // Add event listener, for:
    // - reload of page
    // - going to other origin
    // - closing tab
    window.addEventListener(
        "beforeunload",
        outsideAppListener,
    );

    // Return unregister callback
    return () => {
        window.navigation.removeEventListener(
            "navigate",
            insideAppListener,
        );
        window.removeEventListener(
            "beforeunload",
            outsideAppListener,
        );
    };
};
```

The next step is to connect this implementation to your favorite library/framework :)
For me it will be **React**. But this adventure will be for a next article.

If you can't wait for the article you can look at [my repository](https://github.com/romain-trotard/routing-lib-navigation-api).

----

## Conclusion

Nowadays, building agnostic libraries of all library/framework (React, Vue, Svelte, ...) is something that is done more and more. For example with all **TanStack libraries**. 
**Adapters** are then developed to connect them to your core library.
In this article we have developed the abstraction. The magic is to expose a **subscribe** method to listen state changes from the adapter. 
In a next article we will see how to connect it to React.

If you want to play with the application you can go to [this link](https://routing-lib-navigation-api-1w6k.vercel.app/).

----

Do not hesitate to comment and if you want to see more, you can follow me on [Twitch](https://twitch.tv/rainbowapps ) or go to my [Website](https://www.romaintrotard.com). And here is a little link if [you want to buy me a coffee](https://ko-fi.com/romaintrotard) :coffee: