useBlocker - Blocking Navigation In React Router

February 01, 2025

I typically don't like overriding user controls. When scrolling a website and I end up getting stuck on a section, it irritates me. When elements pop up all over the place and disrupt what I'm doing, I find it counter-intuitive and the website I'm using definitely goes down in my estimations.

So, when I recently implemented a navigation blocker in React, it felt, somehow, a bit dirty.

But needs must, and sometimes you need to prevent a user from doing something they might regret.

That's what this post is about. In React Router, it's possible to block navigation, either programmatically using the useNavigate hook, or through clicking on a <Link />, when you think the user doesn't actually want to do that given the current situation.

This isn't affecting the user's ability to close the tab, or to navigate backwards or forwards using the browser controls, but it's designed to prevent the user from clicking to another page when they have a half-filled form or otherwise unsaved data. This is particularly useful when you consider that some of these navigations might come from accidental clicks. Despite what I wrote at the beginning, there's actually nothing more frustrating than filling in a form, then accidentally clicking on some link at the side and losing absolutely everything.

Let's talk about how this is done in React Router using the useBlocker hook. I found this hook very confusing when I first took on this task and couldn't quite understand how it was supposed to be used. So allow me to explain, in case you ever find yourself in a similar position.

The useBlocker hook provided by React Router works by doing exactly that - hooking itself into navigation caused by React Router. Once the hook is called with a blocker callback function, the callback is called once any sort of navigation takes place.

Note here that navigating with a simple <a href=''></a> will of course not be intercepted, as such a link click doesn't go through React Router, but rather is registered directly by the browser which will then navigate to the whatever the href is.

The blocker function takes an argument which is an object containing the current location, the history action that is taking place, and the next location. See more about this at the documentation here. It must return a boolean. Returning false means the navigation can continue unimpeded, whereas true indicates that the navigation must be halted. At first, this seemed to be reversed in my mind. I intuited that true would be equivalent to "yep, ok, go ahead and navigate there" with false being "no, don't do that, it's not safe".

I had to switch my mental model to understand the return value to mean "should I block the navigation?" - with that it makes a bit more sense to me. This is reflected in the variable naming in the examples that follow.

A super simple implementation of this is if you have a form that has been changed by the user and you therefore have a dirty form state, where isDirty tells you that the form has, in fact, been changed.

const shouldBlock = useCallback(() => {
  if (!isDirty) {
    return true;
  }

  return false;
}, [isDirty]);
const blocker = useBlocker(shouldBlock);

Note that we're wrapping this in a useCallback to prevent multiple instances of the blocker being created. React Router will complain if we try to create multiple blockers on the same page, so this helps to prevent that.

For the astute among you, you'll notice we could write this conditional much simpler.

const shouldBlock = useCallback(() => !isDirty, [isDirty]);
const blocker = useBlocker(shouldBlock);

For the downright pedantic, we don't even need to pass a function to useBlocker, we could just pass a boolean value, which would be !isDirty in this case.

const blocker = useBlocker(useMemo(!isDirty, [isDirty]));

We've again added a useMemo to prevent any duplication of the blocker. Otherwise React Router will start shouting at us and calling us filthy names, nobody wants that.

Ok, job done for the blocking. But wait, how can I actually use this blocker? Where does it come into play with the rest of my logic?

Well, it turns out that we can use our blocker (see here for the very limited blocker documentation) to either continue the navigation or to cancel it.

A simple use case here would be to have some modal that gives the user a choice - "do you want to leave and lose your unsaved data, or stay and continue what you were doing?". It could look something like this:

type Props = {
  isOpen: boolean;
  onAccept: () => void;
  onCancel: () => void;
};

const BlockerModal = ({ isOpen, onAccept, onCancel }: Props) => {
  if (!isOpen) return null;

  return (
    <div>
      <p>leave and lose it all, or stay and carry on?</p>
      <button onClick={onAccept}>Leave</button>
      <button onClick={onCancel}>Stay</button>
    </div>
  );
};

Nice and simple interface. I leave it as an exercise for you to style this properly - and you can check out how to out my piece on creating a modal with React Portals to get the modal functionality exactly right.

We still need to update the component where we're blocking the navigation though, so let's do that now and add in the rest of the component code that we omitted earlier.

const MyComponent = () => {
  // form state providing the isDirty boolean goes up here

  const blocker = useBlocker(useMemo(!isDirty, [isDirty]));

  const handleContinueNavigation = () => {
    // we can call `proceed()` on the blocker to continue the navigation
    blocker.proceed?.();
  };

  const handleContinueNavigation = () => {
    // we can call `reset()` on the blocker to cancel the navigation
    blocker.reset?.();
  };

  return (
    <div>
      {/* some other stuff here with the form */}
      <BlockerModal
        isOpen={blocker.state === "blocked"}
        onAccept={handleContinueNavigation}
        onCancel={handleCancelNavigation}
      />
    </div>
  );
};

OK, there's quite a bit to unpack here. When our blocker is called and the form is dirty, it's going to enter a blocked state. Among other things, this means blocker.state will be equal to blocked. That means we can open our modal when the blocker's state is blocked. Nice.

The next thing to notice is that when the blocker is blocked, it has some functions on it that can be called. Namely, proceed() and reset().

Proceed will continue with the navigation. This is what we want to do when the user has accepted that they will lose their unsaved data and wishes to continue with their navigation.

Reset, on the other hand, will cancel the navigation. This is when our user got cold feet about losing all their precious data and decided to stick around to finish what they started.

Lovely, looks pretty good.

However, there might be a couple more footguns lying in wait to shoot a big fat hole in our feet. This was very much the case for me when I added this functionality to a real application and not just a stripped-back example.

The one footgun that got me was the navigation post-submit. When the user had filled in the form fully (and correctly) and actually submitted it, then we want to navigate them back to another screen. Of course, the blocker doesn't know about that yet. All it knows is that form is dirty, and when the form is dirty, it must intervene with any navigation!

Fortunately for me, we used a hook from React Query to submit our form data, and that same hook provided the data that was returned from the API when submission was successful.

Fudging that data together into a wasFormSubmitted variable just about does the job to stop any unwanted interruptions after the user has successfully submitted their data (and we've collected it, harvested it, and sold it to the highest bidder, of course).

Let's see how our final makeshift component looks (shortened for brevity, as before):

const MyComponent = () => {
  // form state providing the isDirty boolean goes up here
  // API data fudged into success variable

  const blocker = useBlocker(
    useMemo(!isDirty && !wasFormSubmitted, [isDirty, wasFormSubmitted])
  );

  const handleContinueNavigation = () => {
    // we can call `proceed()` on the blocker to continue the navigation
    blocker.proceed?.();
  };

  const handleContinueNavigation = () => {
    // we can call `reset()` on the blocker to cancel the navigation
    blocker.reset?.();
  };

  return (
    <div>
      {/* some other stuff here with the form */}
      <BlockerModal
        isOpen={blocker.state === "blocked"}
        onAccept={handleContinueNavigation}
        onCancel={handleCancelNavigation}
      />
    </div>
  );
};

B-e-a-utiful.

This now gives us a perfect prototype to implement the useBlocker hook from React Router, so you can get out there and start p*ssing your users off right now! Let's go, and don't forget to send me what you come up with yourself, I'd love to see - feel free to mention me on LinkedIn.

Are you looking to take the next step as a developer? Whether you're a new developer looking to break into the tech industry, or just looking to move up in terms of seniority, book a coaching session with me and I will help you achieve your goals.