8

My app has two pages: Step1 and Step2. Step1 has a checkbox that blocks navigation if it is checked and a Next button that navigates to Step2 when clicked. Step2 has a Previous button that navigates back to Step1 when clicked.

Link to demo

As per this tutorial, I'm using the block method of the createBrowserHistory object to block route changes if the checkbox in Step1 is checked:

const unblock = useRef();

  useEffect(() => {
    unblock.current = history.block((tx) => {
      if (block.current.checked) {
        const promptMessage = "Are you sure you want to leave?";

        if (window.confirm(promptMessage)) {
          unblock.current();
          tx.retry();
        }
      } else {
        console.log("tfgx");
        unblock.current();
        tx.retry();
      }
    });
  }, []);

I also had to set the history prop in the low-level <Router> (not <BrowserRouter>) to the createBrowserHistory object, like so:

<Router history={createBrowserHistory()}>
...
</Router>

But this prevents the routes from being rendered properly. I think this may have something to do with <Switch> not being able to read the location object properly. If I use <BrowserRouter>, the location object looks like this: {pathname: "/step1", ... key: "7sd45"}. But when I use <Router={createBrowserHistory()}>, the location object looks like this {action: "PUSH", location: {pathname: "/step1", ... key: "7sd45"}}. (I'm also getting the warning You cannot change <Router history>.)

My desired result is to block navigation if the 'Block navigation' checkbox is checked and unblock it otherwise. If the location changes when navigation is unblocked, I would like the corresponding route to be rendered correctly.

The section on createBrowserHisory in the React Router v5 docs is sparse and there aren't many examples that make use of it, so I'd be grateful if someone could shed some light on this.


EDIT: Passing location.location to <Switch> seems to fix it (Updated demo). But if I call useLocation inside Step1 and print the result (line 17-18), I get {pathname: "/step1", ... key: "7sd45"} and not {action: "PUSH", location: {pathname: "/step1", ... key: "7sd45"}}. Why is this?

Also, if the user attempts to go to another location when navigation is blocked, my custom prompt appears as expected ("Are you sure you want to leave" with "OK" and "Cancel" buttons). However, if they dismiss this by clicking Cancel, then the browser's own dialog box appears -

In Chrome:

enter image description here

In Firefox:

enter image description here

Is it possible to suppress the browser prompt after my prompt has been dismissed?

user51462
  • 1,658
  • 2
  • 13
  • 41
  • I think you need to return `true` or `false` from the `history.block` callback to indicate behavior. `false` to block or let the browser take default action(s), `true` to not block. – Drew Reese Jan 01 '21 at 01:02
  • @DrewReese Are you able to link a demo? I tried returning `true`/`false` but then the route transition isn't reattempted if the user clicks "OK" since `tx.retry()` isn't called. – user51462 Jan 01 '21 at 01:17
  • 1
    Sort of. Best demo I have is from an [answer](https://stackoverflow.com/a/65515145/8690857) of mine to a question yesterday. I saw the same docs but I was unable to get the `tx` and retry stuff to work. I definitely agree that the docs are very sparse on this subject. I even found that `block` will be removed in a future version since the authors of react-router-dom claim it's never worked well and tends to cause more issues than it solves. – Drew Reese Jan 01 '21 at 01:22
  • Thank you Drew, your answer works for me ([demo](https://codesandbox.io/s/react-router-createbrowserhistory-w-historyblock-2-8hks5?file=/example.js)). Why isn't `retry` required when we use React Router's history object? – user51462 Jan 01 '21 at 01:51
  • Also, why aren't reloads blocked when 'Block navigation' is checked? When I use ``, reloading `/step1` when the checkbox is checked causes the browser's "leave page" prompt to appear (see [here](https://codesandbox.io/s/react-router-createbrowserhistory-w-historyblock-zoybv?file=/example.js)) but this isn't the case when I use `` as in your approach (see [here](https://codesandbox.io/s/react-router-createbrowserhistory-w-historyblock-2-8hks5?file=/example.js)). – user51462 Jan 01 '21 at 01:51

2 Answers2

31

The router context's history object also has a block function but it works a little differently. It takes a callback that consumes location and action arguments.

history.block((location, action) => {...});

Returning false from the callback blocks the navigation transition, returning true allows the transition to go through.

React.useEffect(() => {
  const unblock = history.block((location, action) => {
    if (checkBlockingCondition) {
      return window.confirm("Navigate Back?");
    }
    return true;
  });

  return () => {
    unblock();
  };
}, []);

Alternatively, react-router-dom suggests using the Prompt component to conditionally block route transitions. Your code is very close to their preventing transitions example.

Updates to your last codesandbox:

  1. Use blocking state versus react ref so the prompt rerenders and reevaluates the condition.
  2. Render a Prompt component.
  3. Prevent the default form submit action, i.e. to prevent the page from reloading.

code

import {
  BrowserRouter as Router,
  Prompt, // <-- import Prompt
  Redirect,
  Switch,
  Route,
  useLocation
} from "react-router-dom";

const Step1 = ({ id, history }) => {
  const [isBlocking, setIsBlocking] = useState(false);

  return (
    <form
      id={id}
      onSubmit={(e) => {
        e.preventDefault(); // <-- prevent default form action, i.e. page reload
        history.push("/step2");
      }}
    >
      <label>
        Block navigation
        <input
          type="checkbox"
          onChange={(e) => setIsBlocking(e.target.checked)}
        />
      </label>
      <br />
      <br />
      <button type="submit">Next</button>
      <Prompt
        when={isBlocking} // <-- blocking condition
        message="Are you sure you want to leave?"
     />
    </form>
  );
};

Edit react-router-v5-2-blocking-route-change-with-createbrowserhistory-and-history

Drew Reese
  • 165,259
  • 14
  • 153
  • 181
  • 4
    How can i use the first part of your answer with TypeScript? I can't use `true` when i want to allow the transition, because the return value of the `block` function callback must be either a `string`, `false` or `void`. Am i missing something? – knoefel Jun 27 '21 at 11:00
  • @knoefel I'm not extremely familiar with Typescript but I suspect you've misunderstood something since `false` isn't a return type, it's a value. A boolean return type would/should cover both true and false. Do you have a link to any `history` documentation that covers the return typing, or a running typescript sandbox that provides a bit more context? – Drew Reese Jun 27 '21 at 19:02
  • how can i block to go back on avery single page? – Dimer Bwimba Jul 28 '21 at 07:50
  • @DimerBwimba You could use the `history.block` between the `Router` and rendering of your `Route` components, or you could possibly create a custom route component using either of the above mentioned methods. – Drew Reese Jul 30 '21 at 23:17
  • 1
    This is a great answer. One note: History is now at v5, but React Router v5.2 uses History v4.9, so the APIs are different if you try to look at the History docs for reference. – ericgio Aug 17 '21 at 08:20
  • Chrome will deprecate JS blocker dialogs (not just in cross-origin iframes) so react-router Prompt and window.confirm() aren't future proof unfortunately. – Zoltan Szokodi Nov 04 '21 at 10:43
  • 1
    @ZoltanSzokodi I think you are partially correct. Chrome did actually deprecate these dialogs a few [versions back and it completely broke many websites, so they were forced to reinstate the alert/confirm dialogs](https://developer.chrome.com/blog/deps-rems-91/), albeit only temporarily. – Drew Reese Nov 04 '21 at 16:23
  • @DrewReese why do u use unblock func only on unmount (in return in useEffect)? – deathfry Sep 28 '22 at 21:46
  • @deathfry Because that is what the OP was using and there was only a need to create the blocker once for when the component unmounts, i.e. like navigating away from the route the component is rendered on. You could certainly have some state to conditionally block the navigation that is used as a dependency much like the `Prompt` component checks. – Drew Reese Sep 28 '22 at 22:03
  • @DrewReese consider I have a modal with such a blocking function. Anyway I should add it only to return statement in useEffect? Or useEffect(() => { checkForBlock(); return () => { checkForBlock(); }; }, []); is ok? – deathfry Sep 28 '22 at 22:07
  • @deathfry That seems likely to be incorrect on the surface. Generally speaking a `useEffect` hook cleanup function *shouldn't* run the same logic as the hook callback function. This sounds like you might have a good question to ask. Feel free to create a new post on SO and ping me here with a link to it and I can take a look when available. – Drew Reese Sep 28 '22 at 22:13
  • @DrewReese already did ) https://stackoverflow.com/questions/73888304/using-history-block-causing-warning-a-history-supports-only-one-prompt-at-a-tim – deathfry Sep 28 '22 at 22:21
4

To do this in typescript, you could use a ternary based on your "dirty" condition to show the prompt before navigating to your next route.

As long as the component this goes in is inside of a a routing context, it will work.

isChecked is a psuedo-code variable to be replaced by whatever condition you want to evaluate on leaving the page. I recommend having it in the dependencies because when the state variable updates, so will the block condition.

  const history = useHistory();

  useEffect(() => {
    const unblock = history.block(
      !isChecked
        ? "You’ve got unsaved changes. Are you sure you want to navigate away from this page?"
        : true
    );

    return function cleanup() {
      unblock();
    };
  }, [isChecked]);
krieger
  • 41
  • 1
  • This example is very precise, thks – testing_22 Feb 12 '23 at 05:13
  • This has nothing to do with TypeScript, other than being on a modern enough version of JS to have ternaries, right? – Julix May 16 '23 at 11:37
  • 1
    @Julix The underlying typings are `string | boolean | TransitionPromptHook | undefined` so this would satisfy the problem @knoefel was having with the callback not returning the correct typings. – krieger Jun 01 '23 at 12:38
  • just a small feedback on the naming, "unblock" is a little misleading. it should be named as 'unlistenBlocker' or 'unlistenBlockChecker' – Phạm Huy Phát Jun 09 '23 at 02:50