Go to main content
April 5, 2023
Cover image

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.


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:

Tanner Linsley @tannerlinsley

When I build libraries for React, ironically, I don't really use hooks like useState, useReducer, etc. One of the best perks (and footguns) of managing your state *outside* of react is that you get to have full control over when a component should rerender.

Mar 18, 2022, 04:18 PM
619

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,
);

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

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

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 };
        },
    },
];

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

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

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

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

  return navigate;
}

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

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

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

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

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 .


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.