React Router v.6 usePrompt TypeScript
Asked Answered
K

7

6

I am basically trying to intercept route changes. Maybe something equivalent of vue's beforeEach in React Router v6 could be useful as React Router v.6 does not include usePrompt.

BEFORE each route change I want to do some logic - the logic might need to interrupt or even change the end route based on the result.

I have searched around but I really can't find something that solves this specific problem.

Thanks in advance.

Kierkegaardian answered 22/3, 2022 at 13:13 Comment(3)
react-router/react-router-dom doesn't have this functionality. Could you provide a minimal and complete code example of the Vue code, and your attempt at something similar in React we can help?Blunt
@DrewReese I cant provide an example. Basically I want to display a modal/alert when the user tries to leave a specific route. It seems it was possible in react-router v5 with Prompt/BlockerKierkegaardian
I see. Yes, currently that functionality has been excluded from RRDv6 (supposedly it will return, TBD), but I imagine you could replicate something close to it with a custom router & history object to listen for route changes, specifically the POP action, possibly combined with listening for the beforeunload event.Blunt
K
17

Currently they have removed the usePrompt from the react-router v6.

I found a solution from ui.dev and added TypeScript support, and am now using that until the react-router will bring back the usePrompt/useBlocker hooks

import { History, Transition } from 'history';
import { useCallback, useContext, useEffect } from "react";
import { Navigator } from 'react-router';
import { UNSAFE_NavigationContext as NavigationContext } from "react-router-dom";

type ExtendNavigator = Navigator & Pick<History, "block">;
export function useBlocker(blocker: (tx: Transition) => void, when = true) {
    const { navigator } = useContext(NavigationContext);

    useEffect(() => {
        if (!when) return;

        const unblock = (navigator as ExtendNavigator).block((tx) => {
            const autoUnblockingTx = {
                ...tx,
                retry() {
                    unblock();
                    tx.retry();
                },
            };

            blocker(autoUnblockingTx);
        });

        return unblock;
    }, [navigator, blocker, when]);
}

export default function usePrompt(message: string, when = true) {
    const blocker = useCallback((tx: Transition) => {
        if (window.confirm(message)) tx.retry();
    }, [message]);

    useBlocker(blocker, when);
}

This can then be used in any view/component where you would like a "A you sure you want to leave?"-message displayed when the condition is true.

usePrompt("Do you want to leave?", isFormDirty());
Kierkegaardian answered 23/3, 2022 at 12:23 Comment(5)
Hey bro, is there anyway to not using any? Cause my eslint rule (no-explicit-any).Theocritus
@Theocritus You can remove the as any casting, if you import the correct Navigator type. I didnt initially see that you need to import that type from react-router, but i just checked and that should work. I have updated my answer accordinglyKierkegaardian
I tried implementing this but Transition no longer exists on 'history'Spheno
@Spheno Cant see anything about that mentioned in the 5.3 changelog for "history" (I used 5.2 version of the npm package "history" and that worked fine. What version are you using?Kierkegaardian
@Kierkegaardian I had version 4.9.0. Updated to latest and the error is gone. Thx!Spheno
E
3

Using the other solutions listed here against react-router v6.8.0, I got errors stating that navigator.block is undefined when I tried pass a true into the second argument of usePrompt

After a little digging on the react-router GitHub pages, I found the maintainer's docs explaining why the changes happened but I also found this little gem pushed in recently.

While there currently isn't a full-fledged feature in the library, the unstable_useBlocker func can get the job done.

Here's a rough example that worked for me:

import { useEffect, useState } from 'react';
import { unstable_useBlocker } from 'react-router-dom';

const MyComponent = () => {
  const [shouldBlockLeaving, setShouldBlockLeaving] = useState<boolean>(false);

  const blocker = unstable_useBlocker(shouldBlockLeaving);

  if (blocker.state === 'blocked') {
    if (window.confirm('You have unsaved changes, are you sure you want to leave?')) {
      blocker.proceed?.();
    } else {
      blocker.reset?.();
    }
  }

  // reset the blocker if the user cleans the form
  useEffect(() => {
    if (blocker.state === 'blocked' && !shouldBlockLeaving) {
      blocker.reset();
    }
  }, [blocker, shouldBlockLeaving]);

  return (<>
    ...
  </>)
} 

Note that this is an incomplete example because I have nothing setting the shouldBlockLeaving state to true, but hopefully this is easy to extrapolate from.

Here's a more thorough example on Stackblitz

Elwandaelwee answered 25/3, 2023 at 7:2 Comment(3)
unstable_useBlocker doesn't handle browser triggered events anymore. Have you found any way to prevent refreshing or closing tab that doesn't use event onbeforeunload that is not implemented in all browser?Accrescent
No, unfortunately, but from my testing the original code using <Prompt /> I was migrating from had this exact same limitation, so it wasn't really a loss of functionality for my case, just continuation of the same "good enough for now" solution. There's a lot of chatter on the react-router GitHub page about this feature being re-added so I'm really hoping a future version addresses the problem you cited. github.com/remix-run/react-router/issues/8139Elwandaelwee
This is pretty cool. I modified to blocker.reset() everytime when shouldBlockLeaving is true and stored the target location in redux from blocker.location.pathname. Created a custom confirm dialog and if the user confirmed, then navigated to the stored location.Neral
D
1

Since React Router v6.7.0, the unstable_usePrompt hook is available. It requires using a data router, e.g. by calling createBrowserRouter (which is recommended for all new React Router web projects).

The documentation provides the following example:

function ImportantForm() {
  let [value, setValue] = React.useState("");

  // Block navigating elsewhere when data has been entered into the input
  unstable_usePrompt({
    message: "Are you sure?",
    when: ({ currentLocation, nextLocation }) =>
      value !== "" &&
      currentLocation.pathname !== nextLocation.pathname,
  });

  return (
    <Form method="post">
      <label>
        Enter some important data:
        <input
          name="data"
          value={value}
          onChange={(e) => setValue(e.target.value)}
        />
      </label>
      <button type="submit">Save</button>
    </Form>
  );
}

However, useBlocker (which also requires a data router) is recommended instead for providing consistency across browsers as it enables handling how the confirmation message is styled. As of React Router v6.19.0, it no longer has the unstable prefix.

unstable_usePrompt is expected to retain its unstable_ prefix, as the documentation states:

We do not plan to remove the unstable_ prefix from this hook because the behavior is non-deterministic across browsers when the prompt is open, so React Router cannot guarantee correct behavior in all scenarios. To avoid this non-determinism, we recommend using useBlocker instead which also gives you control over the confirmation UX.

Dualpurpose answered 10/5 at 22:9 Comment(0)
F
0

useBlocker hook has been reintroduced and deemed stable as of version 6.19. Official docs here.

Forethought answered 25/12, 2023 at 14:56 Comment(1)
That is true. However you are forced to use react-router's newer versions of routers if you want to use this. When wrapping your app in the previously standard <BrowserRouter>, you will get this error: useBlocker must be used within a data router. See https://reactrouter.com/routers/picking-a-router.Kierkegaardian
R
0

Block Navigation

const [isChangesMade, setIsChangesMade] = useState(false);

const blocker = useBlocker(isChangesMade)


  useEffect(() => {
    if (blocker.state === 'blocked' && isChangesMade) {
      setCustomDisplayModal(true);
    }
    if (blocker.state === 'blocked' && !isChangesMade) {
      blocker.reset?.();
    }
    //return () => blocker.reset?.();
  }, [blocker, isChangesMade]);

The "isChangedMade" can be replaced with any type of conditional, either from an expression, redux state, etc

The useEffect monitors when to trigger the block mechanism. The setCustomDisplayModal is then used to display a modal (Custom Component) to prevent clicking away from the page.

Hard Refresh

The blocker is not enough to prevent the hard refresh from occuring, to handle this scenario, you need the beforeunload event to display a default browser message about the unsaved changes.

  useEffect(() => {
    if (!isChangesMade) return;
    const handleBeforeUnload = (event: BeforeUnloadEvent) => {
      event.preventDefault();
    };

    window.addEventListener('beforeunload', handleBeforeUnload);
    return () => {
      window.removeEventListener('beforeunload', handleBeforeUnload);
    };
  }, [isChangesMade]);
Redistrict answered 14/6 at 8:16 Comment(0)
B
-1

Yes usePrompt and useBlock has been removed, but you can achieve same thing using history.block, here is the working example for blocking navigation using history.block with custom modal in React Router Dom V5

import { useHistory } from "react-router-dom";
import { UnregisterCallback } from "history";
...

type Prop = {
  verify?: {
    blockRoute?: (nextRoute: string) => boolean;
  }
};

...
// in the component where you want to show confirmation modal on any nav change

const history = useHistory();
const unblock = useRef<UnregisterCallback>();
const onConfirmExit = () => {
  /**
   * if user confirms to exit, we can allow the navigation
   */

  // Unblock the navigation.
  unblock?.current?.();
  // Proceed with the blocked navigation
  goBack();
};

useEffect(() => {
  /**
   * Block navigation and register a callback that
   * fires when a navigation attempt is blocked.
   */
  unblock.current = history.block(({ pathname: to }) => {
   /**
    * Simply allow the transition to pass immediately,
    * if user does not want to verify the navigate away action,
    * or if user is allowed to navigate to next route without blocking.
    */
   if (!verify || !verify.blockRoute?.(to)) return undefined;

   /**
    * Navigation was blocked! Let's show a confirmation dialog
    * so the user can decide if they actually want to navigate
    * away and discard changes they've made in the current page.
    */
   showConfirmationModal();
   // prevent navigation
   return false;
});

// just in case theres an unmount we can unblock if it exists
  return unblock.current;
}, [history]);

Betseybetsy answered 20/6, 2022 at 6:50 Comment(0)
P
-3

Here is a JS example of the react-route-dom v6 usePrompt if you're not using TS.

import { useContext, useEffect, useCallback } from 'react';
import { UNSAFE_NavigationContext as NavigationContext } from 'react-router-dom';

export function useBlocker( blocker, when = true ) {
    const { navigator } = useContext( NavigationContext );

    useEffect( () => {
        if ( ! when ) return;

        const unblock = navigator.block( ( tx ) => {
            const autoUnblockingTx = {
                ...tx,
                retry() {
                    unblock();
                    tx.retry();
                },
            };

            blocker( autoUnblockingTx );
        } );

        return unblock;
    }, [ navigator, blocker, when ] );
}

export function usePrompt( message, when = true ) {
    const blocker = useCallback(
        ( tx ) => {
            // eslint-disable-next-line no-alert
            if ( window.confirm( message ) ) tx.retry();
        },
        [ message ]
    );

    useBlocker( blocker, when );
}

Then the implementation would be...

const MyComponent = () => {
    const formIsDirty = true; // Condition to trigger the prompt.
    usePrompt( 'Leave screen?', formIsDirty );
    return (
        <div>Hello world</div> 
    );
};

Here's the article with the example

Preordain answered 12/7, 2022 at 17:40 Comment(2)
Sorry about that...was having trouble with the formatting. Supporting references added and link updatedPreordain
Uncaught TypeError: navigator.block is not a functionBeecher

© 2022 - 2024 — McMap. All rights reserved.