Toda la navegación de CicloUrbano nace hasta ahora de un clic del usuario sobre un <Link>. Pero muchas navegaciones no son un clic sobre un enlace: cuando el usuario confirma el FormularioReserva en /reservas/nueva, la aplicación debe crear la reserva y llevarlo a /reservas, sin que el formulario quede en el historial esperando a que pulse «atrás» y lo reenvíe; cuando una operación termina, conviene volver a la pantalla anterior; cuando alguien intenta abandonar un formulario a medias, conviene detenerlo y preguntar. Todo eso es navegación programática: navegar desde el código. En esta lección aprenderás useNavigate con sus opciones, cómo pasar y leer estado en la navegación, el useLocation completo, la alternativa declarativa <Navigate />, cómo bloquear una salida con useBlocker, cómo restaurar la posición de desplazamiento con ScrollRestoration y cómo mostrar un indicador de carga con useNavigation.

Contenido

  1. Cuándo navegar desde el código y cuándo no
  2. useNavigate: la firma completa
  3. replace: cuándo no dejar rastro en el historial
  4. Navegar hacia atrás y hacia adelante con números
  5. Pasar estado en la navegación
  6. useLocation al completo
  7. Caso central: confirmar una reserva
  8. Redirecciones declarativas con <Navigate />
  9. <Navigate /> frente a useNavigate en un efecto
  10. Bloquear la salida con useBlocker
  11. Restaurar el desplazamiento con ScrollRestoration
  12. Indicadores de carga con useNavigation

  1. Cuándo navegar desde el código y cuándo no

Antes de la API, el criterio, porque el error más común de este tema no es técnico sino de diseño.

Si el usuario decide ir a un sitio, eso es un enlace. Si la aplicación decide llevarlo, eso es navegación programática.

Situación Herramienta correcta
«Ver ficha de la bicicleta» <Link to={...}>
Menú de la cabecera <NavLink to={...}>
Pestañas de una estación <NavLink to="incidencias">
Tras confirmar una reserva, ir a «Mis reservas» useNavigate
Tras identificarse, volver al destino original useNavigate
Si no hay sesión, llevar a /acceso <Navigate />
Botón «Cancelar» que vuelve atrás useNavigate(-1)
Tras 5 segundos de inactividad, cerrar sesión useNavigate

Y el antipatrón que hay que evitar por encima de todo:

{/* ❌ NUNCA hagas esto */}
<div onClick={() => navigate('/estaciones')}>Estaciones</div>

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

Ambos «funcionan» y ambos son un fallo de accesibilidad grave, por las razones de 03-06: no se pueden abrir en pestaña nueva, no se puede copiar la dirección, un lector de pantalla los anuncia como «botón» o directamente como nada, los buscadores no los siguen y el <div> ni siquiera es alcanzable con el tabulador. Un <Link> produce un <a> de verdad y conserva todo eso. Si el destino se conoce en el momento de renderizar, es un enlace.

  1. useNavigate: la firma completa

import { useNavigate } from 'react-router';

function MiComponente() {
  const navegar = useNavigate();
  // …
}

El hook devuelve una función con dos formas de llamada:

// Forma 1: navegar a una ruta
navegar(destino, opciones);

// Forma 2: moverse por el historial
navegar(delta);   // número: -1 atrás, 1 adelante, -2 dos atrás…

Las opciones de la primera forma:

Opción Tipo Qué hace
replace booleano Sustituye la entrada actual del historial en vez de añadir una
state cualquiera serializable Datos que viajan con la navegación, invisibles en la URL
relative 'route' | 'path' Cómo interpretar un destino relativo (como en <Link>, 06-03)
preventScrollReset booleano Impide que el desplazamiento vuelva arriba
flushSync booleano Fuerza la actualización síncrona del DOM. Caso raro

Y el destino admite tanto una cadena como un objeto, útil cuando quieres construir la consulta por partes:

navegar('/reservas');                                  // cadena simple
navegar(`/bicicletas/${bicicleta.id}`);                // interpolada
navegar({ pathname: '/', search: '?tipo=electrica' }); // objeto
navegar('incidencias');                                // relativa a la ruta activa
navegar('..');                                         // sube un nivel de ruta

Una regla que no se puede saltar: navegar cambia el estado del enrutador, así que solo se puede llamar durante un evento o dentro de un efecto, nunca durante el render.

function PaginaTaller() {
  const navegar = useNavigate();
  const { esOperario } = useUsuario();

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

Ese error es de los primeros que verás. Durante el render, un componente no puede provocar una actualización de otro. Las dos salidas correctas están en los apartados 8 y 9.

  1. replace: cuándo no dejar rastro en el historial

Por defecto, navegar('/reservas') apila una entrada nueva en el historial, igual que un <Link>. Con { replace: true } sustituye la actual.

Compara los dos historiales tras crear una reserva:

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

Estando en /reservas y pulsando «atrás»:

Sin replace Con replace: true
Destino de «atrás» /reservas/nueva /
Qué ve el usuario El formulario otra vez, quizá con los datos que ya envió El catálogo
Riesgo Que vuelva a enviar la misma reserva Ninguno

Ese riesgo es real y tiene nombre propio: el usuario ve el formulario, piensa que no se guardó, vuelve a rellenar y acaba con dos reservas idénticas. Es exactamente el problema que en las webs tradicionales resuelve el patrón POST-Redirect-GET, y replace es su equivalente en una SPA.

Cuándo usar replace: true:

  • Después de enviar un formulario con éxito. El formulario ya no debe estar en el historial.
  • Después de iniciar sesión. «Atrás» no debe devolver al formulario de acceso de un usuario ya identificado.
  • En una redirección. Una pantalla que solo redirige no debe quedar en el historial, o «atrás» rebotaría en bucle.
  • Al corregir la URL. Si conviertes /bicicletas/BICI-003 en /bicicletas/bici-003, sustituye.
  • En filtros y ordenaciones (ya lo aplicaste en 06-02 con useSearchParams).

Cuándo NO usarlo:

  • Navegación normal entre secciones. El usuario espera poder volver.
  • Al abrir un detalle desde una lista. «Atrás» debe devolver a la lista.
  • Al cambiar de pestaña dentro de una pantalla, si consideras que son estados navegables.

  1. Navegar hacia atrás y hacia adelante con números

navegar(-1);   // atrás, como el botón del navegador
navegar(1);    // adelante
navegar(-2);   // dos pantallas atrás
navegar(0);    // recarga la ruta actual (raro, pero existe)

Es la forma natural de un botón «Cancelar» o «Volver»:

// src/componentes/BotonVolver.jsx
import { useNavigate } from 'react-router';

/**
 * Botón de retroceso.
 * Props:
 *  - destinoAlternativo (cadena, opcional, por defecto '/'): a dónde ir si no hay historial
 *  - children (contenido, opcional)
 */
function BotonVolver({ destinoAlternativo = '/', children = 'Volver' }) {
  const navegar = useNavigate();

  function manejarClic() {
    // window.history.length > 2 ≈ hay a dónde volver dentro de la aplicación
    if (window.history.length > 2) {
      navegar(-1);
    } else {
      navegar(destinoAlternativo, { replace: true });
    }
  }

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

export default BotonVolver;

La razón del if es una limitación honesta que conviene conocer: navegar(-1) no puede saber a dónde va. Si el usuario ha llegado a /reservas/nueva pegando la URL directamente, no hay entrada anterior dentro de tu aplicación y -1 lo sacará de CicloUrbano, quizá de vuelta a su buscador. Y window.history.length es solo una aproximación, porque cuenta todo el historial de la pestaña, no solo el de tu aplicación; no existe una forma fiable de saberlo, por razones de privacidad.

Por eso, para un botón «Volver a las estaciones» con destino conocido, es preferible un <Link to="/estaciones">: es determinista, accesible y se puede abrir en pestaña nueva. Reserva navegar(-1) para el «Cancelar» genérico de un diálogo o un formulario al que se puede llegar desde varios sitios.

  1. Pasar estado en la navegación

A veces la pantalla de destino necesita saber algo sobre cómo se ha llegado a ella. El caso de CicloUrbano: tras crear una reserva quieres que /reservas muestre el AvisoReservaCreada, pero solo si vienes de crearla; no cada vez que entras.

La opción state transporta datos que no aparecen en la URL:

navegar('/reservas', {
  replace: true,
  state: { avisoReservaCreada: true, idReserva: reserva.id }
});

Y en el destino se lee con useLocation:

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

Este mecanismo se apoya directamente en el history.pushState que viste en 06-01, con las consecuencias que eso implica:

Característica Detalle
No se ve en la URL Bien para datos accesorios, mal para nada que deba ser compartible
Sobrevive a la recarga El navegador lo guarda con la entrada del historial
Se pierde al abrir la URL en pestaña nueva No forma parte de la dirección
Debe ser serializable Nada de funciones, Map, Set, elementos del DOM o clases
Tiene límite de tamaño Unos pocos megabytes; no es un almacén de datos
Es visible para el usuario history.state en la consola lo muestra: nunca metas secretos ahí

Y la regla de diseño que evita el mal uso: state es para el «cómo he llegado», no para el «qué estoy viendo». El aviso de reserva creada, el destino original antes de un inicio de sesión o «vengo del listado filtrado» son buenos usos. La bicicleta que se está mostrando no: eso va en la URL, porque debe ser compartible y sobrevivir a una pestaña nueva.

  1. useLocation al completo

useLocation() devuelve un objeto con la URL actual descompuesta, y repinta el componente cada vez que cambia.

import { useLocation } from 'react-router';

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

Para la URL /estaciones/est-02/incidencias?orden=fecha#nota-3:

{
  pathname: '/estaciones/est-02/incidencias',
  search: '?orden=fecha',
  hash: '#nota-3',
  state: null,
  key: 'x7k2m9'
}
Propiedad Contenido Uso típico
pathname El camino, sin consulta ni fragmento Decidir qué está activo, registrar la página vista
search La cadena de consulta con su ? Normalmente prefieres useSearchParams (06-02)
hash El fragmento con su # Desplazarse a una sección concreta
state Lo pasado en la navegación Avisos, destino original
key Identificador único de esta entrada del historial Reiniciar estado, cachés por entrada

Sobre key, que es la menos conocida y la más útil de las tres últimas: cambia con cada navegación, incluso si vuelves a la misma URL. Sirve para forzar el reinicio de un subárbol, con el mismo mecanismo de las claves de 03-03:

// Cada vez que se navega, el formulario se remonta desde cero
const { key } = useLocation();
<FormularioReserva key={key} />

Un ejemplo completo de registro de páginas vistas, que en una aplicación real irá a la herramienta de analítica:

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

/**
 * Registra cada cambio de pantalla. Sin parámetros. No devuelve nada.
 */
export function useRegistroDeNavegacion() {
  const { pathname, search } = useLocation();

  useEffect(() => {
    // En producción, aquí iría la llamada a la herramienta de medición
    console.info('[navegación] pantalla vista:', pathname + search);
  }, [pathname, search]);
}

Fíjate en las dependencias: pathname y search, no el objeto location entero. React Router devuelve un objeto nuevo en cada navegación, pero desestructurar cadenas hace que el efecto solo se dispare cuando cambian de verdad, evitando el problema de dependencias de 05-02. Llamado desde Diseno, cubre toda la aplicación.

  1. Caso central: confirmar una reserva

Reunamos todo lo anterior en el flujo más importante de CicloUrbano. En /reservas/nueva el usuario rellena el FormularioReserva; al confirmar, la aplicación valida, construye la reserva, la despacha al reductorReservas y lo lleva a /reservas con un aviso.

sequenceDiagram
    participant U as Usuario
    participant F as PaginaNuevaReserva
    participant V as validarReserva
    participant R as reductorReservas
    participant N as useNavigate
    U->>F: Envía el formulario
    F->>V: validarReserva(borrador, bicicletas)
    alt Hay errores
        V-->>F: { bicicletaId: '…' }
        F-->>U: Mensajes en los campos (03-05)
    else Válido
        V-->>F: {}
        F->>R: despachar({ tipo: 'reserva_creada', reserva })
        F->>N: navegar('/reservas', { replace: true, state: {…} })
        N-->>U: Pantalla «Mis reservas» con el aviso
    end
// src/paginas/PaginaNuevaReserva.jsx
import { useNavigate } from 'react-router';
import { useReservas } from '../contextos/ContextoReservas.jsx';
import { useUsuario } from '../contextos/ContextoUsuario.jsx';
import { validarReserva } from '../utilidades/validarReserva.js';
import FormularioReserva from '../componentes/FormularioReserva.jsx';
import { bicicletas } from '../datos/dominio.js';

function PaginaNuevaReserva() {
  const navegar = useNavigate();
  const { estado, despachar } = useReservas();
  const { usuario } = useUsuario();

  function manejarEnvio(borrador) {
    const errores = validarReserva(borrador, bicicletas);
    if (Object.keys(errores).length > 0) {
      return; // el propio formulario ya muestra los mensajes (03-05)
    }

    despachar({ tipo: 'envio_iniciado' });

    // Los datos no deterministas se generan aquí, no en el reductor (05-05)
    const reserva = {
      id: `res-${crypto.randomUUID().slice(0, 8)}`,
      bicicletaId: borrador.bicicletaId,
      usuario: usuario.id,
      fechaInicio: borrador.fechaInicio,
      horas: borrador.horas,
      estado: 'activa'
    };

    despachar({ tipo: 'reserva_creada', reserva });

    // replace: el formulario no debe quedar en el historial
    navegar('/reservas', {
      replace: true,
      state: { avisoReservaCreada: true, idReserva: reserva.id }
    });
  }

  return (
    <section>
      <h2>Nueva reserva</h2>
      <FormularioReserva
        bicicletas={bicicletas}
        borrador={estado.borrador}
        alCambiarCampo={(campo, valor) =>
          despachar({ tipo: 'borrador_actualizado', campo, valor })
        }
        alEnviar={manejarEnvio}
      />
    </section>
  );
}

export default PaginaNuevaReserva;

Y el destino, que muestra el aviso una sola vez:

// src/paginas/PaginaReservas.jsx
import { useEffect } from 'react';
import { useLocation, useNavigate, Link } from 'react-router';
import { useReservas } from '../contextos/ContextoReservas.jsx';
import { useAvisos } from '../contextos/ContextoAvisos.jsx';
import PanelReservas from '../componentes/PanelReservas.jsx';

function PaginaReservas() {
  const { estado } = useReservas();
  const { mostrarAviso } = useAvisos();
  const location = useLocation();
  const navegar = useNavigate();

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

    mostrarAviso({
      tono: 'exito',
      titulo: 'Reserva creada',
      texto: `Tu reserva ${location.state.idReserva} está confirmada.`
    });

    // Se consume el state para que no reaparezca al recargar (F5)
    navegar(location.pathname, { replace: true, state: null });
  }, [location.state, location.pathname, mostrarAviso, navegar]);

  return (
    <section>
      <h2>Mis reservas</h2>
      {estado.reservas.length === 0 ? (
        <p>
          Todavía no tienes reservas. <Link to="/reservas/nueva">Crear una</Link>
        </p>
      ) : (
        <PanelReservas reservas={estado.reservas} />
      )}
    </section>
  );
}

export default PaginaReservas;

Tres decisiones que merecen justificación:

  • despachar antes de navegar. El orden importa poco en la práctica, porque React agrupa las actualizaciones (05-01) y ProveedorReservas vive en Diseno, que no se desmonta (06-03), pero leerlo en ese orden expresa la intención: primero se guarda el dato, después se cambia de pantalla.
  • Se consume el state. Sin ese navegar(pathname, { state: null }), un F5 en /reservas volvería a mostrar «Reserva creada», porque el state sobrevive a la recarga. Se sustituye la entrada por una idéntica sin state, y el aviso no vuelve.
  • replace: true en ambas navegaciones. En la primera, para sacar el formulario del historial. En la segunda, para no añadir una entrada duplicada de /reservas cada vez que se limpia el aviso.

  1. Redirecciones declarativas con <Navigate />

<Navigate /> es un componente que, al renderizarse, navega. No pinta nada.

import { Navigate } from 'react-router';

function PaginaTaller() {
  const { esOperario } = useUsuario();

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

  return <PanelDeTaller />;
}

Sus props son las mismas opciones de useNavigate:

Prop Equivale a
to El destino
replace { replace: true }
state { state: … }
relative { relative: … }

La ventaja frente a useNavigate en el render es que es legal: <Navigate /> no navega durante el render de quien lo devuelve, sino en su propio efecto de montaje. Por eso no produce el aviso de «Cannot update a component while rendering another» del apartado 2.

  1. <Navigate /> frente a useNavigate en un efecto

Los dos resuelven «llevar al usuario a otro sitio sin que haya pulsado nada», y elegir bien evita bastantes problemas:

<Navigate to replace /> useNavigate dentro de useEffect
Dónde se escribe En el return, como cualquier JSX En un efecto
Cuándo actúa Al renderizarse Tras el render, cuando cambian sus dependencias
Legibilidad Alta: se ve en el árbol Media: hay que leer las dependencias
Condición basada en props/estado ✅ Natural ✅ Posible
Reaccionar a algo asíncrono (una promesa, un temporizador) ❌ No ✅ Sí
Riesgo de bucle Bajo Alto si falta la condición
Recomendación Por defecto Solo cuando la decisión no se puede tomar en el render

Regla práctica: si puedes decidirlo mirando props y estado durante el render, usa <Navigate />. Es lo que hará RutaProtegida en 06-05. Deja el efecto para cuando la decisión dependa de algo que ocurre después:

// Caso legítimo de navegación en efecto: cierre de sesión por inactividad
useEffect(() => {
  const temporizador = setTimeout(() => {
    cerrarSesion();
    navegar('/acceso', { replace: true, state: { motivo: 'inactividad' } });
  }, 15 * 60 * 1000);

  return () => clearTimeout(temporizador);   // limpieza obligatoria (05-02)
}, [cerrarSesion, navegar]);

La advertencia importante: navegar dentro de un efecto sin condición provoca un bucle.

// ❌ BUCLE INFINITO
useEffect(() => {
  navegar('/reservas');
}, [navegar]);

Recorrido del desastre: el componente se monta, el efecto navega a /reservas, la ruta cambia, la pantalla se monta, el efecto vuelve a navegar… La pestaña se congela y el historial se llena de entradas. Con { replace: true } el historial no crece, pero el bucle sigue.

Las tres reglas que lo evitan:

  1. Siempre una condición que deje de cumplirse tras navegar: if (!usuario) navegar('/acceso').
  2. Dependencias completas y estables. navegar es estable entre renders, así que puede ir en el array sin peligro.
  3. Comprueba dónde estás. Si el destino puede coincidir con la ruta actual, if (location.pathname !== destino) antes de navegar.

  1. Bloquear la salida con useBlocker

Situación conocida: el usuario ha rellenado media reserva y pulsa «Estaciones» en el menú. Si lo dejas ir, pierde el trabajo sin ningún aviso.

useBlocker —disponible solo en el modo de datos, otra razón de la elección de 06-01— permite interceptar una navegación en curso y decidir si dejarla pasar.

const bloqueador = useBlocker(condicionOFuncion);

El bloqueador es un objeto con un estado:

bloqueador.state Significado
'unblocked' No hay nada bloqueado
'blocked' Se ha interceptado una navegación y espera decisión
'proceeding' Se ha llamado a proceed() y la navegación está completándose

Y dos métodos, disponibles cuando está 'blocked':

  • bloqueador.proceed(): deja continuar la navegación interceptada.
  • bloqueador.reset(): la cancela; el usuario se queda donde estaba.

Además, bloqueador.location contiene el destino que se intentaba alcanzar, útil para decir a dónde iba.

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

/**
 * Intercepta la salida de una pantalla cuando hay cambios sin guardar.
 * Parámetros:
 *  - haycambiosSinGuardar (booleano)
 * Devuelve: el objeto bloqueador de React Router
 */
export function useBloquearSalida(hayCambiosSinGuardar) {
  return useBlocker(
    ({ currentLocation, nextLocation }) =>
      hayCambiosSinGuardar && currentLocation.pathname !== nextLocation.pathname
  );
}

La comparación de pathname evita bloquear cuando solo cambia la cadena de consulta: si el usuario ajusta un filtro dentro de la misma pantalla, no tiene sentido preguntarle si quiere abandonarla.

Y la interfaz de confirmación, reutilizando el Modal del proyecto:

// src/componentes/DialogoSalida.jsx
import Modal from './Modal.jsx';

/**
 * Diálogo de confirmación de salida con cambios sin guardar.
 * Props:
 *  - bloqueador (objeto, obligatorio): el que devuelve useBlocker
 */
function DialogoSalida({ bloqueador }) {
  if (bloqueador.state !== 'blocked') return null;

  return (
    <Modal
      titulo="Tienes una reserva sin terminar"
      alCerrar={() => bloqueador.reset()}
    >
      <p>
        Si sales ahora perderás los datos que has introducido. ¿Quieres salir de
        todas formas?
      </p>
      <p>
        <button type="button" onClick={() => bloqueador.reset()}>
          Seguir editando
        </button>{' '}
        <button type="button" onClick={() => bloqueador.proceed()}>
          Salir sin guardar
        </button>
      </p>
    </Modal>
  );
}

export default DialogoSalida;

Uso en la pantalla de nueva reserva:

// src/paginas/PaginaNuevaReserva.jsx — añadidos
import { useBloquearSalida } from '../hooks/useBloquearSalida.js';
import DialogoSalida from '../componentes/DialogoSalida.jsx';

function PaginaNuevaReserva() {
  const { estado, despachar } = useReservas();
  // …

  // Hay cambios si el borrador difiere de vacío y aún no se ha enviado
  const hayCambios =
    estado.estadoEnvio !== 'enviado' &&
    (estado.borrador.bicicletaId !== '' || estado.borrador.fechaInicio !== '');

  const bloqueador = useBloquearSalida(hayCambios);

  return (
    <section>
      <h2>Nueva reserva</h2>
      <FormularioReserva /* … */ />
      <DialogoSalida bloqueador={bloqueador} />
    </section>
  );
}

Lo que useBlocker no cubre, y conviene tener presente: solo intercepta las navegaciones de React Router. Cerrar la pestaña, recargar con F5 o escribir otra dirección en la barra no pasan por el enrutador. Para esos casos existe un mecanismo del navegador, mucho más tosco:

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

  function manejarSalida(evento) {
    evento.preventDefault();
    // El navegador muestra SU mensaje: no se puede personalizar
    evento.returnValue = '';
  }

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

Y una advertencia de diseño: bloquear la salida es intrusivo. Úsalo solo cuando la pérdida sea real y costosa —un formulario largo, un editor—, nunca con un campo de búsqueda a medias. Y siempre con la condición bien afinada: un diálogo que aparece cuando el usuario no ha tocado nada es de las cosas que más molestan de una aplicación.

  1. Restaurar el desplazamiento con ScrollRestoration

Prueba esto en cualquier SPA sin configurar: baja hasta el final de una lista larga, entra en un detalle, vuelve atrás. Aparecerás arriba del todo, y tendrás que volver a bajar para encontrar dónde estabas.

La causa es la que ya conoces de 06-01: al navegar no hay recarga de página. La restauración automática del desplazamiento que hace el navegador con el botón atrás está atada al ciclo de carga de documentos, y en una SPA ese ciclo no ocurre. El DOM se sustituye sin que el navegador considere que ha habido navegación alguna.

React Router lo resuelve con un componente que se coloca una vez, en Diseno:

// src/componentes/Diseno.jsx — versión final del módulo
import { Outlet, ScrollRestoration } from 'react-router';
// …

function Diseno() {
  return (
    <ProveedorReservas>
      <ProveedorAvisos>
        <div className={estilos.diseno}>
          <Cabecera />
          <ListaAvisos />
          <main className={estilos.principal}>
            <MigasDePan />
            <Outlet />
          </main>
          <PieDePagina />
          <ScrollRestoration />
        </div>
      </ProveedorAvisos>
    </ProveedorReservas>
  );
}

Su comportamiento por defecto:

  • En una navegación nueva (<Link>, navigate), desplaza arriba.
  • En una navegación de vuelta (atrás/adelante), restaura la posición que tenía esa entrada del historial.

Se puede afinar con getKey, que decide bajo qué clave se guarda cada posición:

<ScrollRestoration
  getKey={(location) => {
    // El catálogo comparte posición aunque cambie el filtro:
    // volver de una ficha devuelve al mismo punto de la lista
    if (location.pathname === '/') return location.pathname;
    // El resto, una posición por entrada del historial (comportamiento normal)
    return location.key;
  }}
/>

Y para casos puntuales en los que no quieres que el desplazamiento salte arriba —cambiar de pestaña dentro de una pantalla larga, por ejemplo—, tanto <Link> como navigate aceptan preventScrollReset:

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

ScrollRestoration es, junto con useBlocker, otra API exclusiva del modo de datos.

  1. Indicadores de carga con useNavigation

En CicloUrbano las navegaciones son instantáneas porque los datos están en memoria. En cuanto una ruta cargue datos de una API o su código se descargue de forma perezosa (08-04), habrá un intervalo en el que el usuario ya ha pulsado y todavía no ve nada. Sin señal visible, volverá a pulsar.

useNavigation informa del estado global de la navegación:

import { useNavigation } from 'react-router';

const navegacion = useNavigation();
navegacion.state Significado
'idle' No hay ninguna navegación en curso
'loading' Se está navegando: cargando el código o los datos de la ruta destino
'submitting' Se está enviando un formulario a un action

Y campos auxiliares: navegacion.location (a dónde se va), navegacion.formData (lo que se envía).

Una barra de progreso discreta en Diseno:

// src/componentes/BarraDeProgreso.jsx
import { useNavigation } from 'react-router';
import estilos from './BarraDeProgreso.module.css';

function BarraDeProgreso() {
  const navegacion = useNavigation();
  const cargando = navegacion.state !== 'idle';

  if (!cargando) return null;

  return (
    <div
      className={estilos.barra}
      role="progressbar"
      aria-label="Cargando la página"
      aria-busy="true"
    />
  );
}

export default BarraDeProgreso;
/* src/componentes/BarraDeProgreso.module.css */
.barra {
  position: fixed;
  top: 0;
  left: 0;
  height: 3px;
  width: 100%;
  background: var(--color-marca);
  animation: avanzar 1.2s ease-in-out infinite;
  transform-origin: left;
}

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

/* Respeta la preferencia de movimiento reducido (03-06) */
@media (prefers-reduced-motion: reduce) {
  .barra { animation: none; opacity: 0.8; }
}

Un detalle de experiencia de usuario que vale la pena aplicar: una barra que aparece y desaparece en 80 milisegundos produce un parpadeo más molesto que la propia espera. Retrasa su aparición:

function BarraDeProgreso() {
  const navegacion = useNavigation();
  const [visible, setVisible] = useState(false);

  useEffect(() => {
    if (navegacion.state === 'idle') {
      setVisible(false);
      return;
    }
    // Solo se muestra si la espera supera los 200 ms
    const temporizador = setTimeout(() => setVisible(true), 200);
    return () => clearTimeout(temporizador);
  }, [navegacion.state]);

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

Y useNavigation también sirve para un uso local, deshabilitando el botón de envío mientras se procesa:

const navegacion = useNavigation();
const enviando = navegacion.state === 'submitting';

<button type="submit" disabled={enviando}>
  {enviando ? 'Creando reserva…' : 'Confirmar reserva'}
</button>

Errores Comunes y Consejos

Llamar a navegar durante el render. Produce «Cannot update a component while rendering another». Solo dentro de manejadores de eventos o de efectos; para el render, <Navigate />.

Navegar en un efecto sin condición. Bucle infinito garantizado. Siempre una condición que deje de cumplirse tras navegar.

Olvidar replace tras enviar un formulario. «Atrás» devuelve al formulario y el usuario reenvía. Reservas duplicadas.

Usar navigate(-1) cuando el destino se conoce. <Link to="/estaciones"> es determinista, accesible y abrible en pestaña nueva. -1 puede sacar al usuario de la aplicación si ha llegado pegando la URL.

Navegar con onClick en un <div> o en un <button> cuando debería ser un enlace. Se pierde todo lo que un <a> da gratis: pestaña nueva, copiar dirección, teclado, lectores de pantalla, buscadores.

Meter datos importantes en state. Se pierden al abrir la URL en otra pestaña y no se pueden compartir. Si el dato define lo que se ve, va en la URL.

No consumir el state tras usarlo. El aviso «Reserva creada» reaparece en cada F5, porque el state sobrevive a la recarga. Sustituye la entrada con state: null.

Poner location entero como dependencia de un efecto. Es un objeto nuevo en cada navegación. Desestructura pathname y search.

Bloquear la salida con condiciones demasiado amplias. Un diálogo que aparece sin que el usuario haya tocado nada es peor que no tenerlo.

Consejo: centraliza las rutas en constantes. RUTAS.reservas en vez de '/reservas' repartido por veinte ficheros hace que renombrar una sección sea cambiar una línea:

// src/rutas-constantes.js
export const RUTAS = {
  catalogo: '/',
  fichaBicicleta: (id) => `/bicicletas/${id}`,
  estaciones: '/estaciones',
  detalleEstacion: (id) => `/estaciones/${id}`,
  reservas: '/reservas',
  nuevaReserva: '/reservas/nueva',
  acceso: '/acceso',
  taller: '/taller'
};

Consejo: pregúntate siempre qué pasa al pulsar «atrás». Es la comprobación que descubre casi todos los fallos de navegación de una aplicación, y la que nadie hace hasta que un usuario se queja.

Ejercicios

Ejercicio 1: enviar una bicicleta al taller

TarjetaBicicleta tiene la prop alEnviarATaller, visible solo para operarios. Implementa el flujo en PaginaFichaBicicleta: al pulsar, se marca la bicicleta como mantenimiento y se lleva al operario a /taller con un aviso indicando qué bicicleta ha enviado. Decide razonadamente si usar replace y por qué.

Ejercicio 2: filtro con vuelta al catálogo

Un usuario entra en /?tipo=electrica, abre la ficha de bici-005 y pulsa el enlace «Volver al catálogo» de la ficha. Ahora mismo aterriza en / sin filtro y pierde su contexto. Corrígelo para que vuelva a /?tipo=electrica, usando lo aprendido sobre state y useLocation. Explica por qué esta solución tiene una limitación cuando la ficha se abre pegando la URL directamente, y qué alternativa hay.

Ejercicio 3: analizar cuatro navegaciones

Para cada fragmento, di si es correcto y, si no lo es, qué falla y cómo se arregla.

// A
function PaginaReservas() {
  const { estado } = useReservas();
  const navegar = useNavigate();
  if (estado.reservas.length === 0) navegar('/reservas/nueva');
  return <PanelReservas reservas={estado.reservas} />;
}

// B
function PaginaAcceso() {
  const { usuario } = useUsuario();
  if (usuario) return <Navigate to="/" replace />;
  return <FormularioAcceso />;
}

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

// D
function BotonCancelar() {
  const navegar = useNavigate();
  return <a onClick={() => navegar(-1)}>Cancelar</a>;
}

Soluciones

Solución 1

// src/paginas/PaginaFichaBicicleta.jsx — fragmento
import { useNavigate, useParams, Link } from 'react-router';
import { useUsuario } from '../contextos/ContextoUsuario.jsx';
import { bicicletas, estaciones } from '../datos/dominio.js';
import TarjetaBicicleta from '../componentes/TarjetaBicicleta.jsx';
import PaginaNoEncontrada from './PaginaNoEncontrada.jsx';

function PaginaFichaBicicleta() {
  const { bicicletaId } = useParams();
  const navegar = useNavigate();
  const { esOperario } = useUsuario();

  const bicicleta = bicicletas.find((bici) => bici.id === bicicletaId);
  if (!bicicleta) {
    return <PaginaNoEncontrada recurso="bicicleta" identificador={bicicletaId} />;
  }

  function manejarEnvioATaller() {
    // En una aplicación real esto sería una llamada a la API o un despacho al reductor
    bicicleta.estado = 'mantenimiento';   // simplificación didáctica

    navegar('/taller', {
      state: {
        avisoBicicletaEnviada: true,
        modelo: bicicleta.modelo,
        idBicicleta: bicicleta.id
      }
    });
  }

  return (
    <article>
      <TarjetaBicicleta
        bicicleta={bicicleta}
        nombreEstacion={estaciones.find((e) => e.id === bicicleta.estacionId)?.nombre}
        alEnviarATaller={esOperario ? manejarEnvioATaller : undefined}
      />
      <Link to="/">Volver al catálogo</Link>
    </article>
  );
}

Sobre replace: aquí no conviene. El razonamiento: el operario venía consultando la ficha de la bicicleta, y esa ficha sigue siendo un destino válido y útil después de la operación —de hecho, querrá comprobar que ahora aparece en mantenimiento—. La ficha no es un formulario que quede obsoleto al enviarse, así que «atrás» debe devolverlo a ella. Compara con el caso de la reserva: allí el formulario de /reservas/nueva queda obsoleto y volver a él invita a duplicar la reserva.

Solución 2

// src/componentes/TarjetaBicicleta.jsx — el enlace guarda de dónde viene
import { Link, useLocation } from 'react-router';

function TarjetaBicicleta({ bicicleta, /* … */ }) {
  const location = useLocation();

  return (
    <article>
      <h3>
        <Link
          to={`/bicicletas/${bicicleta.id}`}
          state={{ volverA: location.pathname + location.search }}
        >
          {bicicleta.modelo}
        </Link>
      </h3>
      {/* … */}
    </article>
  );
}
// src/paginas/PaginaFichaBicicleta.jsx — el enlace de vuelta lo lee
const location = useLocation();
const destinoVuelta = location.state?.volverA ?? '/';

return (
  <article>
    {/* … */}
    <Link to={destinoVuelta}>Volver al catálogo</Link>
  </article>
);

Nota que <Link> acepta state igual que navigate, y que se guarda pathname + search para no perder el filtro.

La limitación: si el usuario abre /bicicletas/bici-005 pegando la URL o desde un enlace compartido, no hay state —no ha pasado por el catálogo— y el ?? '/' lo devuelve al catálogo sin filtrar. Es correcto y no rompe nada, pero conviene entender por qué ocurre: el state viaja con la entrada del historial, no con la dirección.

La alternativa es poner el origen en la URL: /bicicletas/bici-005?volverA=%2F%3Ftipo%3Delectrica. Ventaja: sobrevive a compartir el enlace y a abrirlo en otra pestaña. Inconvenientes: ensucia una URL que debería ser limpia y compartible, y hay que sanear el valor —un volverA apuntando a otro dominio sería un vector de redirección abierta—. Para un dato accesorio de navegación como este, state es la elección correcta; para algo que deba ser compartible, la URL.

Solución 3

A — Incorrecto. Llama a navegar durante el render: aviso de React y comportamiento impredecible. Además es un mal diseño: llevar a la fuerza a /reservas/nueva a quien entra en «Mis reservas» le impide ver que no tiene ninguna. Lo correcto es mostrar un estado vacío con un enlace:

if (estado.reservas.length === 0) {
  return (
    <p>
      Todavía no tienes reservas. <Link to="/reservas/nueva">Crear una</Link>
    </p>
  );
}

B — Correcto. <Navigate /> durante el render es legal, la condición usuario deja de cumplirse tras navegar (no hay bucle) y replace es lo apropiado: un usuario ya identificado no debe volver al formulario de acceso pulsando «atrás». Este es exactamente el patrón que usarás en 06-05.

C — Incorrecto por dos motivos. Primero, al useEffect le falta el array de dependencias, así que se ejecuta después de cada render; con la condición presente no llega a haber bucle infinito, pero es un descuido de 05-02 que en cuanto la condición se relaje sí lo provocará. Debe ser }, [bicicletaId, navegar]);. Segundo, y más importante, es la estrategia equivocada: redirigir al catálogo cuando el identificador no existe borra la URL problemática y deja al usuario sin saber qué ha pasado. Mejor la solución de 06-02: comprobar en el render y devolver <PaginaNoEncontrada recurso="bicicleta" … />, conservando la URL para que se pueda corregir o reportar.

D — Incorrecto. Un <a> sin href no es un enlace para el navegador: no es alcanzable con el tabulador, no responde a la tecla Intro y un lector de pantalla no lo anuncia. Y semánticamente tampoco es un enlace, porque no lleva a un destino conocido: es una acción. Lo correcto es un botón:

<button type="button" onClick={() => navegar(-1)}>Cancelar</button>

El type="button" es obligatorio por la convención del proyecto y por una razón concreta: dentro de un formulario, un <button> sin type es de envío y provocaría un envío accidental.

Conclusión

La navegación programática cubre todo lo que no es un clic en un enlace, y el criterio para elegir entre una y otra es sencillo: si el destino se conoce al renderizar y quien decide es el usuario, es un <Link>; si decide la aplicación, es código. useNavigate devuelve una función que acepta un destino y unas opciones, o un número para moverse por el historial, y de esas opciones la más importante es replace: sustituir la entrada actual en lugar de apilar otra es lo correcto después de enviar un formulario, después de identificarse y en cualquier redirección, porque evita que «atrás» devuelva a una pantalla obsoleta y que el usuario duplique una reserva sin querer. La opción state transporta datos que no aparecen en la URL —el «cómo he llegado», nunca el «qué estoy viendo»—, sobrevive a la recarga pero no a abrir el enlace en otra pestaña, y conviene consumirlo tras usarlo. useLocation te da la URL descompuesta en pathname, search, hash, state y key, con el detalle de que key cambia en cada navegación y sirve para forzar el reinicio de un subárbol.

Has implementado el flujo central de CicloUrbano: al confirmar el FormularioReserva en /reservas/nueva se valida con validarReserva, se construye la reserva con sus datos no deterministas fuera del reductor, se despacha reserva_creada a reductorReservas y se navega a /reservas con replace y un state que dispara el AvisoReservaCreada una sola vez. Para las redirecciones has visto que <Navigate to replace /> es la opción por defecto —legal durante el render, legible en el árbol— y que useNavigate dentro de un efecto queda para cuando la decisión depende de algo asíncrono, siempre con una condición que deje de cumplirse tras navegar, porque sin ella el bucle es inmediato. Y has añadido tres piezas exclusivas del modo de datos que elevan bastante la calidad de la aplicación: useBlocker con sus estados blocked y proceeding para impedir que se pierda un formulario a medias —sabiendo que no cubre cerrar la pestaña, para lo que hace falta beforeunload—, ScrollRestoration para devolver al usuario al punto de la lista donde estaba, algo que en una SPA no ocurre solo porque nunca hay recarga de documento, y useNavigation para mostrar un indicador de carga cuando la navegación tarde, con el retardo que evita el parpadeo.

Queda una pieza del módulo. /taller está en el mapa desde 06-02 y hoy la puede abrir cualquiera: basta con escribir la dirección. CicloUrbano tiene dos perfiles —usr-01 Ana Ribera, cliente, y usr-02 Marc Solé, operario— y el panel de taller es solo para el segundo. Hay que construir el inicio de sesión en /acceso, un guardián que redirija a quien no tenga sesión y sepa devolverlo al destino original, autorización por rol con una pantalla de «sin permisos» distinta de la de «no encontrado», y —lo más importante de toda la lección— entender por qué nada de esto es seguridad de verdad. La próxima lección es Rutas Protegidas y Control de Acceso.

Curso de React

Módulo 1: Introducción a React

Módulo 2: Componentes de React

Módulo 3: Trabajando con Eventos

Módulo 4: Conceptos Avanzados de Componentes

Módulo 5: Hooks de React

Módulo 6: Enrutamiento en React

Módulo 7: Gestión del Estado

Módulo 8: Optimización del Rendimiento

Módulo 9: Pruebas en React

Módulo 10: Temas Avanzados

Módulo 11: Proyecto: Construyendo una Aplicación Completa

© Copyright 2026. Todos los derechos reservados