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:
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 PMOkay, 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.