DEV Community

Cover image for React routing library with Navigation API
Romain Trotard
Romain Trotard

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

React routing library with Navigation API

In the previous articles, we saw what is the new Navigation API and how to make an agnostic library with it.
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:


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:

const state = useSyncExternalStore(
    router.subscribe,
    () => router.state,
);
Enter fullscreen mode Exit fullscreen mode

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

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

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:

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

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

    return contextValue;
};
Enter fullscreen mode Exit fullscreen mode

Getting loader data

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

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

            return { libraries };
        },
    },
];
Enter fullscreen mode Exit fullscreen mode

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

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

    return state.loaderData as T;
}
Enter fullscreen mode Exit fullscreen mode

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.

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

And here is the hook to get values from this context
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;
};

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:

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

    return navigate;
}
Enter fullscreen mode Exit fullscreen mode

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

Note: If you want to know more about useEffectEvent, you can read my article useEvent: the new upcoming hook?.

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:

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

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

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

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

    setShowModal(true);

    return promise;
};
Enter fullscreen mode Exit fullscreen mode

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

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

Here is full code of the Prompt modal
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>
    );
}


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.
And the full code is available on my outing-lib-navigation-api repository.


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 (1)

Collapse
 
podarudragos profile image
Podaru Dragos

This is very very interesting, good work.
Waiting for part 4 ( nested routes )