decisions/0001-use-blocker.md
Date: 2023-01-17
Status: accepted
React Router v5 had a <Prompt> component that allowed app developers to indicate when navigation should be blocked (via the when prop), and specify a string message to display in a window.confirm UI that would let the user confirm the navigation. The primary use case for this is preventing users from losing half-filled form info when navigating away.
The React Router v6 beta initially had two hooks to replace this component (useBlocker and usePrompt) but they were removed during the beta release process. The reasoning being:
As for why it was removed in v6, we decided we'd rather ship with what we have than take even more time to nail down a feature that isn't fully baked.
Folks then started adding this functionality back in manually using navigator.block via the UNSAFE_NavigationContext so they could upgrade their apps from v5 to v6 while keeping the blocking ability.
However, as part of the 6.4 data-routing work, we significantly streamlined and inlined the history library and removed the block method, causing issues for those using the UNSAFE_NavigationContext workaround. Although, there was still ways to achieve this via an unstable_HistoryRouter.
In September we commented back further advising our stance that storing data in localStorage was a preferable pattern to blocking.
Over time though, we did receive some valuable feedback which indicated some other use cases where blocking was useful, and stashing form info in localStorage might not be sufficient:
localStorageBased on the feedback, we decided to re-consider but with known limitations so that we weren't preventing users from benefitting from the awesome new features in 6.4 and beyond.
Having not used React Router v5, nor it's blocking functionality, I can only guess at what I think the main pain points were. Then we can look at how we might solve then in a v6 implementation.
Blocking PUSH/REPLACE navigations is generally straightforward - they come through history so we can deal with blockers before we call window.history.pushState, and if blocked skip the call all together. This means the URL and the UI remain synced.
Blocking POP navigations is different - since we don't know about them via popstate until after the URL has been updated - so we're immediately in an unsynced state. I.e., if we've navigated A -> B -> C and the user hits the back button - we evaluate our blockers while the UI shows C, but the url shows B. However, v5 had a way to handle that as well - by storing an index on the location.state we can determine what the popstate delta was and revert it if the navigation was blocked.
So what was the issue? I think it boiled down not to how to block but instead in when to retry. We exposed a retry function to userland as part of useBlocker and therefore we lost control over when that function might be called. A retry of a blocked POP navigation is inherently tightly-coupled to the current location oin the history stack. But by exposing retry, we could no longer ensure that retry was called from the right location. For example:
C, with a history stack of A -> B -> CB, and the navigation is blocked.C and provide a retry of () => pop(-1)B and then retry gets called, we land on A, not the B the original blocked transition intended to take us toSpecifically, part of the issue comes down to the fact that while window.confirm is synchronous on the JS thread, is does not prevent additional user interaction with the back/forward buttons. This causes issues with retry like the flow described above.
C, with a history stack of A -> B -> CB, and we show the window.confirm promptB, so this back button goes to A)window.confirm to return false (indicating we should block the C->B back button click) but it respects the new back button click!A, but our history library thinks we're on C since it thinks we blocked the original back button navigationIt's also worth noting that these popstate blockers don't work on navigations leaving your app - such as cross-origin or full document reloads. To handle those, you need to also wire up a beforeunload event listener on window. This does block further back-button clicks while it's open so it's not subject to the same issues as window.confirm above.
Having played around with some of our POC implementations in v6, I think we've identified a few assumptions we will need to make oin order to implement blocking in a reliable way.
popstate whether we even need to revert. In v5, we would automatically revert, then run the blocker, then maybe retry the navigation. In v6, non-blocked navigations are a no-op, and blocked navigations are immediately reverted which re-syncs the with the URL before any other navigations can happen.usePrompt because while the window.confirm function is synchronous, it does not block additional user-initiated navigations. Furthermore, browser behave very differently when it comes to back button clicks while a window.confirm prompt is open. Any attempt to support window.confirm in React Router will inevitable result in a table in our docs explaining why and how each browser behaves differently. This is a non-starter from a UX perspective in my eyes.retry functions are inherently stale and therefore calling them can only do more weird things.With these assumptions in mind, I think we can implement a fairly robust useBlocker hook in v that would suffice for the majority (if not all) known use-cases, and we could clearly document where this hook has rough edges. Any usage of window.confirm would be left to a userland implementation of usePrompt and all of the concerns that come with it are then part of the application and not React Router.
As part of the ongoing Github Discussion, Chance asked folks if they could elaborate on how they were using the <Prompt> component in v5 and specifically if they were using the getUserConfirmation prop to customize the experience away from window.confirm. As it turns out,. it seems the vast majority of folks were opting not to use window.confirm- either via getUserConfirmation or more often via a bit of a hacked implementation of <Prompt message={() => { ... }} />.
getUserConfirmation to avoid window.confirm
history.block usage
message prop function to trigger custom modals
In the end, there are maybe 1-2 folks who responded that use the simple window.confirm scenario, and instead almost all people are skipping window.confirm in favor of a custom dialog. I don't find this very surprising - knowing the look I'd have gotten from prior UX designers if I said that wa the UI were going to ship to our users 😉.
Some folks have mentioned that they don't it to block navigation, but instead to detect before a navigation happens for firing off analytics or what not. While useBlocker could be abused for this purpose, that will eventually be solved more accurately via the proposed Events API
The proposal for support in v6 is to implement a single low-level useBlocker hook that provides the user enough information to (1) show a custom confirmation alert/dialog/modal/etc. and (2) allow the navigation to proceed if the user accepts the dialog. This would only allow one active blocker at a time in the component tree, and would error or warn if a second useBlocker was encountered.
type Blocker =
| {
state: "unblocked";
reset: undefined;
proceed: undefined;
}
| {
state: "blocked";
reset(): void;
proceed(): void;
}
| {
state: "proceeding";
reset: undefined;
proceed: undefined;
};
declare function useBlocker(shouldBlock: boolean | () => boolean): Blocker;
function MyFormComponent() {
let [formIsDirty, setFormIsDirty] = React.useState(false);
let blocker = useBlocker(formIsDirty);
return (
<Form method="post" onChange={(e) => setFormIsDirty(true)}>
<label>
First name:
<input name="firstname" required />
</label>
<label>
Last name:
<input name="lastname" required />
</label>
<button type="submit">Submit</button>
{blocker.state === "blocked" ? (
<div>
<p>You have unsaved changes!<p>
<button onClick={() => blocker.reset()}>
Oh shoot - I need them keep me here!
</button>
<button onClick={() => blocker.proceed()}>
I know! They don't matter - let me out of here!
</button>
</div>
) : blocker.state === "proceeding" ? (
<p>Navigating away with unsaved changes...</p>
) : null}
</Form>
);
}
The blocker received by the user would be either unblocked, blocked, or proceeding:
unblocked is the normal idle stateblocked means the user tried to navigate and the blocker function returned true and the navigation was blocked. When in a blocked state the blocker would expose proceed/reset functions:
blocker.proceed() would allow the blocked navigation to happen (and thus lose unsaved changes). This proceed navigation would not re-run the blocker function.blocker.reset() would reset the blocker back to unblocked and remain on the current pageproceeding indicates the navigation from blocker.proceed() is in-progress - and essentially reflects the non-idle navigation.state during that navigationOther navigations and/or interruptions to proceeding navigations would reset the blocker back to an unblocked state.
We will not provide a usePrompt implementation, however it would be somewhat trivial to implement that on top of useBlocker in userland.
We decided in the end to include a usePrompt even though it's got more broken edge cases than useBlocker:
graph TD;
Unblocked -->|navigate| A{shouldBlock?};
A -->|false| Unblocked;
A -->|true| Blocked;
Blocked -->|blocker.proceed| Proceeding;
Blocked -->|Unblocked Navigation| Unblocked;
Blocked -->|blocker.reset| Unblocked;
Proceeding -->|Navigation Complete| Unblocked;
Proceeding -->|Navigation Interrupted| Unblocked;
v5 -> v6 BrowserRouter -> v6 RouterProvider
RouterProvider just as easily as a 6.3 BrowserRouterhistoryAction/location of the active navigation to shouldBlock() similar to how v5 did it. Should we also pass the submission (formMethod, formData, etc.)?
{ currentLocation, nextLocation, historyAction } to align naming loosely with shouldRevalidate. Can always extend that API ion the future if needed (with form submission info).usePrompt, we should accept a beforeUnload:boolean option to add cross-navigation handling in an opt-in fashion.
beforeUnload is also unreliable because it does not prevent the user from doing additional back/forward navigations ao this is not included out of the box and can be implemented in user-land.