DEV Community

Cover image for Master the Art of Navigation: Building a Library with the Navigation API
Romain Trotard
Romain Trotard

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

Master the Art of Navigation: Building a Library with the Navigation API

In my previous article we have seen what is the new Navigation Web API.

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

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.


Backbone code

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

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

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.

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

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 :)

<a href="/otherAppPage">Go to other app page</a>
Enter fullscreen mode Exit fullscreen mode

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:

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

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:

type Route = {
    // The pathname of the route
    path: string;
    // The content for the route
    component: JSX.Element;
};
Enter fullscreen mode Exit fullscreen mode

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

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

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

type RouterState = {
    // The route matching the pathname
    matchingRoute: Route | null;
    // The current pathname
    pathname: string;
};
Enter fullscreen mode Exit fullscreen mode

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:

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

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.

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

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:

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

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

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

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

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

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.

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

Click here to see full code
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,
    };
}

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:

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

The implementation is really easy:

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

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:

window.navigation.navigate(event.destination.url, {
    history: "push",
    state: event.destination.state,
    info: { forceNavigate: true, ...event.info },
});
Enter fullscreen mode Exit fullscreen mode

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:

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

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.


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.


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

Oldest comments (0)