Customised confirm navigation prompt with React-Router v6
During the past month I had the opportunity to be a part of the team that migrated a large codebase from React Router v5 to the new React Router v6.0.0-beta.0.
Before starting the migration I thought it would be a piece of cake but it turned out to be very demanding and required attention to the detail.
I decided to focus on a specific step of the migration process regarding the custom prompt. A custom prompt should be displayed in the app when the user is trying to leave a page that has unsaved changes. The dialog that we used to display with React Router 5 looked like this:
Our goal was to display the same dialog as a Prompt after the migration to the new Router.
The new React Router 6 API provides a number of ways to display a prompt to the user when they are leaving a page.
usePrompt
usePrompt is a hook that can be used when we want to confirm navigation before the user navigates away from the current page. UnfortunatelyusePrompt
uses window.confirm dialog under the hood and we can only adjust the text that will be displayed.
usePrompt('Are you sure you want to leave?', isDirty);
Which results to this “beautiful” dialog
<Prompt />
The <Prompt>
component is the declarative version of usePrompt
. It doesn't render anything. It just calls usePrompt
with its props. It can come in handy when we are inside a Class component where hooks are not available
<Prompt message="Are you sure you want to leave?" when={isDirty} />
So the end result is the same as with the usePrompt
The main downside with the two solutions above is that the window.confirm dialog doesn’t look that pretty.
useBlocker
useBlocker
hook is the solution we are searching for when we want to create a custom Prompt dialog.
The documentation regarding the useBlocker
hook is very limited. In fact the documentation states
This is probably something you don’t ever want to do unless you also display a confirmation dialog to the user to help them understand why their navigation attempt was blocked.
Also, there aren’t many examples online that make use of the useBlocker
hook. It is after all still the beta version of the Router
Looking into the Typescript definitions of the useBlocker we see
function useBlocker(blocker: Blocker, when?: boolean): void;
The first parameter of useBlocker
(blocker: Blocker) is actually a function that is invoked by React Router before we leave the current page and only when then second parameter (when?: boolean) is true. Inside that function we are going to add all the code needed for opening the custom prompt.
…but first let’s see some history…
Prior to version 6 implementation
Feel free to skip this section if you are only interested in version 6 implementation.
With React Router 5 the way to set up the custom Prompt mechanism was to
1. Use the Prompt component provided by React Router inside a page
<Prompt
message={() => {
if (isDirty()) {
return {
title: "Warning!",
firstMsg: "You have entered data that will be lost if you exit the page",
secondMsg: "Are you sure?"
}
}
return true
}}
/>
When the url changes and the component is about to be unmounted the function we have passed to message property is invoked by React Router. At this point we check to see if the form is dirty (which means if we have unsaved data in this page. )
If the form is dirty, we return a custom object that will later be used by our custom Prompt component.
If the form is not dirty we return true
, the navigation is unblocked and the component is unmounted.
2. Set up the getUserConfirmation handler
function App() {
const { openModal } = useModal()
const getUserConfirmation = useCallback((messages, cb) => {
openModal({
name: "EXIT_ROUTE_CONFIRMATION",
data: { ...messages, cb }
})
},
[openModal]
) return (
<BrowserRouter getUserConfirmation={getUserConfirmation}>
..........
</BrowserRouter>
)
}
By providing the getUserConfirmation
handler we are able to open our custom Prompt when needed. React Router is invoking this function when the function we provided earlier to the message
property of the Prompt component returns the custom object.
Note: if the
message
function returns true then thegetUserConfirmation
handler will not be invoked
Inside the getUserConfirmation
function we call the openModal function that is our implementation for opening the prompt modal.
openModal({
name: "EXIT_ROUTE_CONFIRMATION",
data: { ...messages, cb }
})
We also pass as data to the Modal component the messages
object (containing the title
, firstMsg
, secondMsg
) as well as the callback function given to us by React Router. This callback function is our way to tell React Router that we want to stay or leave the page.
When we press the “Leave this page” button we are calling the cb
function from the Modal passing true
as an argument. Then React Router unblocks the navigation and we are presented with the new page.
When we press the “Stay on this page” button the cb function is called with false
as an argument. Then the modal is closed and we remain on the current page.
Version 6 implementation
In React Router 6 we have some breaking changes regarding this mechanism. The first and most important is that the getUserConfirmation
function is no longer a property of the BrowserRouter. The second is that the <Prompt />
component’s implementation is much different and doesn’t allow the use of a custom prompt dialog. So that leaves us just with the useBlocker
hook to do all the work for us now.
We ended up implementing a custom hook calleduseCustomPrompt
which internally makes use of the useBlocker
hook.
Let’s see first how this is used:
useCustomPrompt(
{
title: "Warning!",
firstMsg: "You have entered data that will be lost if you exit the page",
secondMsg: "Are you sure?"
},
isDirty
)
The usage is intentionally very similar to the <Prompt />
component of the previous React Router version. The first argument is the messages that will be displayed in the warning Prompt. The second argument defines if the prompt should be displayed when leaving the page or not and it can either be a boolean or a function that returns a boolean.
Note: The reason we want the second argument to be a function that returns a boolean is that we want this decision to be made only when we try to leave the page. Checking if the form is dirty can be a heavy task and shouldn’t be performed on each render cycle.
The useCustomPrompt hook
Voila the implementation…
function useCustomPrompt(messageObj, shouldPrompt) {
const { openModal } = useModal()
const [confirmedNavigation, setConfirmedNavigation] = useState(false) const retryFn = useRef(() => {}) useEffect(() => {
if (confirmedNavigation) {
retryFn.current()
}
}, [confirmedNavigation])
const handleBlockNavigation = ({ retry }) => {
const shouldDisplayPrompt =
typeof shouldPrompt === "boolean" ? shouldPrompt : shouldPrompt()
if (shouldDisplayPrompt) {
openModal({
name: "EXIT_ROUTE_CONFIRMATION",
data: {
...messageObj,
cb: (leaveRoute: Boolean) => {
if (leaveRoute) {
setConfirmedNavigation(true)
retryFn.current = retry
}
}
}
})
} else {
retry()
}
}
useBlocker(handleBlockNavigation, !confirmedNavigation)
}
Let’s break it down into pieces in order to understand how this works.
The main logic is implemented in handleBlockNavigation
.
First of all we check if the form is dirty or not
const shouldDisplayPrompt =
typeof shouldPrompt === "boolean" ? shouldPrompt : shouldPrompt()
Depending on this we either open the warning modal and block the navigation or we allow the navigation to be performed.
If shouldDisplayPrompt
is false
we call the retry
method that is provided to us by the React Router and the navigation is continued normally.
} else {
retry()
}
If shouldDisplayPrompt
is true
we open the modal passing as arguments the messages that need to be displayed and a callback function.
data: {
...messageObj,
cb: (leaveRoute: Boolean) => {
if (leaveRoute) {
setConfirmedNavigation(true)
retryFn.current = retry
}
}
}
The cb
function will be called with true by the Modal component if the user confirms that they want to leave the page, or with false if they don’t. This way we keep the implementation of our custom prompt the same during this migration.
If they decide that they want to leave the page then we allow them to do so with a — not so pretty — hack…
if (leaveRoute) {
setConfirmedNavigation(true)
retryFn.current = retry
}
At this point I should mention that simply calling
retry
at this point doesn’t work. I am not certain why this happens. I would appreciate any reasoning on this.
The way to leave the current route is by setting the second parameter of useBlocker to be false
useBlocker(handleBlockNavigation, !confirmedNavigation)
and then calling the retry function again. (we previously stored its reference in a useRef retryFn.current = retry
)
useEffect(() => {
if (confirmedNavigation) {
retryFn.current()
}
}, [confirmedNavigation])
That’s it! We now have a working prompt modal that is displayed to user if the form is dirty.
Downside
This solution comes with an issue that I couldn’t overcome.
If we are in a page where we use the useCustomPrompt
hook and try to refresh the page or manually change the url on the browser (these actions are out of the control of React Router) then our custom modal is not displayed and we get these warnings.
The handleBlockNavigation
function doesn’t get invoked and these warnings are block the user even if we haven’t made any changes to the page.
The reason is that in order to be able to go through the handleBlockNavigation
function at all times and check our form for unsaved data, we need to keep the when
parameter of useBlocker true. When we try to navigate away through these ways, the handleBlockNavigation
doesn’t get invoked and this message is displayed only based on the when
parameter.
Final thoughts
The migration to the new React Router v6 was an interesting experience and we got our hands on some interesting new features. Really appreciate the effort put and the quality produced by the maintainers!
Implementing the custom Prompt functionality using the useBlocker
hook wasn’t the best developer experience we could have and also we introduced an issue that we couldn’t overcome. I like though how the useCustomPrompt works and hope to get the chance to iron out those details.
I think that if the when parameter ofuseBlocker
could also be a function that returns a boolean, the issue with the browser’s window.confirm dialogs would go away.
Please comment if you have found another solution to implementing a custom Prompt with the new React Router.
Special thanks to my teammate @gadagalakis with whom I paired programmed the solution and to Kostas Kapetanakis (@kapekost) for encouraging me on writing this.
Also this is my first post on Medium so try to be lenient with me…
I hope to get the chance to share soon some more challenges and solutions we are finding along this great journey of programming!
Thank you!