React routing library with Navigation API
Tags: react, javascript, tutorial
Published: 2023-04-05 17:23:50

In the previous articles, we saw [what is the new Navigation API](https://dev.to/romaintrotard/back-to-the-future-navigation-api-4pil) and [how to make an agnostic library with it](https://dev.to/romaintrotard/master-the-art-of-navigation-building-a-library-with-the-navigation-api-6g).
It's now time to connect the library with **React**.

> **Note:** Thanks to the agnostic way the libray is done, we could connect it to any framework.

----

## useSyncExternalStore

The **`useSyncExternalStore`** hook enable to subscribe to an external store. 

_Why would we want to manage data outside of React?_

- when you build an agnostic library you have to store the data outside of React.

- if you build a library exclusively for React, storing the data outside of React gives you full control of when your data change and when your components render. **Tanner Linsley** made an interesting tweet about it:

{% twitter 1504854824952610818 %}

----

## React implementation

Okay, let's make it concrete!

Thanks to the `router` instance created with the `createBrowserRouter` function we can subscribe to data changes with `router.subscribe` and then get data snapshot with `router.data` which gives use:

```ts
const state = useSyncExternalStore(
    router.subscribe,
    () => router.state,
);
```

We are going to put this `state` data in a React context to be able to access to it, anywhere in the tree below, thanks to custom hooks that we are going to make.

> **Note:** If you want to know more about React context performances, go read my article [React context, performance?](https://dev.to/romaintrotard/react-context-performance-5832).

```tsx
type RouterType = ReturnType<typeof createBrowserRouter>;

const RouterDataContext =
    React.createContext<RouterState | null>(null);

function RouterProvider({
    router,
}: { router: RouterType }) {
    const state = useSyncExternalStore(
        router.subscribe,
        () => router.state
    );

    return (
        <RouterDataContext.Provider value={state}>
            {
                // If the application is not initialized yet,
                // let's show a placeholder
            }
            {state.initialized ? (
                state.matchingRoute?.component ?? null
            ) : (
                <p>Loading the application</p>
            )}
        </RouterDataContext.Provider>
    );
}
```

And the hook to access this context, because I'm not a big to expose the **context object** directly and it allows us to throw nice Error if not wrapped with the **Provider**:

```ts
const useRouterDataContext = () => {
    const contextValue = useContext(RouterDataContext);

    if (contextValue === null) {
        throw new Error(
            "Should put a `RouterProvider`" +
                " at the top of your application",
        );
    }

    return contextValue;
};
```

----

## Getting loader data

Let's make a quick reminder of what is the **loader data** with some code:

```ts
const routes: Routes = [
    {
        path: "/listing",
        component: <ListingPage />,
        // It will return data returned by this function
        loader: async () => {
            const libraries = await getLibraries();

            return { libraries };
        },
    },
];
```

Thanks to the previous hook it's easy to build `useLoaderData` that allows use to get this data:

```ts
function useLoaderData<T>() {
    const state = useRouterDataContext();

    return state.loaderData as T;
}
```

----

## navigate an prompt before leave features

Okay we can get the loader data but what about `navigate` and show a prompt before leave modal if there are some unsaved data before leaving the page.

We can get this thanks to the `router` instance but it's not really convenient. I propose to put theses methods directly in another context.

> **Note:** we could also put the router instance in a context! I just restrict what is accessible.

```tsx
type RouterType = ReturnType<typeof createBrowserRouter>;

type RouterContextProps = Pick<
    RouterType,
    "navigate" | "registerBlockingRoute"
>;

const RouterContext =
    React.createContext<RouterContextProps | null>(null);

function RouterProvider({
    router,
}: { router: RouterType }) {
    const state = useSyncExternalStore(
        router.subscribe,
        () => router.state,
        () => router.state,
    );

    // Expose `navigate` and `registerBlockingRoute`
    const routerContextValue = useMemo(
        () => ({
            navigate: router.navigate,
            registerBlockingRoute:
                router.registerBlockingRoute,
        }),
        [router.navigate, router.registerBlockingRoute],
    );

    return (
        <RouterContext.Provider value={routerContextValue}>
            <RouterDataContext.Provider value={state}>
                {state.initialized ? (
                    state.matchingRoute?.component ?? null
                ) : (
                    <p>Loading the application</p>
                )}
            </RouterDataContext.Provider>
        </RouterContext.Provider>
    );
}
```


{% details And here is the hook to get values from this context %} 

```ts
export const useRouterContext = () => {
    const contextValue = useContext(RouterContext);

    if (contextValue === null) {
        throw new Error(
            "Should put a `RouterProvider`" +
                " at the top of your application",
        );
    }

    return contextValue;
};

```

{% enddetails %}

It's now time to implement our 2 hooks `useNavigate` and `usePrompt`.

### useNavigate

Yep we are going to start with `useNavigate` implementation because it's really simple:

```ts
function useNavigate() {
    const { navigate } = useRouterContext();

    return navigate;
}
```

----

### usePrompt

We want to be able to pass the following props:
- the condition to display the modal
- a callback to display the modal
- the message to display (useful when going outside of the React application. In this case, the native browser prompt will be displayed)

```ts
// This hook will be expose by React in the future
function useEffectEvent(cb: Function) {
    const cbRef = useRef(cb);

    useLayoutEffect(() => {
        cbRef.current = cb;
    });

    return useCallback((...args) => {
        return cbRef.current(...args);
    }, []);
}

function usePrompt({
    when,
    promptUser,
    message = "Are you sure you want to leave?" +
        " You will lose unsaved changes",
}: {
    message?: string;
    when: boolean;
    promptUser: () => Promise<boolean>;
}) {
    const { registerBlockingRoute } = useRouterContext();
    const shouldPrompt = useEffectEvent(() => when);
    const promptUserEffectEvent =
        useEffectEvent(promptUser);

    useEffect(() => {
        return registerBlockingRoute({
            customPromptBeforeLeaveModal:
                promptUserEffectEvent,
            message: message,
            shouldPrompt,
        });
    }, [registerBlockingRoute, shouldPrompt]);
}
```

> **Note:** If you want to know more about `useEffectEvent`, you can read my article [useEvent: the new upcoming hook?](https://dev.to/romaintrotard/useevent-the-new-upcoming-hook-3482).


_And that's all?_
Well, I think it's important to see the `Prompt` component implementation, because there is a little "trick".

To do this I will use the `react-modal` library that allows use to build modal that is controlled.

The backbone is:

```tsx
function Prompt({
    when,
    message = "Are you sure you want to leave?" +
        " You will lose unsaved changes",
}: { when: boolean; message?: string }) {
    const [showModal, setShowModal] = useState(false);

    const promptUser = () => {
        setShowModal(true);

        // What should we return?
        // If we return Promise.resolve(true) 
        // it will be resolved directly :/
    };
    const closeModal = () => {
        setShowModal(false);
    };

    usePrompt({ when, promptUser, message });

    return (
        <Modal
            isOpen={showModal}
            onRequestClose={closeModal}
        >
            <span>Are you sure?</span>
            <span>{message}</span>
            <div>
                <button type="button" onClick={closeModal}>
                    Cancel
                </button>
                <button
                    type="button"
                    onClick={() => {
                        // How to tell to `usePrompt` that
                        // the user really wants to navigate
                        closeModal();
                    }}
                >
                    Confirm
                </button>
            </div>
        </Modal>
    );
}
```

There is nothing fancy here. We just control a modal to open it. But we didn't implement the `promptUser` totally and event handlers of both buttons too.

The navigation or not to the page is made asynchronously thanks to `Promise`.

If the user wants to navigate (and lose unsaved changes) we resolve this promise with `true` otherwise with `false`.

When we `promptUser`, let's create a `Promise` and store the resolve function in a **React ref**.

> **Note:** If you want to know more about React ref, you can read my article [Things you need to know about React ref](https://dev.to/romaintrotard/things-you-need-to-know-about-react-ref-25ld)

```ts
const promiseResolve = useRef<
    ((value: boolean) => void) | undefined
>(undefined);

const promptUser = () => {
    const promise = new Promise<boolean>((resolve) => {
        promiseResolve.current = resolve;
    });

    setShowModal(true);

    return promise;
};
```

And now thanks to the reference we can execute it with the right value.

```tsx
const closeModal = () => {
    // Do not forget to unset the ref when closing the modal
    promiseResolve.current = undefined;
    setShowModal(false);
};

const cancelNavigationAndCloseModal = () => {
    // Resolve with false
    promiseResolve.current(false);
    closeModal();
};

const confirmNavigationAndCloseModal = () => {
    // Resolve with true
    promiseResolve.current(true);
    closeModal();
};

return (
    <Modal
        isOpen={showModal}
        onRequestClose={cancelNavigationAndCloseModal}
    >
        <span>Are you sure?</span>
        <span>{message}</span>
        <div>
            <button
                type="button"
                onClick={cancelNavigationAndCloseModal}
            >
                Cancel
            </button>
            <button
                type="button"
                onClick={confirmNavigationAndCloseModal}
            >
                Confirm
            </button>
        </div>
    </Modal>
);
```

{% details Here is full code of the Prompt modal %}

```tsx
function Prompt({
    when,
    message = "Are you sure you want to leave?" +
        " You will lose unsaved changes",
}: { when: boolean; message?: string }) {
    const [showModal, setShowModal] = useState(false);
    const promiseResolve = useRef<
        ((value: boolean) => void) | undefined
    >(undefined);

    const promptUser = () => {
        const promise = new Promise<boolean>((resolve) => {
            promiseResolve.current = resolve;
        });

        setShowModal(true);

        return promise;
    };

    usePrompt({ when, promptUser, message });

    const closeModal = () => {
        // Do not forget to unset the ref when closing the modal
        promiseResolve.current = undefined;
        setShowModal(false);
    };

    const cancelNavigationAndCloseModal = () => {
        // Resolve with false
        promiseResolve.current(false);
        closeModal();
    };

    const confirmNavigationAndCloseModal = () => {
        // Resolve with true
        promiseResolve.current(true);
        closeModal();
    };

    return (
        <Modal
            isOpen={showModal}
            onRequestClose={cancelNavigationAndCloseModal}
        >
            <span>Are you sure?</span>
            <span>{message}</span>
            <div>
                <button
                    type="button"
                    onClick={cancelNavigationAndCloseModal}
                >
                    Cancel
                </button>
                <button
                    type="button"
                    onClick={confirmNavigationAndCloseModal}
                >
                    Confirm
                </button>
            </div>
        </Modal>
    );
}
```

{% enddetails %}

----

## Conclusion

Thanks to the `subscribe` method and `useSyncExternalStore` we can synchronize updates of the state with React.
If you want to play with the application using this mini library you can go on the [application using navigation API](https://routing-lib-navigation-api-1w6k.vercel.app/).
And the full code is available on my [`outing-lib-navigation-api` repository](https://github.com/romain-trotard/routing-lib-navigation-api).


----

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: