Skip to content

Instantly share code, notes, and snippets.

@chaance
Last active August 5, 2024 18:44
Show Gist options
  • Save chaance/2f3c14ec2351a175024f62fd6ba64aa6 to your computer and use it in GitHub Desktop.
Save chaance/2f3c14ec2351a175024f62fd6ba64aa6 to your computer and use it in GitHub Desktop.
Example implementation of `usePrompt` and React Router v5's `<Prompt>` with `unstable_useBlocker`
/**
* ------------------------------------------------------------------------------
* IMPORTANT UPDATE:
* This is *not* a complete implementation and I do not suggest using it!. This
* was primarily an experiment to determine whether or not a decent blocking
* hook could be implemented in userland, and the answer we came up with is NO.
*
* Luckily we added `usePrompt` (behind an `unstable_` flag) back to React Router
* a few versions ago! It's not documented [and I'm no longer on the team, so I
* probably won't try to do anything about that], but you can see it in source.
* https://github.com/remix-run/react-router/blob/react-router-dom%406.15.0/packages/react-router-dom/index.tsx#L1460
*
* Also, it's unstable because browser APIs are not quite consistent or robust
* enough for reliable navigation blocking, so it's pretty much impossible to
* implement this hook without some weird behavior. This is why it wasn't
* originally added to v6, but the team determined that it was important to
* provide the best implementation we could for users who needed better
* compatibility with v5 and a smooth upgrade path.
*
* For more context:
* https://github.com/remix-run/react-router/issues/8139#issuecomment-1396078490
*
* Example usage:
*
* ```ts
* import { unstable_usePrompt as usePrompt } from "react-router-dom";
*
* usePrompt({
* when: formIsDirty,
* message: "You have unsaved changes. Are you sure you want to leave?",
* });
* ```
* ------------------------------------------------------------------------------
*/
import * as React from "react";
import {
useBeforeUnload,
unstable_useBlocker as useBlocker,
} from "react-router-dom";
// You can abstract `useBlocker` to use the browser's `window.confirm` dialog to
// determine whether or not the user should navigate within the current origin.
// `useBlocker` can also be used in conjunction with `useBeforeUnload` to
// prevent navigation away from the current origin.
//
// IMPORTANT: There are edge cases with this behavior in which React Router
// cannot reliably access the correct location in the history stack. In such
// cases the user may attempt to stay on the page but the app navigates anyway,
// or the app may stay on the correct page but the browser's history stack gets
// out of whack. You should test your own implementation thoroughly to make sure
// the tradeoffs are right for your users.
function usePrompt(message, { beforeUnload } = {}) {
let blocker = useBlocker(
React.useCallback(
() => (typeof message === "string" ? !window.confirm(message) : false),
[message]
)
);
let prevState = React.useRef(blocker.state);
React.useEffect(() => {
if (blocker.state === "blocked") {
blocker.reset();
}
prevState.current = blocker.state;
}, [blocker]);
useBeforeUnload(
React.useCallback(
(event) => {
if (beforeUnload && typeof message === "string") {
event.preventDefault();
event.returnValue = message;
}
},
[message, beforeUnload]
),
{ capture: true }
);
}
// You can also reimplement the v5 <Prompt> component API
function Prompt({ when, message, ...props }) {
usePrompt(when ? message : false, props);
return null;
}
import * as React from "react";
import {
useBeforeUnload,
unstable_useBlocker as useBlocker,
} from "react-router-dom";
// You can abstract `useBlocker` to use the browser's `window.confirm` dialog to
// determine whether or not the user should navigate within the current origin.
// `useBlocker` can also be used in conjunction with `useBeforeUnload` to
// prevent navigation away from the current origin.
//
// IMPORTANT: There are edge cases with this behavior in which React Router
// cannot reliably access the correct location in the history stack. In such
// cases the user may attempt to stay on the page but the app navigates anyway,
// or the app may stay on the correct page but the browser's history stack gets
// out of whack. You should test your own implementation thoroughly to make sure
// the tradeoffs are right for your users.
function usePrompt(
message: string | null | undefined | false,
{ beforeUnload }: { beforeUnload?: boolean } = {}
) {
let blocker = useBlocker(
React.useCallback(
() => (typeof message === "string" ? !window.confirm(message) : false),
[message]
)
);
let prevState = React.useRef(blocker.state);
React.useEffect(() => {
if (blocker.state === "blocked") {
blocker.reset();
}
prevState.current = blocker.state;
}, [blocker]);
useBeforeUnload(
React.useCallback(
(event) => {
if (beforeUnload && typeof message === "string") {
event.preventDefault();
event.returnValue = message;
}
},
[message, beforeUnload]
),
{ capture: true }
);
}
// You can also reimplement the v5 <Prompt> component API
function Prompt({ when, message, ...props }: PromptProps) {
usePrompt(when ? message : false, props);
return null;
}
interface PromptProps {
when: boolean;
message: string;
beforeUnload?: boolean;
}
@abemedia
Copy link

abemedia commented Jul 3, 2023

Thanks @chaance!
What's the purpose of storing blocker.state in prevState? It doesn't appear to ever get used.
Also, I tested it without the useEffect which calls blocker.reset() and it seemed to work as expected. What is it needed for?

@pauldraper
Copy link

Yeah, I have all the same questions.

@chaance
Copy link
Author

chaance commented Aug 15, 2023

@pauldraper @abemedia Just FYI, this is not a complete implementation and you probably shouldn't use it. I added some comments to the Gist to provide context, and an example for how you can use the built-in usePrompt hook in React Router v6.7.0 and up.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment