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
- Cuándo navegar desde el código y cuándo no
useNavigate: la firma completareplace: cuándo no dejar rastro en el historial- Navegar hacia atrás y hacia adelante con números
- Pasar estado en la navegación
useLocational completo- Caso central: confirmar una reserva
- Redirecciones declarativas con
<Navigate /> <Navigate />frente auseNavigateen un efecto- Bloquear la salida con
useBlocker - Restaurar el desplazamiento con
ScrollRestoration - Indicadores de carga con
useNavigation
- 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.
useNavigate: la firma completa
useNavigate: la firma completaimport { 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 rutaUna 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.
replace: cuándo no dejar rastro en el historial
replace: cuándo no dejar rastro en el historialPor 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-003en/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.
- 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.
- 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:
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.
useLocation al completo
useLocation al completouseLocation() 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.
- 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:
despacharantes denavegar. El orden importa poco en la práctica, porque React agrupa las actualizaciones (05-01) yProveedorReservasvive enDiseno, 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 esenavegar(pathname, { state: null }), un F5 en/reservasvolvería a mostrar «Reserva creada», porque elstatesobrevive a la recarga. Se sustituye la entrada por una idéntica sinstate, y el aviso no vuelve. replace: trueen ambas navegaciones. En la primera, para sacar el formulario del historial. En la segunda, para no añadir una entrada duplicada de/reservascada vez que se limpia el aviso.
- Redirecciones declarativas con
<Navigate />
<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.
<Navigate /> frente a useNavigate en un efecto
<Navigate /> frente a useNavigate en un efectoLos 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.
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:
- Siempre una condición que deje de cumplirse tras navegar:
if (!usuario) navegar('/acceso'). - Dependencias completas y estables.
navegares estable entre renders, así que puede ir en el array sin peligro. - Comprueba dónde estás. Si el destino puede coincidir con la ruta actual,
if (location.pathname !== destino)antes de navegar.
- Bloquear la salida con
useBlocker
useBlockerSituació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.
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.
- Restaurar el desplazamiento con
ScrollRestoration
ScrollRestorationPrueba 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.
- Indicadores de carga con
useNavigation
useNavigationEn 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:
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 sí 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:
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
- ¿Qué es React?
- Configuración del Entorno de Desarrollo
- Hola Mundo en React
- JSX: Extensión de Sintaxis de JavaScript
- Cómo Renderiza React: Virtual DOM y Reconciliación
Módulo 2: Componentes de React
- Entendiendo los Componentes
- Componentes Funcionales vs de Clase
- Props: Pasando Datos a Componentes
- State: Gestión del Estado del Componente
- Estilos en los Componentes: CSS, Módulos y Utilidades
Módulo 3: Trabajando con Eventos
- Manejo de Eventos en React
- Renderizado Condicional
- Listas y Claves
- Formularios y Componentes Controlados
- Validación de Formularios y Componentes No Controlados
- Accesibilidad en Componentes Interactivos
Módulo 4: Conceptos Avanzados de Componentes
- Elevando el Estado
- Composición vs Herencia
- Métodos del Ciclo de Vida de React
- Hooks: Introducción y Uso Básico
- Límites de Error: Capturar Fallos en la Interfaz
Módulo 5: Hooks de React
- Hook useState
- Hook useEffect
- Hook useRef y Acceso al DOM
- Hook useContext
- Hook useReducer
- Hooks Personalizados
Módulo 6: Enrutamiento en React
- Introducción a React Router
- Configuración de React Router
- Rutas Anidadas
- Navegación Programática
- Rutas Protegidas y Control de Acceso
Módulo 7: Gestión del Estado
- Introducción a la Gestión del Estado
- API de Contexto
- Redux: Introducción y Configuración
- Redux: Acciones y Reductores
- Redux: Conectando a React
- Estado del Servidor: Peticiones, Caché y Sincronización
Módulo 8: Optimización del Rendimiento
- Técnicas de Optimización del Rendimiento en React
- Memorización con React.memo
- Hooks useMemo y useCallback
- División de Código y Carga Perezosa
- Medir el Rendimiento con React DevTools Profiler
Módulo 9: Pruebas en React
- Introducción a las Pruebas
- Pruebas Unitarias con Jest
- Pruebas de Componentes con React Testing Library
- Pruebas de Código Asíncrono y Simulación de APIs
- Pruebas de Extremo a Extremo con Cypress
Módulo 10: Temas Avanzados
- Renderizado del Lado del Servidor (SSR) con Next.js
- Generación de Sitios Estáticos (SSG) con Next.js
- Suspense y React Server Components
- TypeScript con React
- React Native: Creación de Aplicaciones Móviles
