DEV Community

Cover image for Block user navigation with React Router v6
Tadas Goberis
Tadas Goberis

Posted on • Updated on

Block user navigation with React Router v6

We will create a reusable type-safe hook, to prevent user navigation, and warn that the changes might be lost if he decides to continue. All of that while keeping good developer experience, readability and performance in mind.

If the final solution is what you are after, scroll to the end. Feel free, use it and abuse it! 🫑

But if you want to dive deeper into the logic and thought process behind complex feature keep reading. πŸ€“


Issues

Right now React Router v6 doesn't support navigation blocking out of the box but it's an open github issue.

We want to improve one of the community solutions with few iterations:

  • Add Typescript for better autocompletion and type safety.
  • Improve developer experience of the custom hook.
  • Optimize to prevent unnecessary rerenders.

Lets get started

React Router follows semantic versioning loosely and minor version updates might be backward incompatible. So best to use exact version or allow only patch version updates like in the example.

// package.json
{
...
  "dependencies": {
    ...
    "react-router-dom": "~6.3.0",
  }
}

Enter fullscreen mode Exit fullscreen mode

What use cases to support?

  1. Navigation is blocked only if changes were made.
  2. Navigation POP action is used if user was trying to go back.
  3. Navigation PUSH action is used if user was going to specific route.
  4. Navigation is not blocked if warning is already open (2 x BACK should navigate user out).

Iteration v1 (meh..)

Lets take one of the existing solutions. Clean it up, so the v1 is born... To be honestly starting version was pretty bad... πŸ€¦β€β™‚οΈ

❌ We had way to many states, only 2 of 4 use cases were functioning properly and developer experience of using the hook was meh...

Lets just jump to v2 and take it from there.

Iteration v2 (it's alive!)

During the second iteration we will be trying to achieve working functionality as fast as possible meaning the coverage of all 4 use cases.
Perfection is not the goal here, we can tinker with that later.

Let's step through the code and see what's under the hood. πŸ‘¨β€πŸ”§

import { History, Transition, Action } from 'history';
import { useContext, useEffect, useState } from 'react';
import {
  UNSAFE_NavigationContext,
  useNavigate,
} from 'react-router-dom';

type BlockerState = 'DISABLED' | 'PENDING' | 'ACTIVE';

export const useNavBlocker = (enabled = true) => {
  const navigate = useNavigate();
  // This is the only way to use blocker right now
  const navigator = useContext(UNSAFE_NavigationContext)
    .navigator as History;
  // Save the location user was navigating to
  const [nextLocation, setNextLocation] =
    useState<Transition | null>(null);
  // Blocking user in a page is not a nice thing to do
  // Allow users to leave page with multiple POP actions
  // 'PENDING' state is needed for the desired behavior
  const [blocker, setBlocker] =
    useState<BlockerState>('ACTIVE');

  // Actions to control blocker from outside
  const navigation = {
    confirm: () => setBlocker('DISABLED'),
    cancel: () => setBlocker('ACTIVE'),
  };

  useEffect(() => {
    // Functionality to turn blocker on/off
    if (!enabled) {
      return;
    }

    // User confirms navigation ->
    // navigate user to the location he was trying to go
    if (blocker === 'DISABLED' && nextLocation?.action) {
      if (nextLocation.action === Action.Push) {
        navigate(nextLocation.location.pathname);
      }

      if (nextLocation.action === Action.Pop) {
        navigate(-1);
      }
    }

    // Blocker is in 'ACTIVE' state by default ->
    // active blocker -> stop any navigation
    if (blocker === 'ACTIVE') {
      const unblock = navigator.block(
        (transition: Transition) => {
          // User is still deciding - warning is open
          setBlocker('PENDING');
          // Save location user was trying to access
          setNextLocation(transition);
        },
      );

      // Active blocker is cleaned up before each render
      return unblock;
    }
  }, [
    nextLocation?.action,
    nextLocation?.location.pathname,
    navigate,
    blocker,
    navigator,
    enabled,
  ]);

  // Expose a flag and control for the outside component
  return { showPrompt: blocker === 'PENDING', navigation };
};
Enter fullscreen mode Exit fullscreen mode

Usage - conditional rendering based on the showPrompt flag + passing of navigation control to modal.

We are using promise based modals so in our case it looks like this.

const AnyReactComponent = () => {
  ...
  const confirmationModal = useModal(ConfirmationModal);
  const { showPrompt, navigation } = useNavBlocker(
    formState.isDirty,
  );

  useEffect(() => {
    if (showPrompt) {
      confirmationModal.show().then((result) => {
        if (result.action === 'CONFIRM') {
          return navigation.confirm();
        }

        navigation.cancel();
      });
    }
  }, [confirmationModal, navigation, showPrompt]);

  return (
    ...
  );
};
Enter fullscreen mode Exit fullscreen mode

βœ… All of the use cases are encountered for (UI), good reusability.
❌ Too many states, causing unnecessary rerenders. Maybe there is a way to make hook even more convenient to use. πŸ€”

Iteration v3 (happy devs)

Users and business guys are celebrating while developers are frowning.
The issue is our custom hook implementation - every time hook is used a useEffect is needed. Developer experience is all the rage these days. Let's look what would be the ideal hook interface and usage.

We would love to eliminate additional useEffect in each component and have something like this:

const AnyReactComponent = () => {
  ...
  const confirmationModal = useModal(ConfirmationModal);

  useNavBlocker({
    enabled: formState.isDirty,
    onBlock: (navigation) =>
      confirmationModal.show().then((result) => {
        if (result.action === 'CONFIRM') {
          return navigation.confirm();
        }

        navigation.cancel();
      }),
  });

  return (
    ...
  );
};
Enter fullscreen mode Exit fullscreen mode

Lovely, this will surely put back smiles to developers faces! Lets look to the improvements we have done to implement the desired behavior.

Only places with comments were updated ❗️

import { History, Transition, Action } from 'history';
import { useContext, useEffect, useState } from 'react';
import {
  UNSAFE_NavigationContext,
  useNavigate,
} from 'react-router-dom';

interface NavBlockerControl {
  confirm: () => void;
  cancel: () => void;
}

interface NavBlocker {
  onBlock: (control: NavBlockerControl) => void;
  enabled?: boolean;
}

type BlockerState = 'DISABLED' | 'PENDING' | 'ACTIVE';

// Main changes:
// Added additional argument of onBlock to the function.
// Hook will expose control through the function parameters

// Strictly typed useNavBlocker control simplifies usage
// Typescript will take care of autocompletion for the devs
// This allows us to skip useEffect in every component 🀠
export const useNavBlocker = ({
  onBlock,
  enabled,
}: NavBlocker) => {
  const navigate = useNavigate();
  const navigator = useContext(UNSAFE_NavigationContext)
    .navigator as History;
  const [nextLocation, setNextLocation] =
    useState<Transition | null>(null);
  const [blocker, setBlocker] =
    useState<BlockerState>('ACTIVE');

  useEffect(() => {
    if (!enabled) {
      return;
    }

    if (blocker === 'DISABLED' && nextLocation?.action) {
      if (nextLocation.action === Action.Push) {
        navigate(nextLocation.location.pathname);
      }

      if (nextLocation.action === Action.Pop) {
        navigate(-1);
      }
    }

    if (blocker === 'ACTIVE') {
      const unblock = navigator.block((transition) => {
        // Magic happens here πŸ§™β€β™‚οΈ
        // Reusing this useEffect for our needs 🧐
        // Developers are responsible for actions passed to
        // onBlock function. We don't care about that
        // We just call it with specified TS interface params
        onBlock({
          confirm: () => setBlocker('DISABLED'),
          cancel: () => setBlocker('ACTIVE'),
        });
        setBlocker('PENDING');
        setNextLocation(transition);
      });

      return unblock;
    }
  }, [
    nextLocation?.action,
    nextLocation?.location.pathname,
    navigate,
    blocker,
    navigator,
    enabled,
    onBlock,
  ]);
};
Enter fullscreen mode Exit fullscreen mode

βœ… All of the use cases are encountered for (UI), amazing reusability and developer experience. πŸŽ‰
❌ Too many states - do we need them at all?

Iteration v4 (why state?)

At the end of v3 refactoring we should question if we need useState at all. The purpose of state is to store data and in case of change, rerender the component. With the current useNavBlocker v3 implementation there is nothing to rerender neither inside of the hook, nor outside because we are no longer exposing it to the outside components.

Lets refactor once more! πŸ™‹β€β™‚οΈ This one is a bit tricky so will thoroughly explain each part.

import { History, Action } from 'history';
import { useContext, useEffect } from 'react';
import {
  UNSAFE_NavigationContext,
  useNavigate,
} from 'react-router-dom';

interface NavBlockerControl {
  confirm: () => void;
  cancel: () => void;
}

interface NavBlocker {
  onBlock: (control: NavBlockerControl) => void;
  enabled?: boolean;
}

export const useNavBlocker = ({
  onBlock,
  enabled,
}: NavBlocker) => {
  const navigate = useNavigate();
  const { block } = useContext(UNSAFE_NavigationContext)
    .navigator as History;

  useEffect(() => {
    if (!enabled) {
      return;
    }

    // Key concepts
    // block() accepts callback
    // it will be called every time a transition is blocked
    // block() returns a function
    // it stops blocking - we assign it to unblock const

    // Base flow
    // Try to navigate -> isActive=false ->
    // onBlock() activates warning -> isActive=true ->
    // Cancellation / Confirmation

    // Cancellation
    // -> cancel() close warning -> isActive=false

    // Confirmation
    // -> confirm / BACK click ->
    // callback runs on navigate action ->
    // isActive=true so unBlock() and navigate user

    // By default false because warning is not yet shown
    let isActive = false;

    const unblock = block((transition) => {
      const continueNavigation = () => {
        if (transition.action === Action.Push) {
          navigate(transition.location.pathname);
        }

        if (transition.action === Action.Pop) {
          navigate(-1);
        }
      };

      if (isActive) {
        // Stops blocking
        unblock();
        return continueNavigation();
      }

      onBlock({
        confirm: continueNavigation,
        cancel: () => {
          isActive = false;
        },
      });

      isActive = true;
    });

    return unblock;
  }, [navigate, block, enabled, onBlock]);
};
Enter fullscreen mode Exit fullscreen mode

βœ… All of the use cases are encountered for, nice developer experience, optimized.
❌ Unnecessary useEffect triggers

Final (none shall pass!)

It's well known that developers like to over-optimize. This is probably not necessary, but if we are going for the perfection, lets make few more improvements. πŸ…

One thing that kept bugging me is onBlock function in useEffect dependencies. This function reference will change every time parent component rerenders. That will trigger our useEffect more than necessary.

One possible solution would be to useCallback on function passed into useNavBlocker hook. But that adds another layer of complexity. Additionally there is always a risk of other developers forgetting or missing that implementation detail completely.
Another way would be to abstract this optimization into our custom hook by using latest ref pattern. That also results in stable ref preventing unnecessary rerenders.

import { History } from 'history';
import {
  useContext,
  useEffect,
  useLayoutEffect,
  useRef,
} from 'react';
import { UNSAFE_NavigationContext } from 'react-router-dom';

interface NavBlockerControl {
  confirm: () => void;
  cancel: () => void;
}

interface NavBlocker {
  onBlock: (control: NavBlockerControl) => void;
  enabled?: boolean;
}

export const useNavBlocker = ({
  onBlock,
  enabled,
}: NavBlocker) => {
  const { block } = useContext(UNSAFE_NavigationContext)
    .navigator as History;

  // Latest ref pattern
  // Latest version of the function stored to the onBlockRef
  const onBlockRef = useRef(onBlock);
  useLayoutEffect(() => {
    onBlockRef.current = onBlock;
  });

  useEffect(() => {
    if (!enabled) {
      return;
    }

    let isActive = false;

    const unblock = block(({ retry }) => {
      if (isActive) {
        unblock();
        // Retry method handles navigation for us πŸŽ‰
        // Allows to simplify code even more.
        return retry();
      }

      // This doesn't need to be included in dependencies
      // and won't trigger useEffect
      onBlockRef.current({
        confirm: retry,
        cancel: () => {
          isActive = false;
        },
      });

      isActive = true;
    });

    return unblock;
  }, [block, enabled]);
};

Enter fullscreen mode Exit fullscreen mode

Final thoughts

  • Sometimes it takes a lot of time to arrive to a simple solution.
  • First solution can guide you a wrong way. It's good to try to look at your solution from different angle.
  • When solving complex problems start with requirements then build working solution, improvements and optimizations can come later.

Top comments (2)

Collapse
 
victorsamson profile image
Victor Samson • Edited

Thanks for your post!

It looks like navigator.block was removed as of react-router-dom 6.4.

Rolling back to 6.3.0 seems to have fixed it for now.

Collapse
 
margo17 profile image
Martynas Goberis

Interesting walkthrough of your thought process dealing with problems between all the hook iterations, nice read!