La ruta /taller lleva en el mapa de CicloUrbano desde 06-02 y hoy la puede abrir cualquiera: basta con escribir la dirección en la barra del navegador. Debería verla solo Marc Solé, el operario usr-02, no Ana Ribera ni un visitante sin sesión. En esta lección construirás ese control de acceso: un inicio de sesión ficticio en /acceso, un componente guardián que redirija a quien no tenga sesión y sepa devolverlo después al destino original, una ruta de diseño protegida que agrupe varias pantallas bajo un mismo guardián, autorización por rol con una pantalla de «sin permisos» distinta de la de «no encontrado», el manejo del estado intermedio mientras se comprueba la sesión, y la persistencia con useAlmacenLocal. Pero antes que nada, la advertencia que gobierna toda la lección y que hay que tener presente en cada línea de código que escribas.

⚠️ Advertencia imprescindible: esto no es seguridad

Todo lo que hagas en el cliente es únicamente experiencia de usuario. La autorización real se comprueba SIEMPRE en el servidor.

No es una recomendación ni una buena práctica: es un hecho sobre cómo funciona la web. El código de tu aplicación se descarga entero en el navegador del usuario, y ahí él es dueño absoluto. Puede:

  • Abrir las herramientas de desarrollo y cambiar cualquier variable en memoria, incluida esOperario.
  • Poner un punto de interrupción en tu guardián y saltárselo.
  • Modificar localStorage a mano para ponerse el rol que quiera.
  • Leer todo el JavaScript descargado, incluidas las pantallas «protegidas», sin necesidad de acceder a ellas.
  • Llamar directamente a tu API con curl, sin pasar por la interfaz en absoluto.

Lo que un guardián de rutas consigue de verdad:

Lo que SÍ hace Lo que NO hace
Evitar que un usuario legítimo vea una pantalla que no le sirve Impedir que alguien decidido la vea
Redirigir a /acceso a quien no ha entrado Proteger los datos que esa pantalla muestra
Mostrar una interfaz coherente con el rol Sustituir la comprobación del servidor
Evitar errores por datos que no llegan Ocultar el código de la pantalla

La consecuencia práctica: cada petición que la pantalla protegida haga a la API tiene que llevar sus credenciales, y el servidor debe comprobar en cada una si ese usuario tiene permiso para esa operación. Si el servidor devuelve la lista de incidencias a cualquiera que la pida, tu RutaProtegida es un adorno. Volveremos sobre esto al cerrar la lección, porque es lo único de aquí que no se puede matizar.

Contenido

  1. El modelo de sesión de CicloUrbano
  2. La pantalla de acceso
  3. Patrón 1: componente guardián RutaProtegida
  4. Volver al destino original tras identificarse
  5. Patrón 2: ruta de diseño protegida
  6. Autorización por rol: RequiereRol
  7. 403 y 404: por qué son pantallas distintas
  8. El estado intermedio: cargandoSesion
  9. Persistir la sesión con useAlmacenLocal
  10. Tokens, cookies y el límite del almacenamiento del navegador
  11. Ocultar en la interfaz lo que no se puede usar
  12. El mapa de rutas definitivo

  1. El modelo de sesión de CicloUrbano

No hace falta inventar nada: ContextoUsuario existe desde 05-04 con la forma exacta que necesitamos.

// src/contextos/ContextoUsuario.jsx — lo que ya tienes
export function ProveedorUsuario({ children, usuarioInicial = null }) {
  const [usuario, setUsuario] = useState(usuarioInicial);

  function iniciarSesion(id) {
    const encontrado = usuarios.find((u) => u.id === id);
    setUsuario(encontrado ?? null);
  }

  function cerrarSesion() {
    setUsuario(null);
  }

  const valor = {
    usuario,
    esOperario: usuario?.rol === 'operario',
    iniciarSesion,
    cerrarSesion
  };

  return <ContextoUsuario value={valor}>{children}</ContextoUsuario>;
}

export function useUsuario() {
  const contexto = useContext(ContextoUsuario);
  if (contexto === null) {
    throw new Error('useUsuario debe usarse dentro de <ProveedorUsuario>');
  }
  return contexto;
}

Los dos perfiles ficticios del proyecto:

Usuario Nombre Correo Rol Qué puede ver
usr-01 Ana Ribera [email protected] cliente Catálogo, estaciones, sus reservas
usr-02 Marc Solé [email protected] operario Todo lo anterior más /taller

Y tres estados posibles de la sesión, que conviene distinguir bien porque el tercero es el que más problemas da:

stateDiagram-v2
    [*] --> Comprobando: arranca la aplicación
    Comprobando --> SinSesion: no hay sesión guardada
    Comprobando --> ConSesion: sesión recuperada
    SinSesion --> ConSesion: iniciarSesion()
    ConSesion --> SinSesion: cerrarSesion()
    note right of Comprobando
        Estado intermedio.
        Ni redirigir ni mostrar
        contenido protegido:
        indicador de carga.
    end note

Un cambio necesario respecto a 05-04: entonces usuarioInicial era usuarios[0], porque siempre había alguien identificado. Ahora el valor inicial es null, porque «nadie ha entrado» tiene que ser un estado representable. En cuanto existe una pantalla de acceso, arrancar con sesión sería una contradicción.

  1. La pantalla de acceso

Un formulario de acceso ficticio, sin contraseñas, que solo elige entre los dos perfiles. En una aplicación real aquí habría credenciales y una llamada a la API; para aprender enrutamiento eso solo añadiría ruido.

// src/paginas/PaginaAcceso.jsx
import { useState } from 'react';
import { useNavigate, useLocation, Navigate } from 'react-router';
import { useUsuario } from '../contextos/ContextoUsuario.jsx';
import { usuarios } from '../datos/dominio.js';
import Aviso from '../componentes/Aviso.jsx';
import estilos from './PaginaAcceso.module.css';

function PaginaAcceso() {
  const { usuario, iniciarSesion } = useUsuario();
  const navegar = useNavigate();
  const location = useLocation();
  const [idElegido, setIdElegido] = useState('usr-01');

  // A dónde volver: lo dejó el guardián al redirigir aquí (apartado 4)
  const destino = location.state?.desde?.pathname ?? '/';

  // Quien ya tiene sesión no debe ver el formulario
  if (usuario) {
    return <Navigate to={destino} replace />;
  }

  function manejarEnvio(evento) {
    evento.preventDefault();
    iniciarSesion(idElegido);
    // replace: «atrás» no debe devolver al formulario de acceso
    navegar(destino, { replace: true });
  }

  return (
    <section className={estilos.acceso}>
      <h2>Acceso a CicloUrbano</h2>

      {location.state?.desde && (
        <Aviso tono="informacion" titulo="Necesitas identificarte">
          <p>
            La pantalla <code>{location.state.desde.pathname}</code> requiere una
            sesión iniciada. Te llevaremos allí en cuanto entres.
          </p>
        </Aviso>
      )}

      <form onSubmit={manejarEnvio}>
        <fieldset>
          <legend>Elige un perfil de demostración</legend>

          {usuarios.map((candidato) => (
            <p key={candidato.id}>
              <label>
                <input
                  type="radio"
                  name="perfil"
                  value={candidato.id}
                  checked={idElegido === candidato.id}
                  onChange={(evento) => setIdElegido(evento.target.value)}
                />{' '}
                {candidato.nombre} — <span>{candidato.rol}</span>
                <br />
                <small>{candidato.email}</small>
              </label>
            </p>
          ))}
        </fieldset>

        <button type="submit">Entrar</button>
      </form>

      <p className={estilos.nota}>
        Datos ficticios de demostración. Ninguna de estas cuentas existe ni
        requiere contraseña.
      </p>
    </section>
  );
}

export default PaginaAcceso;

Cuatro detalles a destacar:

  • El formulario es controlado (03-04): checked sale del estado y onChange lo actualiza. Los botones de radio comparten name para que sean excluyentes.
  • <fieldset> y <legend> agrupan el conjunto de opciones y son lo que un lector de pantalla anuncia antes de leerlas, tal como se vio en 03-06.
  • Quien ya tiene sesión no ve el formulario: <Navigate to={destino} replace /> durante el render, el patrón de 06-04.
  • replace en el envío, por la misma razón: tras entrar, «atrás» no debe devolver al acceso.

  1. Patrón 1: componente guardián RutaProtegida

El primer patrón envuelve directamente el contenido que hay que proteger.

// src/componentes/RutaProtegida.jsx
import { Navigate, useLocation } from 'react-router';
import { useUsuario } from '../contextos/ContextoUsuario.jsx';

/**
 * Guardián de sesión. Renderiza a sus hijos solo si hay usuario identificado;
 * si no, redirige a /acceso recordando el destino original.
 *
 * ⚠️ Solo experiencia de usuario: la autorización real es del servidor.
 *
 * Props:
 *  - children (contenido, obligatorio): lo que se protege
 */
function RutaProtegida({ children }) {
  const { usuario } = useUsuario();
  const location = useLocation();

  if (!usuario) {
    return <Navigate to="/acceso" replace state={{ desde: location }} />;
  }

  return children;
}

export default RutaProtegida;

Uso en el mapa:

{
  path: 'taller',
  element: (
    <RutaProtegida>
      <PaginaTaller />
    </RutaProtegida>
  )
}

Las tres decisiones de este componente, una por una:

<Navigate /> y no useNavigate en un efecto. La decisión se puede tomar mirando el estado durante el render, así que aplica la regla de 06-04. Con un efecto habría un instante en el que la pantalla protegida se renderiza antes de redirigir: un parpadeo visible del contenido que se quería ocultar, además del riesgo de bucle.

replace y no una entrada nueva. Sin él, el historial quedaría … → /taller → /acceso, y al pulsar «atrás» el usuario volvería a /taller, que redirigiría de nuevo a /acceso, que al volver atrás… Un rebote del que no se sale. Con replace, la entrada de /taller se sustituye y «atrás» lleva a donde estaba antes.

state={{ desde: location }} guarda el destino original. Es el apartado siguiente y la diferencia entre un control de acceso aceptable y uno irritante.

  1. Volver al destino original tras identificarse

Sin esta pieza, la experiencia es la siguiente: un operario recibe por chat el enlace /taller, lo abre, ve el formulario de acceso, entra… y aterriza en el catálogo. Tiene que volver al chat y pulsar el enlace otra vez. Con el state, entra y aparece directamente en el taller.

sequenceDiagram
    participant U as Usuario
    participant T as /taller
    participant G as RutaProtegida
    participant A as /acceso
    U->>T: Abre el enlace compartido
    T->>G: Se renderiza el guardián
    G->>G: usuario === null
    G->>A: Navigate replace<br/>state: { desde: { pathname: '/taller' } }
    A-->>U: Formulario + «Necesitas identificarte»
    U->>A: Elige usr-02 y envía
    A->>A: iniciarSesion('usr-02')
    A->>T: navegar('/taller', { replace: true })
    T-->>U: Panel del operario ✅

Se guarda el objeto location entero, no solo el pathname, para conservar también la consulta:

// Guardado por el guardián
state={{ desde: location }}

// Leído en PaginaAcceso, reconstruyendo la URL completa
const desde = location.state?.desde;
const destino = desde ? `${desde.pathname}${desde.search}` : '/';

Así, quien intentaba abrir /taller?filtro=urgentes vuelve exactamente ahí.

Una precaución de seguridad que conviene conocer desde ahora. Si el destino viniera de un parámetro de consulta en lugar de del state —algo habitual en aplicaciones que integran un sistema de acceso externo— tendrías una redirección abierta: alguien podría enviar \/acceso?volverA=https://sitio-falso.test, y tu aplicación llevaría al usuario a un sitio ajeno justo después de identificarse, con toda la apariencia de legitimidad. La defensa es aceptar solo rutas internas:

function destinoSeguro(candidato) {
  // Debe empezar por una sola barra: ni '//otro.test' ni 'https://…'
  if (typeof candidato !== 'string') return '/';
  if (!candidato.startsWith('/') || candidato.startsWith('//')) return '/';
  return candidato;
}

Con el state de React Router el riesgo es mucho menor, porque lo escribe tu propio guardián y no viaja en la URL, pero la comprobación cuesta cuatro líneas y el patrón conviene tenerlo interiorizado.

  1. Patrón 2: ruta de diseño protegida

El patrón 1 funciona bien con una pantalla. Con cuatro, el mapa se llena de envoltorios repetidos:

// ❌ Repetitivo y fácil de olvidar en la quinta pantalla
{ path: 'taller', element: <RutaProtegida><PaginaTaller /></RutaProtegida> },
{ path: 'informes', element: <RutaProtegida><PaginaInformes /></RutaProtegida> },
{ path: 'flota', element: <RutaProtegida><PaginaFlota /></RutaProtegida> }

Aquí entra la ruta sin path de 06-03: una ruta cuyo elemento es el guardián y que agrupa varias hijas, sin añadir ningún segmento a la URL.

// src/componentes/RutaProtegida.jsx — versión para ruta de diseño
import { Navigate, useLocation, Outlet } from 'react-router';
import { useUsuario } from '../contextos/ContextoUsuario.jsx';

function RutaProtegida() {
  const { usuario } = useUsuario();
  const location = useLocation();

  if (!usuario) {
    return <Navigate to="/acceso" replace state={{ desde: location }} />;
  }

  return <Outlet />;   // ← en vez de children
}

export default RutaProtegida;
// src/rutas.jsx — la rama protegida
{
  element: <RutaProtegida />,      // ← sin path: no consume segmento
  children: [
    { path: 'taller', element: <PaginaTaller /> },
    { path: 'informes', element: <PaginaInformes /> }
  ]
}

Las URLs siguen siendo /taller e /informes. Comparación de los dos patrones:

Patrón 1: envolver hijos Patrón 2: ruta de diseño
Cómo se declara element: <RutaProtegida><X /></RutaProtegida> Ruta sin path con children
Qué renderiza el guardián children <Outlet />
Con una pantalla protegida Simple y directo Añade un nivel al mapa
Con varias Se repite en cada una Se declara una vez
Riesgo de olvidar proteger una nueva Alto Bajo: se añade dentro de la rama
Interfaz común para el grupo Hay que repetirla El guardián puede pintar barra lateral, migas…
Recomendación Casos sueltos Preferible cuando hay varias pantallas

Ese penúltimo punto es un extra apreciable: como el guardián es un componente de ruta normal, puede pintar el marco compartido del área privada además de comprobar la sesión.

function RutaProtegida() {
  const { usuario } = useUsuario();
  const location = useLocation();

  if (!usuario) {
    return <Navigate to="/acceso" replace state={{ desde: location }} />;
  }

  return (
    <div className={estilos.areaPrivada}>
      <p className={estilos.identificado}>
        Sesión iniciada como <strong>{usuario.nombre}</strong> ({usuario.rol})
      </p>
      <Outlet />
    </div>
  );
}

Y el árbol de rutas resultante, con la rama protegida señalada:

flowchart TD
    RAIZ["/ · Diseno"]
    RAIZ --> IDX["index · PaginaCatalogo"]
    RAIZ --> BICI["bicicletas/:bicicletaId"]
    RAIZ --> EST["estaciones"]
    EST --> ESTI["index · PaginaEstaciones"]
    EST --> DET["' :estacionId ' · PaginaDetalleEstacion"]
    DET --> FLO["index · PestanaFlota"]
    DET --> INC["incidencias · PestanaIncidencias"]
    RAIZ --> RES["reservas"]
    RES --> RESI["index · PaginaReservas"]
    RES --> NUE["nueva · PaginaNuevaReserva"]
    RAIZ --> ACC["acceso · PaginaAcceso"]
    RAIZ --> PROT["🔒 (sin path) RutaProtegida"]
    PROT --> ROL["🔒 (sin path) RequiereRol operario"]
    ROL --> TAL["taller · PaginaTaller"]
    RAIZ --> NF["* · PaginaNoEncontrada"]
    style PROT fill:#fde68a
    style ROL fill:#fed7aa
    style TAL fill:#fecaca

  1. Autorización por rol: RequiereRol

Tener sesión y tener permiso son cosas distintas. Ana Ribera está identificada y aun así no debe entrar en /taller. Conviene separar los dos conceptos en dos componentes:

Concepto Pregunta Componente Si falla
Autenticación ¿Quién eres? RutaProtegida Redirige a /acceso
Autorización ¿Puedes hacer esto? RequiereRol Muestra «sin permisos» (403)
// src/componentes/RequiereRol.jsx
import { Outlet, Navigate, useLocation } from 'react-router';
import { useUsuario } from '../contextos/ContextoUsuario.jsx';
import PaginaSinPermisos from '../paginas/PaginaSinPermisos.jsx';

/**
 * Guardián de autorización por rol.
 *
 * ⚠️ Solo experiencia de usuario: la autorización real es del servidor.
 *
 * Props:
 *  - rolesPermitidos (array de cadenas, obligatorio): p. ej. ['operario']
 *  - children (contenido, opcional): si falta, se usa <Outlet />
 */
function RequiereRol({ rolesPermitidos, children }) {
  const { usuario } = useUsuario();
  const location = useLocation();

  // Sin sesión: es un problema de autenticación, no de permisos
  if (!usuario) {
    return <Navigate to="/acceso" replace state={{ desde: location }} />;
  }

  // Con sesión pero sin el rol adecuado: 403, y la URL se conserva
  if (!rolesPermitidos.includes(usuario.rol)) {
    return <PaginaSinPermisos rolesPermitidos={rolesPermitidos} />;
  }

  return children ?? <Outlet />;
}

export default RequiereRol;

El children ?? <Outlet /> del final permite usar el mismo componente en los dos patrones: envolviendo hijos o como ruta de diseño. Es una pequeña comodidad que evita mantener dos componentes casi idénticos.

En el mapa, los dos guardianes se anidan y cada uno hace su trabajo:

{
  element: <RutaProtegida />,                          // ¿hay sesión?
  children: [
    {
      element: <RequiereRol rolesPermitidos={['operario']} />,   // ¿es operario?
      children: [
        { path: 'taller', element: <PaginaTaller />, handle: { miga: 'Taller' } }
      ]
    }
  ]
}

¿Por qué dos niveles y no uno solo? Porque separan responsabilidades y porque son reutilizables por separado: mañana /reservas puede necesitar sesión pero cualquier rol, y /informes puede admitir operario y supervisor. Cada guardián hace una cosa y se combinan según haga falta. Estrictamente, RequiereRol ya cubre el caso sin sesión, así que podrías usarlo solo; el anidamiento expresa mejor la intención y hace evidente en el mapa que hay dos comprobaciones distintas.

  1. 403 y 404: por qué son pantallas distintas

Es tentador reutilizar PaginaNoEncontrada cuando alguien no tiene permisos, y es un error. Los dos casos comunican cosas diferentes al usuario:

404 «No encontrado» 403 «Sin permisos»
Qué significa Esta dirección no existe Existe, pero tú no puedes verla
Culpa de Un error tipográfico o un enlace roto El perfil con el que has entrado
Qué puede hacer el usuario Corregir la URL, ir al inicio Entrar con otra cuenta, o pedir permiso
Acción sugerida «Volver al catálogo» «Cambiar de cuenta» / «Solicitar acceso»
Confusión si se mezclan El usuario cree que la pantalla no existe y deja de intentarlo
// src/paginas/PaginaSinPermisos.jsx
import { Link, useLocation } from 'react-router';
import { useUsuario } from '../contextos/ContextoUsuario.jsx';
import Aviso from '../componentes/Aviso.jsx';

/**
 * Pantalla 403 de CicloUrbano.
 * Props:
 *  - rolesPermitidos (array de cadenas, opcional): para explicar qué hace falta
 */
function PaginaSinPermisos({ rolesPermitidos = [] }) {
  const { usuario, cerrarSesion } = useUsuario();
  const location = useLocation();

  return (
    <section>
      <Aviso tono="advertencia" titulo="No tienes permisos para ver esta pantalla">
        <p>
          Has entrado como <strong>{usuario?.nombre}</strong> con el rol{' '}
          <strong>{usuario?.rol}</strong>. La pantalla{' '}
          <code>{location.pathname}</code> es solo para{' '}
          {rolesPermitidos.join(' o ') || 'otros perfiles'}.
        </p>
        <p>
          Si crees que deberías tener acceso, habla con la persona responsable de
          la flota de CicloUrbano.
        </p>
      </Aviso>

      <p>
        <Link to="/">Volver al catálogo</Link> ·{' '}
        <button type="button" onClick={cerrarSesion}>
          Entrar con otra cuenta
        </button>
      </p>
    </section>
  );
}

export default PaginaSinPermisos;

Se renderiza en su sitio, no se redirige. Conservar la URL /taller tiene dos ventajas: el usuario ve qué pantalla ha intentado abrir y puede corregir el problema (cambiar de cuenta) sin volver a buscar el enlace; y cuando reporte la incidencia, la dirección es la mitad del informe.

Un matiz que se ve en aplicaciones con requisitos de confidencialidad estrictos: a veces se devuelve 404 en lugar de 403 a propósito, para no revelar que la pantalla existe. Es una decisión legítima, pero es una decisión de producto y de seguridad que hay que tomar conscientemente y con el equipo, no un descuido.

  1. El estado intermedio: cargandoSesion

Aquí está el fallo más visible de una protección mal hecha, y aparece en cuanto la sesión se recupera de algún sitio: durante el primerísimo render, usuario todavía es null porque aún no se ha leído nada. El guardián lo interpreta como «no hay sesión» y redirige a /acceso a alguien que sí la tenía. El usuario ve un destello del formulario de acceso y luego, si hay suerte, un salto de vuelta.

La solución es representar el tercer estado del diagrama del apartado 1: «todavía no lo sé».

// src/contextos/ContextoUsuario.jsx — versión con estado de carga
import { createContext, useContext, useState, useEffect } from 'react';
import { usuarios } from '../datos/dominio.js';

const ContextoUsuario = createContext(null);

const CLAVE_SESION = 'ciclourbano:sesion';

export function ProveedorUsuario({ children }) {
  const [usuario, setUsuario] = useState(null);
  const [cargandoSesion, setCargandoSesion] = useState(true);   // ← tercer estado

  useEffect(() => {
    // Recuperación de la sesión al arrancar. En una aplicación real,
    // aquí se validaría el token contra el servidor: sería asíncrono.
    try {
      const guardado = localStorage.getItem(CLAVE_SESION);
      if (guardado) {
        const id = JSON.parse(guardado);
        setUsuario(usuarios.find((u) => u.id === id) ?? null);
      }
    } catch {
      // Almacenamiento bloqueado o datos corruptos: se arranca sin sesión
    } finally {
      setCargandoSesion(false);   // pase lo que pase, la comprobación terminó
    }
  }, []);

  function iniciarSesion(id) {
    const encontrado = usuarios.find((u) => u.id === id) ?? null;
    setUsuario(encontrado);
    try {
      if (encontrado) localStorage.setItem(CLAVE_SESION, JSON.stringify(encontrado.id));
    } catch { /* sin espacio o sin permisos: la sesión vive solo en memoria */ }
  }

  function cerrarSesion() {
    setUsuario(null);
    try {
      localStorage.removeItem(CLAVE_SESION);
    } catch { /* ignorado a propósito */ }
  }

  const valor = {
    usuario,
    esOperario: usuario?.rol === 'operario',
    cargandoSesion,
    iniciarSesion,
    cerrarSesion
  };

  return <ContextoUsuario value={valor}>{children}</ContextoUsuario>;
}

export function useUsuario() {
  const contexto = useContext(ContextoUsuario);
  if (contexto === null) {
    throw new Error('useUsuario debe usarse dentro de <ProveedorUsuario>');
  }
  return contexto;
}

Y los guardianes lo respetan antes de decidir nada:

function RutaProtegida() {
  const { usuario, cargandoSesion } = useUsuario();
  const location = useLocation();

  // 1. Todavía no se sabe: ni redirigir ni mostrar contenido protegido
  if (cargandoSesion) {
    return <IndicadorDeCarga mensaje="Comprobando tu sesión…" />;
  }

  // 2. Ya se sabe y no hay sesión
  if (!usuario) {
    return <Navigate to="/acceso" replace state={{ desde: location }} />;
  }

  // 3. Ya se sabe y hay sesión
  return <Outlet />;
}
// src/componentes/IndicadorDeCarga.jsx
import estilos from './IndicadorDeCarga.module.css';

/**
 * Indicador de espera accesible.
 * Props:
 *  - mensaje (cadena, opcional, por defecto 'Cargando…')
 */
function IndicadorDeCarga({ mensaje = 'Cargando…' }) {
  return (
    <p className={estilos.indicador} role="status" aria-live="polite">
      <span className={estilos.rueda} aria-hidden="true" />
      {mensaje}
    </p>
  );
}

export default IndicadorDeCarga;

El role="status" con aria-live="polite" hace que un lector de pantalla anuncie el mensaje sin interrumpir lo que esté leyendo, como se estableció en 03-06. La rueda animada lleva aria-hidden porque es puramente decorativa.

Tres reglas del estado intermedio:

  1. Mientras cargandoSesion sea true, no se decide nada. Ni redirigir ni mostrar contenido protegido.
  2. PaginaAcceso también debe respetarlo. Si no, parpadea el formulario justo antes de que la sesión se recupere y redirija.
  3. Cuidado con la espera artificial. Si la comprobación es instantánea, un indicador que aparece y desaparece en 30 milisegundos molesta más que ayuda: aplica el retardo de 200 ms del BarraDeProgreso de 06-04.

  1. Persistir la sesión con useAlmacenLocal

El apartado anterior escribió el acceso a localStorage a mano, para que se viera la mecánica junto con el finally. Pero el proyecto ya tiene el hook de 05-06, y usarlo simplifica bastante:

// src/contextos/ContextoUsuario.jsx — con useAlmacenLocal
import { useAlmacenLocal } from '../hooks/useAlmacenLocal.js';

export function ProveedorUsuario({ children }) {
  // El hook lee de forma perezosa en el primer render: no hay estado intermedio
  const [idGuardado, setIdGuardado] = useAlmacenLocal('ciclourbano:sesion', null);

  const usuario = idGuardado ? usuarios.find((u) => u.id === idGuardado) ?? null : null;

  const valor = {
    usuario,
    esOperario: usuario?.rol === 'operario',
    cargandoSesion: false,        // lectura síncrona: nunca hay incertidumbre
    iniciarSesion: (id) => setIdGuardado(id),
    cerrarSesion: () => setIdGuardado(null)
  };

  return <ContextoUsuario value={valor}>{children}</ContextoUsuario>;
}

Fíjate en que usuario es un valor derivado del identificador guardado, no un segundo estado: exactamente la distinción de 02-04 y 05-01. Guardar el objeto entero además del identificador crearía dos fuentes de verdad que se desincronizarían.

Y en que cargandoSesion es false porque useAlmacenLocal lee de forma síncrona en el inicializador perezoso de useState. Ese es el matiz que decide si necesitas el estado intermedio:

Origen de la sesión ¿Es síncrono? ¿Hace falta cargandoSesion?
localStorage con useAlmacenLocal No
Cookie leída con JavaScript No
Validación del token contra el servidor No
Biblioteca de identidad externa No
Renovación silenciosa de token No

En cuanto la sesión de CicloUrbano se valide contra una API —lo normal en producción—, el estado intermedio vuelve a ser imprescindible. Por eso merecía la pena verlo.

  1. Tokens, cookies y el límite del almacenamiento del navegador

Aquí toca ser explícito sobre lo que se está guardando y lo que nunca debe guardarse.

Lo que hemos guardado: el identificador 'usr-02'. Es una preferencia de interfaz, como el tema oscuro: si un usuario lo edita a mano en localStorage, lo único que consigue es que su propia interfaz muestre botones que el servidor rechazará en cuanto los use. No hay nada que robar.

Lo que nunca debe guardarse ahí: contraseñas, claves de API, datos personales de terceros y, con matices importantes, tokens de sesión.

El problema de localStorage con un token es concreto: cualquier JavaScript que se ejecute en tu página puede leerlo. Una vulnerabilidad de tipo XSS —una entrada de usuario que se pinta sin sanear, una dependencia comprometida— convierte el robo del token en una línea de código, y con ese token el atacante actúa como el usuario desde cualquier sitio.

La alternativa habitual son las cookies httpOnly:

localStorage Cookie httpOnly + Secure + SameSite
Legible por JavaScript No: el navegador la envía, el código no la ve
Se envía automáticamente No: hay que ponerla en cada cabecera Sí, en cada petición al dominio
Expuesta a XSS Mucho menos
Expuesta a CSRF No Sí, requiere SameSite y token anti-CSRF
Requiere colaboración del servidor No : solo el servidor puede ponerla
Funciona entre dominios distintos Sí, manualmente Requiere configuración cuidadosa

Y aquí conviene ser honesto sobre el alcance de este curso: la elección entre una y otra, la duración de los tokens, la renovación silenciosa, la revocación y la protección contra CSRF no son decisiones de un desarrollador de interfaces trabajando solo. Son decisiones de arquitectura que se toman con el equipo responsable de la seguridad y del servidor, porque la mitad de la solución vive allí: una cookie httpOnly solo la puede establecer el servidor. Si en un proyecto real te encuentras decidiendo esto por tu cuenta en el cliente, la señal correcta es plantearlo al equipo, no elegir la opción que parezca más cómoda.

Lo que sí es responsabilidad tuya en el cliente, y no es poco:

  • Nunca escribir secretos en el código. Todo lo que hay en el paquete de JavaScript es público, incluidas las variables de entorno de Vite que empiezan por VITE_.
  • No poner datos sensibles en la URL, que se guarda en el historial, se comparte y aparece en los registros del servidor.
  • No fiarse jamás de un dato del cliente para decidir un permiso en el servidor.
  • Borrar la sesión al cerrarla de verdad, y avisar al servidor para que la invalide.
  • Sanear todo lo que se pinte como HTML. React escapa el contenido por defecto —lo viste en 01-04—, y dangerouslySetInnerHTML se llama así por algo.

  1. Ocultar en la interfaz lo que no se puede usar

Proteger la ruta evita que se vea la pantalla; ocultar el enlace evita que el usuario llegue hasta ahí para encontrarse un rechazo. Las dos cosas se hacen juntas, y son complementarias, no alternativas.

// src/componentes/MenuUsuario.jsx — sesión y rol reflejados en el menú
import { NavLink, Link, useNavigate } from 'react-router';
import { useUsuario } from '../contextos/ContextoUsuario.jsx';
import { useTema } from '../contextos/ContextoTema.jsx';
import BotonTema from './BotonTema.jsx';
import estilos from './MenuUsuario.module.css';

function MenuUsuario() {
  const { usuario, esOperario, cerrarSesion } = useUsuario();
  const navegar = useNavigate();

  function manejarCierre() {
    cerrarSesion();
    // Al cerrar sesión, fuera de cualquier pantalla protegida
    navegar('/', { replace: true });
  }

  if (!usuario) {
    return (
      <div className={estilos.menu}>
        <BotonTema />
        <Link to="/acceso" className={estilos.acceso}>
          Iniciar sesión
        </Link>
      </div>
    );
  }

  return (
    <div className={estilos.menu}>
      <BotonTema />
      <span className={estilos.nombre}>{usuario.nombre}</span>

      {/* El enlace al taller solo existe para el operario */}
      {esOperario && (
        <NavLink to="/taller" className={estilos.enlace}>
          Panel de taller
        </NavLink>
      )}

      <button type="button" onClick={manejarCierre}>
        Cerrar sesión
      </button>
    </div>
  );
}

export default MenuUsuario;

El mismo criterio se aplica dentro de las pantallas: TarjetaBicicleta ya lee esOperario del contexto desde 05-04 para mostrar el botón «Enviar a taller» solo a quien puede usarlo.

Y el detalle del cierre de sesión que se olvida a menudo: si el usuario cierra sesión estando en /taller, hay que sacarlo de ahí. Sin ese navegar('/', { replace: true }), el guardián lo detectaría y redirigiría a /acceso, lo cual funciona pero es desconcertante: el usuario ha pulsado «Cerrar sesión» y acaba en una pantalla que le pide que entre. Llevarlo al catálogo es lo que espera.

Ocultar frente a deshabilitar, porque es una decisión de diseño recurrente:

Enfoque Cuándo Ejemplo
Ocultar La opción nunca estará disponible para este perfil «Panel de taller» para un cliente
Deshabilitar con explicación Podría estar disponible si algo cambiase «Reservar» en una bicicleta en mantenimiento
Mostrar y explicar al pulsar Se quiere que se conozca la funcionalidad Una opción de un plan superior

Ocultar todo indiscriminadamente tiene un coste: un usuario que no ve una opción no puede saber que existe ni pedir acceso a ella. Para funciones por rol, ocultar es lo normal; para estados temporales, deshabilitar con una explicación es mejor.

  1. El mapa de rutas definitivo

Así queda src/rutas.jsx al terminar el módulo, reuniendo todo lo construido en las cinco lecciones:

// src/rutas.jsx — versión definitiva del módulo 6
import { createBrowserRouter } from 'react-router';

import Diseno from './componentes/Diseno.jsx';
import RutaProtegida from './componentes/RutaProtegida.jsx';
import RequiereRol from './componentes/RequiereRol.jsx';

import PaginaCatalogo from './paginas/PaginaCatalogo.jsx';
import PaginaFichaBicicleta from './paginas/PaginaFichaBicicleta.jsx';
import PaginaEstaciones from './paginas/PaginaEstaciones.jsx';
import PaginaDetalleEstacion from './paginas/PaginaDetalleEstacion.jsx';
import PestanaFlota from './paginas/PestanaFlota.jsx';
import PestanaIncidencias from './paginas/PestanaIncidencias.jsx';
import MarcoReservas from './componentes/MarcoReservas.jsx';
import PaginaReservas from './paginas/PaginaReservas.jsx';
import PaginaNuevaReserva from './paginas/PaginaNuevaReserva.jsx';
import PaginaAcceso from './paginas/PaginaAcceso.jsx';
import PaginaTaller from './paginas/PaginaTaller.jsx';
import PaginaNoEncontrada from './paginas/PaginaNoEncontrada.jsx';
import PaginaErrorRuta from './paginas/PaginaErrorRuta.jsx';

import { estaciones } from './datos/dominio.js';

export const router = createBrowserRouter([
  {
    path: '/',
    element: <Diseno />,
    errorElement: <PaginaErrorRuta />,
    handle: { miga: 'Inicio' },
    children: [
      { index: true, element: <PaginaCatalogo />, handle: { miga: 'Catálogo' } },

      {
        path: 'bicicletas/:bicicletaId',
        element: <PaginaFichaBicicleta />,
        handle: { miga: 'Ficha de bicicleta' }
      },

      {
        path: 'estaciones',
        handle: { miga: 'Estaciones' },
        children: [
          { index: true, element: <PaginaEstaciones /> },
          {
            path: ':estacionId',
            element: <PaginaDetalleEstacion />,
            handle: {
              miga: (params) =>
                estaciones.find((est) => est.id === params.estacionId)?.nombre ?? 'Estación'
            },
            children: [
              { index: true, element: <PestanaFlota /> },
              { path: 'incidencias', element: <PestanaIncidencias /> }
            ]
          }
        ]
      },

      {
        path: 'reservas',
        element: <MarcoReservas />,
        handle: { miga: 'Reservas' },
        children: [
          { index: true, element: <PaginaReservas /> },
          { path: 'nueva', element: <PaginaNuevaReserva />, handle: { miga: 'Nueva reserva' } }
        ]
      },

      { path: 'acceso', element: <PaginaAcceso />, handle: { miga: 'Acceso' } },

      // Rama protegida: sesión requerida, sin añadir segmento a la URL
      {
        element: <RutaProtegida />,
        children: [
          {
            element: <RequiereRol rolesPermitidos={['operario']} />,
            children: [
              { path: 'taller', element: <PaginaTaller />, handle: { miga: 'Taller' } }
            ]
          }
        ]
      },

      { path: '*', element: <PaginaNoEncontrada /> }
    ]
  }
]);

Comprueba el resultado con esta tabla de escenarios:

Quién URL Qué ve
Sin sesión / Catálogo, con «Iniciar sesión» en el menú
Sin sesión /taller Redirigido a /acceso, con aviso y vuelta posterior
Ana (usr-01, cliente) / Catálogo, sin enlace al taller
Ana /taller Pantalla 403 «Sin permisos», URL conservada
Marc (usr-02, operario) /taller Panel del taller ✅
Marc /estaciones/est-02/incidencias Pestaña de incidencias, con los botones de operario
Cualquiera /estacionez Pantalla 404 «No encontrado»

Errores Comunes y Consejos

Creer que esto es seguridad. El error de fondo de toda la lección. Un guardián de rutas es experiencia de usuario. Si el servidor no comprueba los permisos en cada petición, no hay protección de ninguna clase.

Redirigir durante el estado de carga. Si el guardián decide antes de saber si hay sesión, expulsa a usuarios legítimos. Comprueba cargandoSesion antes que nada.

Olvidar replace al redirigir a /acceso. El historial queda /taller → /acceso y «atrás» produce un rebote infinito entre ambas.

Perder el destino original. Sin state={{ desde: location }}, quien abre un enlace protegido acaba en el catálogo tras identificarse y tiene que volver a buscar el enlace.

Usar la pantalla de 404 para los casos de 403. El usuario cree que la pantalla no existe y no se le ocurre entrar con otra cuenta.

Redirigir en lugar de mostrar el 403. Al perder la URL, el usuario no sabe qué intentaba abrir ni puede corregirlo.

Ocultar el enlace y no proteger la ruta. Escribir la dirección a mano basta para saltárselo. Se hacen las dos cosas.

Proteger la ruta y no proteger la API. Es lo mismo que no proteger nada, pero con la falsa sensación de haberlo hecho.

Guardar tokens en localStorage sin pensarlo. Cualquier XSS los expone. Es una decisión de arquitectura para el equipo, no un detalle de implementación del cliente.

No sacar al usuario de una pantalla protegida al cerrar sesión. Acaba en el formulario de acceso justo después de pulsar «Cerrar sesión»: desconcertante.

Consejo: declara los permisos en el mapa de rutas. Con handle (06-03), el mapa se convierte en la única fuente de verdad y se puede recorrer para generar el menú automáticamente:

{ path: 'taller', element: <PaginaTaller />, handle: { miga: 'Taller', roles: ['operario'] } }

Consejo: prueba los cuatro casos siempre. Sin sesión, con sesión y rol insuficiente, con sesión y rol correcto, y recargando (F5) en cada uno. La recarga es la que descubre los problemas del estado intermedio.

Consejo: escribe la advertencia en el propio código. Un comentario en la cabecera de RutaProtegida recordando que la autorización real está en el servidor evita que dentro de un año alguien —quizá tú— dé por hecho que ese componente protege algo.

Ejercicios

Ejercicio 1: reservar exige sesión

Un visitante sin sesión puede hoy abrir /reservas/nueva y rellenar el formulario. Protege esa pantalla, pero no /reservas, que debe seguir siendo visible para mostrar el estado vacío con una invitación a entrar. Requisitos:

  • /reservas/nueva requiere sesión de cualquier rol.
  • Tras identificarse, el usuario vuelve a /reservas/nueva.
  • MarcoReservas sigue funcionando en ambas.

Indica qué patrón de los dos usarías y por qué.

Ejercicio 2: menú generado desde el mapa

Añade roles al handle de las rutas que lo necesiten y escribe un componente MenuPrincipal que genere los enlaces de Cabecera recorriendo el mapa de rutas, mostrando solo los que el usuario actual puede visitar. Explica qué ventaja tiene esto sobre la lista escrita a mano.

Ejercicio 3: encontrar los fallos del guardián

Este guardián tiene cuatro problemas. Identifícalos y corrígelo.

function RutaProtegida({ children }) {
  const { usuario } = useUsuario();
  const navegar = useNavigate();

  useEffect(() => {
    if (!usuario) {
      navegar('/acceso');
    }
  }, []);

  return children;
}

Soluciones

Solución 1

El patrón 2 (ruta de diseño protegida), aunque solo haya una pantalla, por dos razones: mantiene MarcoReservas como padre común de ambas hijas sin duplicarlo, y deja preparada la rama para cuando haya más pantallas de reservas que requieran sesión (editar, cancelar). Con el patrón 1 habría que envolver el element de cada hija y sería fácil olvidarse en la siguiente.

{
  path: 'reservas',
  element: <MarcoReservas />,
  handle: { miga: 'Reservas' },
  children: [
    // Pública: el estado vacío invita a entrar
    { index: true, element: <PaginaReservas /> },

    // Protegida: rama sin path dentro del marco
    {
      element: <RutaProtegida />,
      children: [
        { path: 'nueva', element: <PaginaNuevaReserva />, handle: { miga: 'Nueva reserva' } }
      ]
    }
  ]
}

Y PaginaReservas distingue los dos casos del estado vacío:

function PaginaReservas() {
  const { estado } = useReservas();
  const { usuario } = useUsuario();

  if (estado.reservas.length === 0) {
    return usuario ? (
      <p>
        Todavía no tienes reservas. <Link to="/reservas/nueva">Crear una</Link>
      </p>
    ) : (
      <p>
        <Link to="/acceso">Inicia sesión</Link> para ver tus reservas y crear una nueva.
      </p>
    );
  }

  return <PanelReservas reservas={estado.reservas} />;
}

La vuelta al destino original funciona sin escribir nada más: RutaProtegida guarda state={{ desde: location }} y PaginaAcceso lo consume.

Solución 2

// src/rutas.jsx — se añade roles al handle donde haga falta
{ index: true, element: <PaginaCatalogo />, handle: { miga: 'Catálogo', enMenu: true } }
{ path: 'estaciones', handle: { miga: 'Estaciones', enMenu: true }, children: [ /* … */ ] }
{ path: 'reservas', handle: { miga: 'Reservas', enMenu: true, requiereSesion: true }, /* … */ }
{ path: 'taller', element: <PaginaTaller />, handle: { miga: 'Taller', enMenu: true, roles: ['operario'] } }
// src/componentes/MenuPrincipal.jsx
import { NavLink } from 'react-router';
import { useUsuario } from '../contextos/ContextoUsuario.jsx';
import { router } from '../rutas.jsx';
import estilos from './Cabecera.module.css';

/**
 * Recorre el mapa de rutas y devuelve las entradas de menú visibles.
 */
function recogerEntradas(rutas, prefijo = '') {
  return rutas.flatMap((ruta) => {
    const camino = ruta.index
      ? prefijo || '/'
      : [prefijo, ruta.path].filter(Boolean).join('/').replace('//', '/');

    const propia = ruta.handle?.enMenu
      ? [{ to: camino.startsWith('/') ? camino : `/${camino}`, handle: ruta.handle, index: Boolean(ruta.index) }]
      : [];

    const hijas = ruta.children ? recogerEntradas(ruta.children, ruta.path ? camino : prefijo) : [];

    return [...propia, ...hijas];
  });
}

function MenuPrincipal() {
  const { usuario } = useUsuario();

  const entradas = recogerEntradas(router.routes).filter(({ handle }) => {
    if (handle.requiereSesion && !usuario) return false;
    if (handle.roles && !handle.roles.includes(usuario?.rol)) return false;
    return true;
  });

  return (
    <nav aria-label="Navegación principal">
      {entradas.map((entrada) => (
        <NavLink
          key={entrada.to}
          to={entrada.to}
          end={entrada.index}
          className={({ isActive }) =>
            isActive ? `${estilos.enlace} ${estilos.activo}` : estilos.enlace
          }
        >
          {typeof entrada.handle.miga === 'function' ? entrada.handle.miga({}) : entrada.handle.miga}
        </NavLink>
      ))}
    </nav>
  );
}

export default MenuPrincipal;

Las ventajas sobre la lista escrita a mano: el mapa de rutas pasa a ser la única fuente de verdad, así que añadir una pantalla al menú es añadir enMenu: true a su ruta —imposible que el menú y las rutas se desincronicen—; las reglas de visibilidad se declaran junto a la ruta que protegen, en lugar de repetirse en la cabecera; y si mañana cambia un path, el enlace se actualiza solo.

El coste, que conviene reconocer: el recorrido del mapa para construir los caminos es delicado con rutas índice, rutas sin path y rutas anidadas —de ahí lo enrevesado de recogerEntradas—, y para un menú de tres entradas puede ser más código del que ahorra. En una aplicación con veinte pantallas y varios roles, compensa con mucho. Es el mismo juicio de siempre: la abstracción se paga, y hay que saber cuándo se recupera la inversión.

Solución 3

Los cuatro problemas:

  1. return children se ejecuta siempre, incluso sin sesión. El efecto se ejecuta después del render, así que el contenido protegido se pinta durante un instante antes de la redirección: exactamente lo que se quería evitar. Es el fallo más grave.
  2. Array de dependencias vacío. Si el usuario cierra sesión estando dentro, el efecto no se vuelve a ejecutar y el guardián deja de protegerlo. Debe incluir usuario y navegar.
  3. Falta replace. El historial queda /taller → /acceso y «atrás» rebota indefinidamente.
  4. No se guarda el destino original. Tras identificarse, el usuario acaba donde el formulario lo lleve, no donde quería ir.

Y un quinto, latente en cuanto la sesión se recupere de forma asíncrona: no se comprueba cargandoSesion, así que expulsaría a usuarios con sesión válida durante el primer render.

La versión corregida es la del apartado 8:

function RutaProtegida({ children }) {
  const { usuario, cargandoSesion } = useUsuario();
  const location = useLocation();

  if (cargandoSesion) {
    return <IndicadorDeCarga mensaje="Comprobando tu sesión…" />;
  }

  if (!usuario) {
    return <Navigate to="/acceso" replace state={{ desde: location }} />;
  }

  return children ?? <Outlet />;
}

La lección de fondo: decidir en el render con <Navigate /> en lugar de en un efecto resuelve de golpe los problemas 1, 2 y buena parte del 3, porque no hay ningún momento en que el contenido protegido llegue a existir. Es la aplicación directa de la regla de 06-04.

Conclusión

Empezamos y terminamos por lo mismo, porque es lo único de esta lección que no admite matices: todo el control de acceso que has escrito es experiencia de usuario, no seguridad. El código se descarga entero en el navegador del usuario, y ahí él puede cambiar variables, saltarse puntos de interrupción, editar localStorage y llamar directamente a tu API sin pasar por la interfaz. Un guardián de rutas evita que un usuario legítimo vea pantallas que no le sirven y hace que la aplicación sea coherente; la autorización real se comprueba siempre en el servidor, en cada petición, con las credenciales de quien la hace. Si el servidor devuelve los datos del taller a cualquiera que los pida, RutaProtegida no protege nada.

Dicho eso, has construido un control de acceso completo y bien hecho. Reutilizando el ContextoUsuario de 05-04 —usuario, esOperario, iniciarSesion, cerrarSesion— y con un acceso ficticio en /acceso que elige entre Ana Ribera (usr-01, cliente) y Marc Solé (usr-02, operario), tienes dos patrones a tu disposición: el componente guardián que envuelve lo que protege y redirige con <Navigate to="/acceso" replace state={{ desde: location }} />, y la ruta de diseño protegida —una ruta sin path cuyo elemento es el guardián y que agrupa varias hijas con <Outlet />—, preferible en cuanto hay más de una pantalla porque se declara una vez, es imposible olvidarla al añadir la siguiente y permite pintar el marco común del área privada. Sobre ellos, RequiereRol separa la autenticación («¿quién eres?») de la autorización («¿puedes?»), con una pantalla 403 que conserva la URL, explica con qué rol has entrado y ofrece cambiar de cuenta, claramente distinta del 404 que dice que la dirección no existe: mezclarlas deja al usuario creyendo que la pantalla no está ahí. Has resuelto el estado intermedio con cargandoSesion y un indicador accesible, para que nadie sea expulsado mientras aún se comprueba si tiene sesión —imprescindible en cuanto la validación sea asíncrona—, has persistido la sesión con el useAlmacenLocal de 05-06 guardando solo el identificador y derivando el usuario, y sabes que la elección entre localStorage y cookies httpOnly para un token es una decisión de arquitectura que se plantea al equipo de seguridad, no algo que se decida solo en el cliente. Y has completado el cuadro ocultando en el menú lo que el rol no puede usar, además de proteger la ruta: las dos cosas, nunca una sola.

Con esto se cierra el Módulo 6. CicloUrbano ha pasado de una única pantalla a una aplicación enrutada de verdad: nueve rutas con Diseno como raíz persistente, segmentos dinámicos para bicicletas y estaciones, filtros en la URL, pestañas anidadas, migas de pan generadas desde el propio mapa, contención de errores por rama, navegación programática tras confirmar una reserva, bloqueo de salida con cambios sin guardar y una rama protegida por sesión y por rol.

Y aparece el siguiente problema, que ya se nota en el código que acabas de escribir. El estado está repartido por toda la aplicación: la sesión en ContextoUsuario, el tema en ContextoTema, los avisos en ContextoAvisos, las reservas en un reductor dentro de ContextoReservas, el filtro del catálogo en la URL y el término de búsqueda en un useState de la página. Cada uno vive en un sitio distinto por una razón distinta, algunos componentes consumen tres contextos a la vez y ya no está claro dónde debería ir el próximo dato que aparezca ni cómo evitar que un cambio en un contexto repinte media aplicación. El Módulo 7: Gestión del Estado pone orden en todo esto: qué tipos de estado existen y dónde debe vivir cada uno, hasta dónde llega el contexto como estrategia global y cuáles son sus costes reales, qué aporta Redux con su almacén único, sus acciones y sus reductores, cómo se conecta a React, y por qué los datos que vienen de un servidor son una categoría aparte con sus propias herramientas. La próxima lección es Introducción a la Gestión del Estado.

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