So far, every navigation in CicloUrbano has started with the user clicking a <Link>. But plenty of navigations aren't a click on a link: when the user confirms the BookingForm on /reservas/nueva, the application must create the booking and take them to /reservas, without the form staying in history waiting for them to hit "back" and resubmit it; when an operation finishes, it's worth returning to the previous screen; when someone tries to leave a half-filled form, it's worth stopping them and asking. All of that is programmatic navigation: navigating from code. In this lesson you'll learn useNavigate and its options, how to pass and read state through navigation, useLocation in full, the declarative alternative <Navigate />, how to block an exit with useBlocker, how to restore scroll position with ScrollRestoration, and how to show a loading indicator with useNavigation.

Contents

  1. When to navigate from code, and when not to
  2. useNavigate: the full signature
  3. replace: when not to leave a trace in history
  4. Navigating back and forward with numbers
  5. Passing state through navigation
  6. useLocation in full
  7. Central case: confirming a booking
  8. Declarative redirects with <Navigate />
  9. <Navigate /> vs. useNavigate in an effect
  10. Blocking the exit with useBlocker
  11. Restoring scroll with ScrollRestoration
  12. Loading indicators with useNavigation

  1. When to navigate from code, and when not to

Before the API, the criterion — because the most common mistake on this topic isn't technical, it's a design one.

If the user decides to go somewhere, that's a link. If the application decides to take them there, that's programmatic navigation.

Situation Right tool
"View bike details" <Link to={...}>
Header menu <NavLink to={...}>
A station's tabs <NavLink to="incidencias">
After confirming a booking, go to "My bookings" useNavigate
After signing in, return to the original destination useNavigate
If there's no session, take them to /acceso <Navigate />
"Cancel" button that goes back useNavigate(-1)
After 5 seconds of inactivity, sign out useNavigate

And the antipattern to avoid above all else:

{/* ❌ NEVER do this */}
<div onClick={() => navigate('/estaciones')}>Stations</div>

<button type="button" onClick={() => navigate('/estaciones')}>Stations</button>

Both "work," and both are a serious accessibility failure, for the reasons covered in 03-06: they can't be opened in a new tab, the address can't be copied, a screen reader announces them as "button" or as nothing at all, search engines don't follow them, and the <div> isn't even reachable with the tab key. A <Link> produces a real <a> and keeps all of that. If the destination is known at render time, it's a link.

  1. useNavigate: the full signature

import { useNavigate } from 'react-router';

function MyComponent() {
  const navigate = useNavigate();
  // …
}

The hook returns a function with two calling forms:

// Form 1: navigate to a route
navigate(destination, options);

// Form 2: move through history
navigate(delta);   // number: -1 back, 1 forward, -2 two back…

The options for the first form:

Option Type What it does
replace boolean Replaces the current history entry instead of adding one
state any serializable value Data that travels with the navigation, invisible in the URL
relative 'route' | 'path' How to interpret a relative destination (as with <Link>, 06-03)
preventScrollReset boolean Stops the scroll position from jumping back to the top
flushSync boolean Forces a synchronous DOM update. A rare case

And the destination accepts either a string or an object, useful when you want to build the query in parts:

navigate('/reservas');                                  // plain string
navigate(`/bicicletas/${bike.id}`);                      // interpolated
navigate({ pathname: '/', search: '?tipo=electrica' });  // object
navigate('incidencias');                                 // relative to the active route
navigate('..');                                           // goes up one route level

One rule you can't skip: navigate changes the router's state, so it can only be called during an event or inside an effect, never during render.

function WorkshopPage() {
  const navigate = useNavigate();
  const { isOperator } = useUser();

  if (!isOperator) {
    navigate('/acceso');   // ❌ Warning: Cannot update a component while rendering another
  }
  // …
}

That's one of the first errors you'll run into. During render, a component can't trigger an update in another. The two correct ways out are in sections 8 and 9.

  1. replace: when not to leave a trace in history

By default, navigate('/reservas') pushes a new entry onto history, just like a <Link>. With { replace: true } it replaces the current one.

Compare the two histories after creating a booking:

flowchart LR
    subgraph SIN["Without replace"]
        A1["/"] --> B1["/reservas/nueva"] --> C1["/reservas"]
    end
    subgraph CON["With replace: true"]
        A2["/"] --> C2["/reservas"]
    end
    style B1 fill:#fecaca

While on /reservas and pressing "back":

Without replace With replace: true
"Back" destination /reservas/nueva /
What the user sees The form again, maybe with the data they already submitted The catalogue
Risk Resubmitting the same booking None

That risk is real and has a name: the user sees the form, thinks it wasn't saved, fills it in again, and ends up with two identical bookings. It's exactly the problem that the POST-Redirect-GET pattern solves on traditional websites, and replace is its equivalent in an SPA.

When to use replace: true:

  • After successfully submitting a form. The form shouldn't stay in history.
  • After signing in. "Back" shouldn't return an already signed-in user to the sign-in form.
  • On a redirect. A screen that only redirects shouldn't remain in history, or "back" would bounce in a loop.
  • When correcting the URL. If you turn /bicicletas/BICI-003 into /bicicletas/bici-003, replace it.
  • On filters and sorting (you already applied this in 06-02 with useSearchParams).

When NOT to use it:

  • Normal navigation between sections. The user expects to be able to go back.
  • Opening a detail page from a list. "Back" should return to the list.
  • Switching tabs within a screen, if you consider them navigable states.

  1. Navigating back and forward with numbers

navigate(-1);   // back, like the browser's button
navigate(1);    // forward
navigate(-2);   // two screens back
navigate(0);    // reloads the current route (rare, but it exists)

It's the natural shape of a "Cancel" or "Back" button:

// src/components/BackButton.jsx
import { useNavigate } from 'react-router';

/**
 * Back button.
 * Props:
 *  - fallbackTo (string, optional, defaults to '/'): where to go if there's no history
 *  - children (content, optional)
 */
function BackButton({ fallbackTo = '/', children = 'Back' }) {
  const navigate = useNavigate();

  function handleClick() {
    // window.history.length > 2 ≈ there's somewhere to go back to within the app
    if (window.history.length > 2) {
      navigate(-1);
    } else {
      navigate(fallbackTo, { replace: true });
    }
  }

  return (
    <button type="button" onClick={handleClick}>
      {children}
    </button>
  );
}

export default BackButton;

The reason for the if is an honest limitation worth knowing: navigate(-1) can't know where it's going. If the user reached /reservas/nueva by pasting the URL directly, there's no previous entry within your application, and -1 will take them out of CicloUrbano, maybe back to their search engine. And window.history.length is only an approximation, because it counts the tab's entire history, not just your application's; there's no reliable way to know, for privacy reasons.

That's why, for a "Back to stations" button with a known destination, a <Link to="/estaciones"> is preferable: it's deterministic, accessible, and can be opened in a new tab. Save navigate(-1) for the generic "Cancel" of a dialog or a form that can be reached from several places.

  1. Passing state through navigation

Sometimes the destination screen needs to know something about how it was reached. CicloUrbano's case: after creating a booking, you want /reservas to show the BookingCreatedNotice, but only if you've just come from creating it — not every time you visit.

The state option carries data that doesn't appear in the URL:

navigate('/reservas', {
  replace: true,
  state: { bookingCreatedNotice: true, bookingId: booking.id }
});

And on the destination it's read with useLocation:

const { state } = useLocation();
if (state?.bookingCreatedNotice) { /* … */ }

This mechanism relies directly on the history.pushState you saw in 06-01, with the consequences that implies:

Feature Detail
Doesn't show in the URL Fine for incidental data, bad for anything that needs to be shareable
Survives a reload The browser stores it with the history entry
Lost when opening the URL in a new tab It's not part of the address
Must be serializable No functions, Map, Set, DOM elements or class instances
Has a size limit A few megabytes; it's not a data store
Is visible to the user history.state in the console shows it: never put secrets there

And the design rule that prevents misuse: state is for "how I got here," not for "what I'm looking at." The booking-created notice, the original destination before a sign-in, or "I came from the filtered list" are good uses. The bike currently being shown is not: that goes in the URL, because it must be shareable and survive a new tab.

  1. useLocation in full

useLocation() returns an object with the current URL broken down, and re-renders the component every time it changes.

import { useLocation } from 'react-router';

function Logger() {
  const location = useLocation();
  console.log(location);
  return null;
}

For the URL /estaciones/est-02/incidencias?orden=fecha#nota-3:

{
  pathname: '/estaciones/est-02/incidencias',
  search: '?orden=fecha',
  hash: '#nota-3',
  state: null,
  key: 'x7k2m9'
}
Property Content Typical use
pathname The path, with no query or fragment Deciding what's active, logging the page view
search The query string with its ? You'll usually prefer useSearchParams (06-02)
hash The fragment with its # Scrolling to a specific section
state What was passed through navigation Notices, original destination
key Unique identifier for this history entry Resetting state, per-entry caches

About key, the least well-known and the most useful of the last three: it changes on every navigation, even if you return to the same URL. It's useful for forcing a subtree to reset, with the same mechanism as the keys from 03-03:

// Every time you navigate, the form remounts from scratch
const { key } = useLocation();
<BookingForm key={key} />

A complete example of logging page views, which in a real application would go to the analytics tool:

// src/hooks/usePageViewLogger.js
import { useEffect } from 'react';
import { useLocation } from 'react-router';

/**
 * Logs every screen change. No parameters. Returns nothing.
 */
export function usePageViewLogger() {
  const { pathname, search } = useLocation();

  useEffect(() => {
    // In production, the call to the measurement tool would go here
    console.info('[navigation] page view:', pathname + search);
  }, [pathname, search]);
}

Notice the dependencies: pathname and search, not the entire location object. React Router returns a new object on every navigation, but destructuring the strings makes the effect fire only when they truly change, avoiding the dependency problem from 05-02. Called from Layout, it covers the whole application.

  1. Central case: confirming a booking

Let's bring everything together in CicloUrbano's most important flow. On /reservas/nueva the user fills in the BookingForm; on confirming, the application validates, builds the booking, dispatches it to the bookingsReducer, and takes them to /reservas with a notice.

sequenceDiagram
    participant U as User
    participant F as NewBookingPage
    participant V as validateBooking
    participant R as bookingsReducer
    participant N as useNavigate
    U->>F: Submits the form
    F->>V: validateBooking(draft, bikes)
    alt There are errors
        V-->>F: { bicicletaId: '…' }
        F-->>U: Messages on the fields (03-05)
    else Valid
        V-->>F: {}
        F->>R: dispatch({ type: 'booking_created', booking })
        F->>N: navigate('/reservas', { replace: true, state: {…} })
        N-->>U: "My bookings" screen with the notice
    end
// src/pages/NewBookingPage.jsx
import { useNavigate } from 'react-router';
import { useBookings } from '../contexts/BookingsContext.jsx';
import { useUser } from '../contexts/UserContext.jsx';
import { validateBooking } from '../utils/validateBooking.js';
import BookingForm from '../components/BookingForm.jsx';
import { bikes } from '../data/domain.js';

function NewBookingPage() {
  const navigate = useNavigate();
  const { state, dispatch } = useBookings();
  const { user } = useUser();

  function handleSubmit(draft) {
    const errors = validateBooking(draft, bikes);
    if (Object.keys(errors).length > 0) {
      return; // the form itself already shows the messages (03-05)
    }

    dispatch({ type: 'submission_started' });

    // Non-deterministic data is generated here, not in the reducer (05-05)
    const booking = {
      id: `res-${crypto.randomUUID().slice(0, 8)}`,
      bicicletaId: draft.bicicletaId,
      user: user.id,
      startDate: draft.startDate,
      hours: draft.hours,
      status: 'activa'
    };

    dispatch({ type: 'booking_created', booking });

    // replace: the form shouldn't stay in history
    navigate('/reservas', {
      replace: true,
      state: { bookingCreatedNotice: true, bookingId: booking.id }
    });
  }

  return (
    <section>
      <h2>New booking</h2>
      <BookingForm
        bikes={bikes}
        draft={state.draft}
        onFieldChange={(field, value) =>
          dispatch({ type: 'draft_updated', field, value })
        }
        onSubmit={handleSubmit}
      />
    </section>
  );
}

export default NewBookingPage;

And the destination, which shows the notice only once:

// src/pages/BookingsPage.jsx
import { useEffect } from 'react';
import { useLocation, useNavigate, Link } from 'react-router';
import { useBookings } from '../contexts/BookingsContext.jsx';
import { useNotices } from '../contexts/NoticesContext.jsx';
import BookingsPanel from '../components/BookingsPanel.jsx';

function BookingsPage() {
  const { state } = useBookings();
  const { showNotice } = useNotices();
  const location = useLocation();
  const navigate = useNavigate();

  useEffect(() => {
    if (!location.state?.bookingCreatedNotice) return;

    showNotice({
      tone: 'success',
      title: 'Booking created',
      text: `Your booking ${location.state.bookingId} is confirmed.`
    });

    // The state is consumed so it doesn't reappear on reload (F5)
    navigate(location.pathname, { replace: true, state: null });
  }, [location.state, location.pathname, showNotice, navigate]);

  return (
    <section>
      <h2>My bookings</h2>
      {state.bookings.length === 0 ? (
        <p>
          You don't have any bookings yet. <Link to="/reservas/nueva">Create one</Link>
        </p>
      ) : (
        <BookingsPanel bookings={state.bookings} />
      )}
    </section>
  );
}

export default BookingsPage;

Three decisions worth justifying:

  • dispatch before navigate. The order barely matters in practice, because React batches updates (05-01) and BookingsProvider lives in Layout, which doesn't unmount (06-03), but reading it in that order expresses the intent: first the data is saved, then the screen changes.
  • The state is consumed. Without that navigate(pathname, { state: null }), an F5 on /reservas would show "Booking created" again, because state survives a reload. The entry is replaced with an identical one with no state, and the notice doesn't come back.
  • replace: true on both navigations. On the first, to get the form out of history. On the second, to avoid adding a duplicate /reservas entry every time the notice is cleared.

  1. Declarative redirects with <Navigate />

<Navigate /> is a component that navigates when it renders. It paints nothing.

import { Navigate } from 'react-router';

function WorkshopPage() {
  const { isOperator } = useUser();

  if (!isOperator) {
    return <Navigate to="/acceso" replace />;
  }

  return <WorkshopPanel />;
}

Its props are the same options as useNavigate:

Prop Equivalent to
to The destination
replace { replace: true }
state { state: … }
relative { relative: … }

The advantage over useNavigate in render is that it's legal: <Navigate /> doesn't navigate during the render of whoever returns it, but in its own mount effect. That's why it doesn't produce the "Cannot update a component while rendering another" warning from section 2.

  1. <Navigate /> vs. useNavigate in an effect

Both solve "take the user somewhere else without them clicking anything," and choosing well avoids quite a few problems:

<Navigate to replace /> useNavigate inside useEffect
Where it's written In the return, like any JSX In an effect
When it acts On render After render, when its dependencies change
Readability High: it's visible in the tree Medium: you have to read the dependencies
Condition based on props/state ✅ Natural ✅ Possible
Reacting to something asynchronous (a promise, a timer) ❌ No ✅ Yes
Risk of a loop Low High if the condition is missing
Recommendation By default Only when the decision can't be made during render

Practical rule: if you can decide by looking at props and state during render, use <Navigate />. That's what ProtectedRoute will do in 06-05. Leave the effect for when the decision depends on something that happens afterwards:

// Legitimate case of navigation in an effect: session timeout from inactivity
useEffect(() => {
  const timer = setTimeout(() => {
    signOut();
    navigate('/acceso', { replace: true, state: { reason: 'inactivity' } });
  }, 15 * 60 * 1000);

  return () => clearTimeout(timer);   // mandatory cleanup (05-02)
}, [signOut, navigate]);

The important warning: navigating inside an effect with no condition causes a loop.

// ❌ INFINITE LOOP
useEffect(() => {
  navigate('/reservas');
}, [navigate]);

Walking through the disaster: the component mounts, the effect navigates to /reservas, the route changes, the screen mounts, the effect navigates again… The tab freezes and history fills up with entries. With { replace: true } history doesn't grow, but the loop continues.

The three rules that prevent it:

  1. Always a condition that stops being true after navigating: if (!user) navigate('/acceso').
  2. Complete and stable dependencies. navigate is stable across renders, so it can safely go in the array.
  3. Check where you are. If the destination could match the current route, if (location.pathname !== destination) before navigating.

  1. Blocking the exit with useBlocker

A familiar situation: the user has half-filled a booking and clicks "Stations" in the menu. If you let them go, they lose the work with no warning at all.

useBlocker — available only in data mode, another reason for the choice made in 06-01 — lets you intercept an in-progress navigation and decide whether to let it through.

const blocker = useBlocker(conditionOrFunction);

The blocker is an object with a state:

blocker.state Meaning
'unblocked' Nothing is blocked
'blocked' A navigation has been intercepted and is waiting for a decision
'proceeding' proceed() has been called and the navigation is completing

And two methods, available while it's 'blocked':

  • blocker.proceed(): lets the intercepted navigation continue.
  • blocker.reset(): cancels it; the user stays where they were.

There's also blocker.location, which holds the destination that was being attempted, useful for telling the user where they were headed.

// src/hooks/useBlockExit.js
import { useBlocker } from 'react-router';

/**
 * Intercepts leaving a screen when there are unsaved changes.
 * Parameters:
 *  - hasUnsavedChanges (boolean)
 * Returns: React Router's blocker object
 */
export function useBlockExit(hasUnsavedChanges) {
  return useBlocker(
    ({ currentLocation, nextLocation }) =>
      hasUnsavedChanges && currentLocation.pathname !== nextLocation.pathname
  );
}

Comparing pathname avoids blocking when only the query string changes: if the user adjusts a filter within the same screen, there's no point asking whether they want to leave it.

And the confirmation UI, reusing the project's Modal:

// src/components/LeaveDialog.jsx
import Modal from './Modal.jsx';

/**
 * Confirmation dialog for leaving with unsaved changes.
 * Props:
 *  - blocker (object, required): the one returned by useBlocker
 */
function LeaveDialog({ blocker }) {
  if (blocker.state !== 'blocked') return null;

  return (
    <Modal
      title="You have an unfinished booking"
      onClose={() => blocker.reset()}
    >
      <p>
        If you leave now you'll lose the data you entered. Do you want to leave
        anyway?
      </p>
      <p>
        <button type="button" onClick={() => blocker.reset()}>
          Keep editing
        </button>{' '}
        <button type="button" onClick={() => blocker.proceed()}>
          Leave without saving
        </button>
      </p>
    </Modal>
  );
}

export default LeaveDialog;

Usage on the new booking screen:

// src/pages/NewBookingPage.jsx — additions
import { useBlockExit } from '../hooks/useBlockExit.js';
import LeaveDialog from '../components/LeaveDialog.jsx';

function NewBookingPage() {
  const { state, dispatch } = useBookings();
  // …

  // There are changes if the draft differs from empty and hasn't been submitted yet
  const hasChanges =
    state.submitState !== 'submitted' &&
    (state.draft.bicicletaId !== '' || state.draft.startDate !== '');

  const blocker = useBlockExit(hasChanges);

  return (
    <section>
      <h2>New booking</h2>
      <BookingForm /* … */ />
      <LeaveDialog blocker={blocker} />
    </section>
  );
}

What useBlocker doesn't cover, worth keeping in mind: it only intercepts React Router navigations. Closing the tab, reloading with F5, or typing another address in the bar don't go through the router. For those cases there's a browser mechanism, much cruder:

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

  function handleUnload(event) {
    event.preventDefault();
    // The browser shows ITS OWN message: it can't be customized
    event.returnValue = '';
  }

  window.addEventListener('beforeunload', handleUnload);
  return () => window.removeEventListener('beforeunload', handleUnload);
}, [hasChanges]);

And a design warning: blocking the exit is intrusive. Only use it when the loss is real and costly — a long form, an editor — never for a half-typed search box. And always with a well-tuned condition: a dialog that pops up when the user hasn't touched anything is one of the most annoying things an application can do.

  1. Restoring scroll with ScrollRestoration

Try this on any unconfigured SPA: scroll to the bottom of a long list, open a detail page, go back. You'll land right at the top, and you'll have to scroll down again to find where you were.

The cause is one you already know from 06-01: navigating doesn't reload the page. The browser's automatic scroll restoration on the back button is tied to the document-loading cycle, and in an SPA that cycle never happens. The DOM gets replaced without the browser considering that any navigation has taken place.

React Router solves this with a component you place once, in Layout:

// src/components/Layout.jsx — the module's final version
import { Outlet, ScrollRestoration } from 'react-router';
// …

function Layout() {
  return (
    <BookingsProvider>
      <NoticesProvider>
        <div className={styles.layout}>
          <Header />
          <NoticeList />
          <main className={styles.main}>
            <Breadcrumbs />
            <Outlet />
          </main>
          <Footer />
          <ScrollRestoration />
        </div>
      </NoticesProvider>
    </BookingsProvider>
  );
}

Its default behaviour:

  • On a new navigation (<Link>, navigate), it scrolls to the top.
  • On a back navigation (back/forward), it restores the position that history entry had.

It can be fine-tuned with getKey, which decides under which key each position is saved:

<ScrollRestoration
  getKey={(location) => {
    // The catalogue shares position even when the filter changes:
    // returning from a detail page goes back to the same point in the list
    if (location.pathname === '/') return location.pathname;
    // Everything else, one position per history entry (normal behaviour)
    return location.key;
  }}
/>

And for occasional cases where you don't want the scroll to jump to the top — switching tabs within a long screen, for example — both <Link> and navigate accept preventScrollReset:

<NavLink to="incidencias" preventScrollReset>Incidents</NavLink>
navigate('?tipo=carga', { preventScrollReset: true });

ScrollRestoration, together with useBlocker, is another API exclusive to data mode.

  1. Loading indicators with useNavigation

In CicloUrbano, navigations are instant because the data lives in memory. As soon as a route loads data from an API, or its code is lazy-loaded (08-04), there will be a window where the user has already clicked and still sees nothing. Without a visible signal, they'll click again.

useNavigation reports the global navigation state:

import { useNavigation } from 'react-router';

const navigation = useNavigation();
navigation.state Meaning
'idle' No navigation in progress
'loading' A navigation is underway: loading the destination route's code or data
'submitting' A form is being submitted to an action

And a couple of helper fields: navigation.location (where it's going), navigation.formData (what's being submitted).

A discreet progress bar in Layout:

// src/components/ProgressBar.jsx
import { useNavigation } from 'react-router';
import styles from './ProgressBar.module.css';

function ProgressBar() {
  const navigation = useNavigation();
  const loading = navigation.state !== 'idle';

  if (!loading) return null;

  return (
    <div
      className={styles.bar}
      role="progressbar"
      aria-label="Loading the page"
      aria-busy="true"
    />
  );
}

export default ProgressBar;
/* src/components/ProgressBar.module.css */
.bar {
  position: fixed;
  top: 0;
  left: 0;
  height: 3px;
  width: 100%;
  background: var(--color-brand);
  animation: advance 1.2s ease-in-out infinite;
  transform-origin: left;
}

@keyframes advance {
  0%   { transform: scaleX(0); }
  50%  { transform: scaleX(0.7); }
  100% { transform: scaleX(1); }
}

/* Respects the reduced-motion preference (03-06) */
@media (prefers-reduced-motion: reduce) {
  .bar { animation: none; opacity: 0.8; }
}

A UX detail worth applying: a bar that appears and disappears within 80 milliseconds produces a flicker more annoying than the wait itself. Delay its appearance:

function ProgressBar() {
  const navigation = useNavigation();
  const [visible, setVisible] = useState(false);

  useEffect(() => {
    if (navigation.state === 'idle') {
      setVisible(false);
      return;
    }
    // Only shown if the wait exceeds 200 ms
    const timer = setTimeout(() => setVisible(true), 200);
    return () => clearTimeout(timer);
  }, [navigation.state]);

  if (!visible) return null;
  return <div className={styles.bar} role="progressbar" aria-busy="true" />;
}

And useNavigation is also useful locally, disabling the submit button while it's processing:

const navigation = useNavigation();
const submitting = navigation.state === 'submitting';

<button type="submit" disabled={submitting}>
  {submitting ? 'Creating booking…' : 'Confirm booking'}
</button>

Common Mistakes and Tips

Calling navigate during render. Produces "Cannot update a component while rendering another." Only inside event handlers or effects; for render, use <Navigate />.

Navigating in an effect with no condition. A guaranteed infinite loop. Always have a condition that stops being true after navigating.

Forgetting replace after submitting a form. "Back" returns to the form and the user resubmits. Duplicate bookings.

Using navigate(-1) when the destination is known. <Link to="/estaciones"> is deterministic, accessible and can be opened in a new tab. -1 can take the user out of the application if they arrived by pasting the URL.

Navigating with onClick on a <div> or a <button> when it should be a link. You lose everything a real <a> gives you for free: new tab, copying the address, keyboard, screen readers, search engines.

Putting important data in state. It's lost when opening the URL in another tab and can't be shared. If the data defines what's shown, it belongs in the URL.

Not consuming state after using it. The "Booking created" notice reappears on every F5, because state survives a reload. Replace the entry with state: null.

Using the whole location as an effect dependency. It's a new object on every navigation. Destructure pathname and search.

Blocking the exit with overly broad conditions. A dialog that appears when the user hasn't touched anything is worse than not having one at all.

Tip: centralise your routes in constants. ROUTES.reservas instead of '/reservas' scattered across twenty files means renaming a section is a one-line change:

// src/route-constants.js
export const ROUTES = {
  catalogue: '/',
  bikeDetail: (id) => `/bicicletas/${id}`,
  stations: '/estaciones',
  stationDetail: (id) => `/estaciones/${id}`,
  bookings: '/reservas',
  newBooking: '/reservas/nueva',
  signIn: '/acceso',
  workshop: '/taller'
};

Tip: always ask yourself what happens when "back" is pressed. It's the check that catches almost every navigation bug in an application, and the one nobody runs until a user complains.

Exercises

Exercise 1: sending a bike to the workshop

BikeCard has the onSendToWorkshop prop, visible only to operators. Implement the flow in BikeDetailPage: on click, the bike is marked as mantenimiento and the operator is taken to /taller with a notice showing which bike they sent. Decide, with reasoning, whether to use replace and why.

Exercise 2: filter with a return to the catalogue

A user enters /?tipo=electrica, opens the detail page for bici-005, and clicks the "Back to catalogue" link on it. Right now they land on / with no filter and lose their context. Fix it so they return to /?tipo=electrica, using what you've learned about state and useLocation. Explain why this solution has a limitation when the detail page is opened by pasting the URL directly, and what alternative exists.

Exercise 3: analysing four navigations

For each snippet, say whether it's correct and, if not, what's wrong and how to fix it.

// A
function BookingsPage() {
  const { state } = useBookings();
  const navigate = useNavigate();
  if (state.bookings.length === 0) navigate('/reservas/nueva');
  return <BookingsPanel bookings={state.bookings} />;
}

// B
function SignInPage() {
  const { user } = useUser();
  if (user) return <Navigate to="/" replace />;
  return <SignInForm />;
}

// C
function BikeDetailPage() {
  const navigate = useNavigate();
  const { bicicletaId } = useParams();
  useEffect(() => {
    if (!bikes.some((b) => b.id === bicicletaId)) {
      navigate('/', { replace: true });
    }
  });
  // …
}

// D
function CancelButton() {
  const navigate = useNavigate();
  return <a onClick={() => navigate(-1)}>Cancel</a>;
}

Solutions

Solution 1

// src/pages/BikeDetailPage.jsx — fragment
import { useNavigate, useParams, Link } from 'react-router';
import { useUser } from '../contexts/UserContext.jsx';
import { bikes, stations } from '../data/domain.js';
import BikeCard from '../components/BikeCard.jsx';
import NotFoundPage from './NotFoundPage.jsx';

function BikeDetailPage() {
  const { bicicletaId } = useParams();
  const navigate = useNavigate();
  const { isOperator } = useUser();

  const bike = bikes.find((b) => b.id === bicicletaId);
  if (!bike) {
    return <NotFoundPage resource="bike" id={bicicletaId} />;
  }

  function handleSendToWorkshop() {
    // In a real application this would be an API call or a dispatch to the reducer
    bike.status = 'mantenimiento';   // didactic simplification

    navigate('/taller', {
      state: {
        bikeSentNotice: true,
        model: bike.model,
        bikeId: bike.id
      }
    });
  }

  return (
    <article>
      <BikeCard
        bike={bike}
        stationName={stations.find((s) => s.id === bike.stationId)?.name}
        onSendToWorkshop={isOperator ? handleSendToWorkshop : undefined}
      />
      <Link to="/">Back to the catalogue</Link>
    </article>
  );
}

On replace: it's not a good fit here. The reasoning: the operator was looking at the bike's detail page, and that page is still a valid, useful destination after the operation — in fact, they'll want to check that it now shows as in maintenance. The detail page isn't a form that becomes stale once submitted, so "back" should return them to it. Compare this with the booking case: there, the /reservas/nueva form does become stale, and returning to it invites duplicating the booking.

Solution 2

// src/components/BikeCard.jsx — the link stores where it came from
import { Link, useLocation } from 'react-router';

function BikeCard({ bike, /* … */ }) {
  const location = useLocation();

  return (
    <article>
      <h3>
        <Link
          to={`/bicicletas/${bike.id}`}
          state={{ returnTo: location.pathname + location.search }}
        >
          {bike.model}
        </Link>
      </h3>
      {/* … */}
    </article>
  );
}
// src/pages/BikeDetailPage.jsx — the return link reads it
const location = useLocation();
const returnDestination = location.state?.returnTo ?? '/';

return (
  <article>
    {/* … */}
    <Link to={returnDestination}>Back to the catalogue</Link>
  </article>
);

Note that <Link> accepts state just like navigate, and that pathname + search is stored so the filter isn't lost.

The limitation: if the user opens /bicicletas/bici-005 by pasting the URL or from a shared link, there's no state — they haven't come through the catalogue — and the ?? '/' sends them to the unfiltered catalogue. That's correct and doesn't break anything, but it's worth understanding why it happens: state travels with the history entry, not with the address.

The alternative is to put the origin in the URL: /bicicletas/bici-005?returnTo=%2F%3Ftipo%3Delectrica. Advantage: it survives sharing the link and opening it in another tab. Drawbacks: it clutters a URL that should be clean and shareable, and the value needs sanitising — a returnTo pointing to another domain would be an open-redirect vector. For an incidental piece of navigation data like this, state is the right choice; for something that needs to be shareable, the URL.

Solution 3

A — Incorrect. It calls navigate during render: a React warning and unpredictable behaviour. It's also bad design: forcibly taking whoever opens "My bookings" to /reservas/nueva stops them from seeing that they have none. The right approach is to show an empty state with a link:

if (state.bookings.length === 0) {
  return (
    <p>
      You don't have any bookings yet. <Link to="/reservas/nueva">Create one</Link>
    </p>
  );
}

B — Correct. <Navigate /> during render is legal, the user condition stops being true after navigating (no loop), and replace is appropriate: an already signed-in user shouldn't be able to return to the sign-in form by pressing "back." This is exactly the pattern you'll use in 06-05.

C — Incorrect for two reasons. First, the useEffect is missing the dependency array, so it runs after every render; with the condition in place there's no infinite loop yet, but it's a 05-02-style oversight that will trigger one the moment the condition relaxes. It should be }, [bicicletaId, navigate]);. Second, and more importantly, it's the wrong strategy: redirecting to the catalogue when the identifier doesn't exist wipes out the problem URL and leaves the user with no idea what happened. Better to use 06-02's solution: check during render and return <NotFoundPage resource="bike" … />, keeping the URL so it can be fixed or reported.

D — Incorrect. An <a> with no href isn't a link to the browser: it's not reachable with the tab key, doesn't respond to the Enter key, and a screen reader doesn't announce it. And semantically it isn't a link either, because it doesn't lead to a known destination: it's an action. The right approach is a button:

<button type="button" onClick={() => navigate(-1)}>Cancel</button>

type="button" is mandatory by the project's convention, and for a concrete reason: inside a form, a <button> with no type defaults to submit and would trigger an accidental submission.

Conclusion

Programmatic navigation covers everything that isn't a click on a link, and the criterion for choosing between the two is simple: if the destination is known at render time and the user is the one deciding, it's a <Link>; if the application decides, it's code. useNavigate returns a function that accepts a destination and some options, or a number to move through history, and of those options the most important is replace: replacing the current entry instead of pushing another one is the right call after submitting a form, after signing in, and on any redirect, because it stops "back" from returning to a stale screen and the user from duplicating a booking by accident. The state option carries data that doesn't appear in the URL — the "how I got here," never the "what I'm looking at" — it survives a reload but not opening the link in another tab, and it's worth consuming it after use. useLocation gives you the URL broken down into pathname, search, hash, state and key, with the detail that key changes on every navigation and is useful for forcing a subtree to reset.

You've implemented CicloUrbano's central flow: confirming the BookingForm on /reservas/nueva validates with validateBooking, builds the booking with its non-deterministic data generated outside the reducer, dispatches booking_created to bookingsReducer, and navigates to /reservas with replace and a state that fires the BookingCreatedNotice exactly once. For redirects, you've seen that <Navigate to replace /> is the default choice — legal during render, readable in the tree — and that useNavigate inside an effect is reserved for when the decision depends on something asynchronous, always with a condition that stops being true after navigating, because without one the loop is immediate. And you've added three APIs exclusive to data mode that raise the application's quality quite a bit: useBlocker, with its blocked and proceeding states, to stop a half-filled form from being lost — knowing it doesn't cover closing the tab, for which you need beforeunloadScrollRestoration, to return the user to the point in the list where they were, something that doesn't happen automatically in an SPA precisely because there's never a document reload, and useNavigation, to show a loading indicator when navigation takes a while, with the delay that avoids the flicker.

One piece of the module remains. /taller has been on the map since 06-02, and right now anyone can open it: just type the address. CicloUrbano has two profiles — usr-01 Ana Ribera, a customer, and usr-02 Marc Solé, an operator — and the workshop panel is only for the second one. You still need to build the sign-in flow at /acceso, a guard that redirects anyone without a session and knows how to return them to their original destination, role-based authorisation with a "forbidden" screen distinct from "not found," and — the most important thing in the whole lesson — understanding why none of this is real security. The next lesson is Protected Routes and Access Control.

React Course

Module 1: Getting Started with React

Module 2: React Components

Module 3: Working with Events

Module 4: Advanced Component Concepts

Module 5: React Hooks

Module 6: Routing in React

Module 7: State Management

Module 8: Performance Optimization

Module 9: Testing React Applications

Module 10: Advanced Topics

Module 11: Project: Building a Complete Application

© Copyright 2026. All rights reserved