Logo
Published on

Handling Browser Back and Forward in React

Authors
  • Name
    Twitter

This short article is about handling navigation info when the user interacts with the browser's back and forward buttons.


Use Case

While developing one of my projects, I came across the need to perform certain actions when the user was navigating to a page using the back or forward button. The logic also necessitated additional information to be stored in the location state.

export interface Location<State = any> extends Path {
    /**
     * A value of arbitrary data associated with this location.
     */
    state: State;
    /**
     * A unique string associated with this location. May be used to safely store
     * and retrieve data in some other storage API, like `localStorage`.
     *
     * Note: This value is always "default" on the initial location.
     */
    key: string;
}

Installation

You will need to install the following libraries:

npm:

npm i -S react-router-dom history

yarn:

yarn add react-router-dom history

pnpm:

pnpm add react-router-dom history

Solution Design

After having reviewed many posts/articles and having chatted with GPT, I was not really satisfied with any of the solutions proposed.

In the meantime, I got accustomed to the history listen function, which allows to intercept events from the browser navigation stack.

listen(listener: Listener): () => void;

The single parameter, listener, carries an object of type Action.

export declare enum Action {
    /**
     * A POP indicates a change to an arbitrary index in the history stack, such
     * as a back or forward navigation. It does not describe the direction of the
     * navigation, only that the current index changed.
     *
     * Note: This is the default action for newly created history objects.
     */
    Pop = "POP",
    /**
     * A PUSH indicates a new entry being added to the history stack, such as when
     * a link is clicked and a new page loads. When this happens, all subsequent
     * entries in the stack are lost.
     */
    Push = "PUSH",
    /**
     * A REPLACE indicates the entry at the current index in the history stack
     * being replaced by a new one.
     */
    Replace = "REPLACE"
}

As described in the comments, the POP event is what I was looking for.

This would cover the route destination. To get the source location I decided to use react-router useLocation hook and save it in the application state.

As the events from the browser navigation stack are a transversal concern, it made complete sense to create a dedicated React context.

// NavigationContext.tsx
import { createContext } from 'react';
import type { Location } from 'react-router-dom';

export interface NavigationBundle {
  from: Location | undefined;
  to: Location | undefined;
}

export const defaultNavigationBundle = {
  from: undefined,
  to: undefined,
};

export const NavigationContext = createContext<NavigationBundle>(
  defaultNavigationBundle
);

Not much to discuss here, let’s check the provider instead.

import type { Update } from 'history';
import { createBrowserHistory } from 'history';
import { useEffect, useMemo, useState } from 'react';
import type { Location } from 'react-router-dom';
import { useLocation } from 'react-router-dom';

import type { NavigationBundle } from './NavigationContext';
import { NavigationContext } from './NavigationContext';

type NavigationProviderProps = {
  children: React.ReactNode;
};

const history = createBrowserHistory();

const NavigationProvider: React.FC<NavigationProviderProps> = ({
  children,
}: NavigationProviderProps) => {
  const [from, setFrom] = useState<Location | undefined>(undefined);
  const [to, setTo] = useState<Location | undefined>(undefined);
  const location = useLocation();

  useEffect(() => {
    setFrom(location);
  }, [location]);

  useEffect(() => {
    history.listen((update: Update) => {
      if (update.action === 'POP') setTo(update.location);
    });
  }, []);

  const navigationBundle: NavigationBundle = useMemo(() => {
    return {
      from,
      to,
    };
  }, [from, to]);

  return (
    <NavigationContext.Provider value={navigationBundle}>
      {children}
    </NavigationContext.Provider>
  );
};

export default NavigationProvider;

The state consists of two Location instances: from and to.

From is set in the body of the effect and is basically the current location before any back or forward button action. As stated before, it is derived using the useLocation hook.

To, instead, is set when a POP event is intercepted (a.k.a browser back and forward).

Important: the history object MUST NOT be in the dependency array of the useEffect. This is the reason why it has been declared outside of the component. If the side effect would be triggered for each change of the history object it would cause an exponential number of listeners to be added, eventually crashing your app!

Don’t forget to wrap your app with the provider.

<Router>
  <NavigationProvider>
    ...
  </NavigationProvider>
</Router>

Usage

The context can then be accessed from everywhere in your application. Here’s an example:

const MyComponent = () => {
  const { from, to } = useContext<NavigationBundle>(NavigationContext);

  useEffect(() => {
    console.log(from);
    console.log(to);
  }, [from, to]);

...

This is how the console looks like when you navigate to MyComponent with the browser forward and back buttons:

Location state

As you can see from the console output, by using this approach you also have access to the state that was sent along with the previous route.

state: {skill: 'Typescript'}

This can be a powerful tool to implement complex navigation scenarios.

Conclusion

In this article, a technique to control the browser's back and forward buttons has been presented. The inherent capability of consulting the previous state was the goal of this research and it has many potential applications.

Thanks for reading and I hope you found it useful.