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
- When to navigate from code, and when not to
useNavigate: the full signaturereplace: when not to leave a trace in history- Navigating back and forward with numbers
- Passing state through navigation
useLocationin full- Central case: confirming a booking
- Declarative redirects with
<Navigate /> <Navigate />vs.useNavigatein an effect- Blocking the exit with
useBlocker - Restoring scroll with
ScrollRestoration - Loading indicators with
useNavigation
- 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.
useNavigate: the full signature
useNavigate: the full signatureimport { 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 levelOne 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.
replace: when not to leave a trace in history
replace: when not to leave a trace in historyBy 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-003into/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.
- 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.
- 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:
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.
useLocation in full
useLocation in fulluseLocation() 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.
- 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:
dispatchbeforenavigate. The order barely matters in practice, because React batches updates (05-01) andBookingsProviderlives inLayout, 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
stateis consumed. Without thatnavigate(pathname, { state: null }), an F5 on/reservaswould show "Booking created" again, becausestatesurvives a reload. The entry is replaced with an identical one with nostate, and the notice doesn't come back. replace: trueon both navigations. On the first, to get the form out of history. On the second, to avoid adding a duplicate/reservasentry every time the notice is cleared.
- Declarative redirects with
<Navigate />
<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.
<Navigate /> vs. useNavigate in an effect
<Navigate /> vs. useNavigate in an effectBoth 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.
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:
- Always a condition that stops being true after navigating:
if (!user) navigate('/acceso'). - Complete and stable dependencies.
navigateis stable across renders, so it can safely go in the array. - Check where you are. If the destination could match the current route,
if (location.pathname !== destination)before navigating.
- Blocking the exit with
useBlocker
useBlockerA 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.
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.
- Restoring scroll with
ScrollRestoration
ScrollRestorationTry 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.
- Loading indicators with
useNavigation
useNavigationIn 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:
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:
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 beforeunload — ScrollRestoration, 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
- What Is React?
- Setting Up the Development Environment
- Hello World in React
- JSX: A JavaScript Syntax Extension
- How React Renders: Virtual DOM and Reconciliation
Module 2: React Components
- Understanding Components
- Function vs Class Components
- Props: Passing Data to Components
- State: Managing Component State
- Styling Components: CSS, Modules and Utilities
Module 3: Working with Events
- Handling Events in React
- Conditional Rendering
- Lists and Keys
- Forms and Controlled Components
- Form Validation and Uncontrolled Components
- Accessibility in Interactive Components
Module 4: Advanced Component Concepts
- Lifting State Up
- Composition vs Inheritance
- React Lifecycle Methods
- Hooks: Introduction and Basic Use
- Error Boundaries: Catching Failures in the UI
Module 5: React Hooks
- The useState Hook
- The useEffect Hook
- The useRef Hook and DOM Access
- The useContext Hook
- The useReducer Hook
- Custom Hooks
Module 6: Routing in React
- Introducing React Router
- Setting Up React Router
- Nested Routes
- Programmatic Navigation
- Protected Routes and Access Control
Module 7: State Management
- Introduction to State Management
- The Context API
- Redux: Introduction and Setup
- Redux: Actions and Reducers
- Redux: Connecting to React
- Server State: Fetching, Caching and Syncing
Module 8: Performance Optimization
- Performance Optimization Techniques in React
- Memoization with React.memo
- The useMemo and useCallback Hooks
- Code Splitting and Lazy Loading
- Measuring Performance with React DevTools Profiler
Module 9: Testing React Applications
- Introduction to Testing
- Unit Testing with Jest
- Component Testing with React Testing Library
- Testing Asynchronous Code and Mocking APIs
- End-to-End Testing with Cypress
Module 10: Advanced Topics
- Server-Side Rendering (SSR) with Next.js
- Static Site Generation (SSG) with Next.js
- Suspense and React Server Components
- TypeScript with React
- React Native: Building Mobile Apps
