JavaScript and React course Β· Module 6: React Router

Managing Browser History on a Journey Through Cosmic Time

12 min read
In this lesson12

In intergalactic travels, it's important to track the route and have the ability to return to previous exploration points. In the world of React Router, this navigation system is the browser history - a mechanism that allows moving back and forth between visited pages. Let's see how to consciously manage history to increase the comfort of users' journeys through our application.

Browser History Basics

Browser history is a stack of URL addresses visited by the user. Browsers provide an API that allows:

  • Moving back and forth between pages
  • Adding new entries to the history
  • Replacing the current entry
  • Manipulating the history state

React Router integrates with this API, providing hooks and components for managing history in a React-friendly way.

The useNavigate Hook

The useNavigate hook is the primary tool for programmatic navigation in React Router:

1import { useNavigate } from 'react-router-dom';
2
3function MissionControl() {
4  const navigate = useNavigate();
5
6  const goToExploration = () => {
7    navigate('/exploration');
8  };
9
10  const goBack = () => {
11    navigate(-1); // Go back one page
12  };
13
14  const goForward = () => {
15    navigate(1);  // Go forward one page
16  };
17
18  const jumpToSpecificPoint = () => {
19    navigate(-3); // Go back three pages
20  };
21
22  return (
23    <div className="mission-control">
24      <h1>Mission Control Center</h1>
25
26      <div className="navigation-panel">
27        <button onClick={goToExploration}>Start Exploration</button>
28        <button onClick={goBack}>Return to previous location</button>
29        <button onClick={goForward}>Forward</button>
30        <button onClick={jumpToSpecificPoint}>Return to starting point</button>
31      </div>
32    </div>
33  );
34}

Replacing vs. Adding to History

When using navigate, we can decide whether to add a new page to history or replace the current one:

1// Add a new page to the history stack (default behavior)
2navigate('/new-location');
3
4// Replace the current page in history
5navigate('/new-location', { replace: true });

When should you use replace: true?

  1. Redirects after login/logout - we don't want the user to be able to return to the login page after logging in
  2. Multi-step forms - we don't want the user to be able to return to intermediate steps of the process
  3. Redirects from non-existent pages - when redirecting from 404 to the home page
  4. When performing a redirect - to avoid loops in browser history

Blocking Navigation

Sometimes we need to prevent the user from leaving a page, for example when they have unsaved changes in a form. Imagine the spaceship configurator: an astronaut has typed in the ship's name and picked the engines but has not saved the configuration yet, and accidentally clicks a link to the hangar or the "Back" button. Without a warning, all that work is lost.

React Router has a built-in hook for this: useBlocker. It appeared in version 6.7 as unstable_useBlocker, has been stable since version 6.19 and also works in v7 and v8. You already know the condition from the lesson on nested routes: the hook works only in a data router, that is, when you pass your routes to createBrowserRouter and render the app with <RouterProvider>. Inside a plain <BrowserRouter> it throws the error "useBlocker must be used within a data router".

1import { createBrowserRouter, RouterProvider } from 'react-router-dom';
2
3const router = createBrowserRouter([
4  { path: '/', element: <MissionControl /> },
5  { path: '/configurator', element: <SpaceshipConfigurator /> },
6]);
7
8function App() {
9  return <RouterProvider router={router} />;
10}

Here the routes are described by an array of objects, while page components such as MissionControl stay unchanged. In v8 you import createBrowserRouter and useBlocker from the react-router package, and RouterProvider from react-router/dom.

Now the hook itself. useBlocker accepts a boolean or a function. The function receives an object with the currentLocation, nextLocation and historyAction fields and returns true when the navigation should be stopped. The hook returns a blocker object with a state field: 'unblocked' (nothing is blocked), 'blocked' (navigation is on hold) or 'proceeding' (the confirmed navigation is in progress). In the 'blocked' state, the location field shows where the user wanted to go, and you have two methods: proceed() resumes the navigation, and reset() cancels it and keeps the user where they are.

1import { useState } from 'react';
2import { useBlocker } from 'react-router-dom';
3
4function SpaceshipConfigurator() {
5  const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
6  const [config, setConfig] = useState({});
7
8  // Block only when there are unsaved changes and the path changes
9  const blocker = useBlocker(
10    ({ currentLocation, nextLocation }) =>
11      hasUnsavedChanges && currentLocation.pathname !== nextLocation.pathname
12  );
13
14  return (
15    <div className="configurator">
16      <input
17        value={config.shipName ?? ''}
18        onChange={(e) => {
19          setConfig({ ...config, shipName: e.target.value });
20          setHasUnsavedChanges(true);
21        }}
22        placeholder="Ship name"
23      />
24      <button onClick={() => setHasUnsavedChanges(false)}>Save</button>
25
26      {blocker.state === 'blocked' && (
27        <div className="warning-panel">
28          <p>You have unsaved changes. Are you sure you want to leave this page?</p>
29          <button onClick={() => blocker.proceed()}>Leave the page</button>
30          <button onClick={() => blocker.reset()}>Stay</button>
31        </div>
32      )}
33    </div>
34  );
35}

The blocker covers clicks on Link, calls to navigate() and the browser's "Back" and "Forward" buttons, and after a click on "Save" the function returns false, so navigation flows freely again. The hook does not display any dialog, so you design the warning panel yourself, in your app's style. There is also unstable_usePrompt, based on window.confirm, but its authors deliberately keep it unstable, because browsers handle window.confirm differently.

useBlocker will not help, however, when the user closes the tab, reloads the page or types a different address into the address bar. In that case the browser unloads the whole page and only the beforeunload event remains. We will handle it in a small custom hook:

1import { useEffect } from 'react';
2
3function useBeforeUnloadWarning(when) {
4  useEffect(() => {
5    if (!when) return;
6
7    const handleBeforeUnload = (e) => {
8      e.preventDefault();
9      e.returnValue = true; // for older browsers
10    };
11
12    window.addEventListener('beforeunload', handleBeforeUnload);
13    return () => {
14      window.removeEventListener('beforeunload', handleBeforeUnload);
15    };
16  }, [when]);
17}

In SpaceshipConfigurator you just call useBeforeUnloadWarning(hasUnsavedChanges). The listener exists only while there are unsaved changes, and the cleanup function removes it once the changes are gone. That is what MDN recommends, because in Firefox a page with a beforeunload listener does not go into the bfcache, so returning to it takes longer. The browser shows its own generic message instead of your text, and only if the user has interacted with the page before.

I recommend always combining both mechanisms: useBlocker guards navigation inside the app, and beforeunload guards closing the tab and reloading the page.

The useLocation Hook

The useLocation hook gives us access to the current location object, which contains information about the current URL:

1import { useLocation } from 'react-router-dom';
2
3function LocationTracker() {
4  const location = useLocation();
5
6  return (
7    <div>
8      <h2>Current Cosmic Location</h2>
9      <p>Path: {location.pathname}</p>
10      <p>Query: {location.search}</p>
11      <p>Hash: {location.hash}</p>
12      <p>Key identifier: {location.key}</p>
13      <p>Navigation method: {location.state?.navigationMethod || 'Standard'}</p>
14    </div>
15  );
16}

The location.key field is particularly useful - it is a unique identifier for each entry in the history. We can use it to detect when the user returns to a page they have already visited.

Tracking Navigation History

We can create our own hook for tracking navigation history:

1import { useState, useEffect } from 'react';
2import { useLocation } from 'react-router-dom';
3
4function useNavigationHistory(maxEntries = 10) {
5  const location = useLocation();
6  const [history, setHistory] = useState([]);
7
8  useEffect(() => {
9    setHistory(prev => {
10      // Add current location to history
11      const newHistory = [...prev, {
12        pathname: location.pathname,
13        search: location.search,
14        hash: location.hash,
15        key: location.key,
16        timestamp: Date.now()
17      }];
18
19      // Limit history size
20      return newHistory.slice(-maxEntries);
21    });
22  }, [location, maxEntries]);
23
24  return history;
25}
26
27// Usage:
28function NavigationLogger() {
29  const history = useNavigationHistory(5);
30
31  return (
32    <div className="navigation-log">
33      <h3>Travel Log</h3>
34      <ul>
35        {history.map((entry, index) => (
36          <li key={entry.key}>
37            {index === history.length - 1 ? '-> ' : ''}
38            {entry.pathname} (visited: {new Date(entry.timestamp).toLocaleTimeString()})
39          </li>
40        ))}
41      </ul>
42    </div>
43  );
44}

Modifying the "Back" Button Behavior

Sometimes we want to change the default behavior of the browser's "Back" button. For example, if the user is on a page that can only be visited after logging in and clicks "Back", we can redirect them to a safe location:

1import { useEffect } from 'react';
2import { useNavigate, useLocation } from 'react-router-dom';
3
4function SecureZone() {
5  const navigate = useNavigate();
6  const location = useLocation();
7  const { isAuthenticated } = useAuth();
8
9  useEffect(() => {
10    // Check if the user is logged in on every location change
11    if (!isAuthenticated) {
12      navigate('/login', { replace: true });
13    }
14
15    // Modified "Back" button behavior
16    const handlePopState = () => {
17      if (!isAuthenticated) {
18        // If the user is not logged in and clicks "Back",
19        // redirect them to the home page instead of the previous page
20        navigate('/', { replace: true });
21      }
22    };
23
24    window.addEventListener('popstate', handlePopState);
25
26    return () => {
27      window.removeEventListener('popstate', handlePopState);
28    };
29  }, [isAuthenticated, navigate, location]);
30
31  return (
32    <div className="secure-zone">
33      <h1>Secured Zone</h1>
34      <p>Welcome to the Command Center. You have access to classified data.</p>
35    </div>
36  );
37}

Synchronizing History with Global State

In more complex applications, we may want to synchronize browser history with global application state (e.g., Redux):

1import { useEffect } from 'react';
2import { useLocation } from 'react-router-dom';
3import { useDispatch } from 'react-redux';
4
5function HistorySync() {
6  const location = useLocation();
7  const dispatch = useDispatch();
8
9  useEffect(() => {
10    // Every time the location changes, we update the Redux state
11    dispatch({
12      type: 'navigation/locationChanged',
13      payload: {
14        pathname: location.pathname,
15        search: location.search,
16        hash: location.hash,
17        state: location.state
18      }
19    });
20  }, [location, dispatch]);
21
22  return null; // Component doesn't render anything
23}
24
25// Usage in App:
26function App() {
27  return (
28    <Router>
29      <HistorySync />
30      {/* Rest of the application */}
31    </Router>
32  );
33}

Analyzing URL Parameters

We often need to analyze query parameters from the URL:

1import { useLocation } from 'react-router-dom';
2
3function useParsedQuery() {
4  const location = useLocation();
5  const queryParams = new URLSearchParams(location.search);
6
7  return {
8    get: (param) => queryParams.get(param),
9    has: (param) => queryParams.has(param),
10    getAll: (param) => queryParams.getAll(param),
11    toString: () => queryParams.toString(),
12    entries: () => Array.from(queryParams.entries()).reduce((acc, [key, value]) => {
13      acc[key] = value;
14      return acc;
15    }, {})
16  };
17}
18
19// Usage:
20function SpaceshipFilter() {
21  const query = useParsedQuery();
22  const typeFilter = query.get('type'); // e.g., 'exploration'
23  const minCrewSize = query.get('minCrew'); // e.g., '3'
24
25  return (
26    <div>
27      <h2>Spaceship Filtering</h2>
28      {typeFilter && <p>Filtering by type: {typeFilter}</p>}
29      {minCrewSize && <p>Minimum crew size: {minCrewSize}</p>}
30
31      {/* Rest of the component */}
32    </div>
33  );
34}

History Stack and Depth Management

Sometimes we want to check how deep in the browser history the user is, or perform an action after reaching a certain depth:

1import { useState, useEffect } from 'react';
2
3function useHistoryDepth() {
4  const [depth, setDepth] = useState(0);
5
6  useEffect(() => {
7    // Set initial depth
8    setDepth(window.history.length);
9
10    // Listen for changes
11    const handleNavigation = () => {
12      setDepth(window.history.length);
13    };
14
15    window.addEventListener('popstate', handleNavigation);
16
17    return () => {
18      window.removeEventListener('popstate', handleNavigation);
19    };
20  }, []);
21
22  return depth;
23}
24
25// Usage:
26function NavigationDepthIndicator() {
27  const depth = useHistoryDepth();
28
29  return (
30    <div className="depth-indicator">
31      <p>History depth: {depth}</p>
32      {depth > 10 && (
33        <button onClick={() => window.history.go(-5)}>
34          Go back 5 pages
35        </button>
36      )}
37    </div>
38  );
39}

Advanced Scenarios

1. Scroll Restoration

The standard browser behavior is to restore the scroll position when navigating back. In SPA applications, we often need to implement this manually:

1import { useEffect, useRef } from 'react';
2import { useLocation } from 'react-router-dom';
3
4function useScrollRestoration() {
5  const location = useLocation();
6  const scrollPositions = useRef({});
7
8  // Save scroll position on location change
9  useEffect(() => {
10    const saveScrollPosition = (key) => {
11      scrollPositions.current[key] = window.scrollY;
12    };
13
14    // Save current position when the user leaves the page
15    return () => {
16      saveScrollPosition(location.key);
17    };
18  }, [location]);
19
20  // Restore scroll position when the user returns to a page
21  useEffect(() => {
22    if (scrollPositions.current[location.key] !== undefined) {
23      // Restore saved position
24      window.scrollTo(0, scrollPositions.current[location.key]);
25    } else {
26      // New page, scroll to top
27      window.scrollTo(0, 0);
28    }
29  }, [location]);
30}
31
32// Usage:
33function ScrollRestoringPage() {
34  useScrollRestoration();
35
36  return (
37    <div className="long-content-page">
38      {/* Lots of content causing scrolling */}
39    </div>
40  );
41}

2. Detecting Parameter Changes

Sometimes we need to know if specific URL parameters have changed:

1import { useEffect, useRef } from 'react';
2import { useLocation } from 'react-router-dom';
3
4function useQueryParamChange(paramName) {
5  const location = useLocation();
6  const previousParam = useRef(null);
7
8  // Get the current parameter value
9  const query = new URLSearchParams(location.search);
10  const currentValue = query.get(paramName);
11
12  // Check if the value has changed
13  const hasChanged = previousParam.current !== currentValue;
14
15  // Update the ref for the next render
16  useEffect(() => {
17    previousParam.current = currentValue;
18  }, [currentValue]);
19
20  return {
21    value: currentValue,
22    hasChanged,
23    previousValue: previousParam.current
24  };
25}
26
27// Usage:
28function GalaxyFilter() {
29  const { value: galaxyType, hasChanged } = useQueryParamChange('galaxy');
30
31  useEffect(() => {
32    if (hasChanged) {
33      // Perform action on galaxy type change
34      console.log(`Filter changed to: ${galaxyType}`);
35      // e.g., fetch new data
36    }
37  }, [galaxyType, hasChanged]);
38
39  return (
40    <div>
41      {/* Filtering components */}
42    </div>
43  );
44}

Summary

Conscious management of browser history is like keeping a cosmic travel log - it allows us to:

  1. Navigate through time - the user can easily return to visited locations
  2. Control the flow - we can influence how the user moves through the application
  3. Preserve context - state data can be transferred between locations
  4. Optimize the experience - restoring state, scroll position, etc.

Well-designed browser history handling makes the journey through our application intuitive and pleasant, just like a well-planned space mission.

Remember that browser history is not just a navigation mechanism, but also an important element of the user experience. Users expect the "Back" and "Forward" buttons to work intuitively, and your application should respect that.

Code for this lesson: App.jsx
1import React, { Suspense, lazy, useState } from 'react';
2import { BrowserRouter, Routes, Route, Link } from 'react-router-dom';
3
4// Lazy loaded components
5const Dashboard = lazy(() => {
6  // Simulation of loading delay
7  return new Promise(resolve => {
8    setTimeout(() => {
9      resolve({
10        default: () => (
11          <div className="page">
12            <h1>Dashboard</h1>
13            <p>This component was lazily loaded!</p>
14            <div className="stats">
15              <div className="stat">Users: 1,234</div>
16              <div className="stat">Sessions: 5,678</div>
17              <div className="stat">Revenue: 9,999 credits</div>
18            </div>
19          </div>
20        )
21      });
22    }, 1500);
23  });
24});
25
26const Analytics = lazy(() => {
27  return new Promise(resolve => {
28    setTimeout(() => {
29      resolve({
30        default: () => (
31          <div className="page">
32            <h1>Analytics</h1>
33            <p>Advanced statistics loaded lazily.</p>
34            <div className="chart">
35              <div className="bar" style={{height: '60%'}}></div>
36              <div className="bar" style={{height: '80%'}}></div>
37              <div className="bar" style={{height: '45%'}}></div>
38              <div className="bar" style={{height: '90%'}}></div>
39              <div className="bar" style={{height: '70%'}}></div>
40            </div>
41          </div>
42        )
43      });
44    }, 2000);
45  });
46});
47
48const Settings = lazy(() => {
49  return new Promise(resolve => {
50    setTimeout(() => {
51      resolve({
52        default: () => (
53          <div className="page">
54            <h1>Settings</h1>
55            <p>System configuration.</p>
56            <div className="settings-list">
57              <div className="setting">Dark mode: Enabled</div>
58              <div className="setting">Notifications: Enabled</div>
59              <div className="setting">Language: English</div>
60            </div>
61          </div>
62        )
63      });
64    }, 1000);
65  });
66});
67
68// Loading fallback component
69function LoadingSpinner() {
70  return (
71    <div className="loading">
72      <div className="spinner"></div>
73      <p>Loading module...</p>
74    </div>
75  );
76}
77
78// Home page (not lazy)
79function HomePage() {
80  return (
81    <div className="page">
82      <h1>Home page</h1>
83      <p>This page is loaded immediately.</p>
84      <p className="info">Click on other links to see lazy loading in action!</p>
85    </div>
86  );
87}
88
89function App() {
90  return (
91    <BrowserRouter>
92      <div className="app">
93        <nav>
94          <Link to="/">Home (instant)</Link>
95          <Link to="/dashboard">Dashboard (1.5s)</Link>
96          <Link to="/analytics">Analytics (2s)</Link>
97          <Link to="/settings">Settings (1s)</Link>
98        </nav>
99
100        <Suspense fallback={<LoadingSpinner />}>
101          <Routes>
102            <Route path="/" element={<HomePage />} />
103            <Route path="/dashboard" element={<Dashboard />} />
104            <Route path="/analytics" element={<Analytics />} />
105            <Route path="/settings" element={<Settings />} />
106          </Routes>
107        </Suspense>
108      </div>
109    </BrowserRouter>
110  );
111}
112
113export default App;

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. What information does the useLocation hook provide?

  2. 2. What does calling navigate(-1) do in React Router?

These are 2 of 3 questions for this lesson. Solve the rest in the game.

Hands-on tasks in the game

  • Code editor

    Location tracking

  • Horizontal ordering

    Arrange URL components in the order they appear in an address:

Useful articles