El andamiaje está montado: el proyecto arranca, el linter no protesta y json-server responde. Pero no hay nada que mirar. Esta lección levanta CicloUrbano entero por dentro: el sistema de diseño, el marco de la aplicación y las ocho pantallas, todas navegables y con aspecto definitivo.
Y lo hace con una restricción deliberada: con datos estáticos, sin tocar la red. La razón no es pedagógica, es de método. Una interfaz que ya se puede recorrer se valida en veinte minutos con quien va a usarla, y los cambios que salgan de esa revisión —que siempre salen— cuestan aquí una fracción de lo que costarían con hooks de consulta, mutaciones e invalidaciones enganchados encima. Separar «cómo se ve» de «de dónde vienen los datos» es lo que hace que 11-03 sea una lección de conexión y no una de reconstrucción.
Hay una segunda idea que gobierna todo lo que viene: los estados vacío, de carga y de error se diseñan ahora, no cuando aparezcan. La mayoría de las interfaces feas de este mundo son interfaces que solo se diseñaron para el caso feliz. Aquí, cada pantalla se escribe con sus cuatro estados desde el primer momento, aunque de momento tres de ellos se activen con una variable a mano.
Contenido
- Qué se construye aquí y qué se deja para 11-03
- El sistema de diseño:
index.css - Tema claro y oscuro con
data-tema - Los componentes base y su contrato de props
Diseno: el marco de la aplicación- Navegación con
NavLinky migas de pan - Accesibilidad estructural: puntos de referencia y salto al contenido
- Los datos de ejemplo mientras no hay red
- Los cuatro estados de cada pantalla
- Pantalla 1: el catálogo
- Pantalla 2: la ficha de una bicicleta
- Pantallas 3 y 4: estaciones y detalle con pestañas
- Pantalla 5: mis reservas
- Pantalla 6: nueva reserva
- Pantallas 7 y 8: acceso y taller
- Las pantallas de error
- Diseño adaptable con CSS Modules
- La lista de comprobación de accesibilidad por pantalla
- Recorrido de revisión de la interfaz terminada
- Qué se construye aquí y qué se deja para 11-03
| Se construye ahora | Se deja para 11-03 |
|---|---|
| Variables de diseño, reinicio y tema | — |
Componentes base (Boton, Campo, Panel, Etiqueta, Modal) |
— |
Diseno, Cabecera, MigasDePan, PieDePagina |
— |
| Las ocho pantallas con su composición completa | — |
| Estados vacío, de carga y de error maquetados | Que se activen con datos reales |
| Formularios con su marcado accesible | La validación conectada y el envío |
Datos importados de src/datos/dominio.js |
fetch, TanStack Query, Redux |
Interacción local (useState para pestañas y modales) |
El filtro en la URL, la sesión real |
Una consecuencia práctica: en esta lección RutaProtegida deja pasar a todo el mundo. Se escribe el componente, se coloca en el árbol de rutas y devuelve <Outlet /> sin comprobar nada, con un comentario que lo señala. Así se puede navegar a /reservas y a /taller para revisar su aspecto, y en 11-03 solo hay que rellenar la condición.
- El sistema de diseño:
index.css
index.cssTodo lo visual del proyecto sale de aquí. Un fichero, unas cuantas variables, y ninguna decisión de color tomada dos veces.
/* src/index.css */
/* ---------- 1. Reinicio mínimo ---------- */
*,
*::before,
*::after {
box-sizing: border-box;
}
* {
margin: 0;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
min-height: 100vh;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
img,
picture,
svg {
display: block;
max-width: 100%;
}
input,
button,
textarea,
select {
font: inherit;
color: inherit;
}
h1, h2, h3, h4 {
line-height: 1.2;
text-wrap: balance;
}
/* ---------- 2. Variables de diseño ---------- */
:root {
/* Color */
--color-marca: #12805c;
--color-marca-oscura: #0d6247;
--color-alquilada: #b45309;
--color-mantenimiento: #9b1c1c;
--color-fondo: #f5f7fa;
--color-superficie: #ffffff;
--color-texto: #1f2933;
--color-texto-suave: #52606d;
--color-borde: #d9e2ec;
--color-foco: #2563eb;
/* Forma */
--radio: 8px;
--sombra: 0 1px 3px rgba(15, 23, 42, 0.08), 0 1px 2px rgba(15, 23, 42, 0.04);
--sombra-elevada: 0 10px 25px rgba(15, 23, 42, 0.15);
/* Espaciado: una escala, no números sueltos */
--espacio: 1rem;
--espacio-xs: 0.25rem;
--espacio-s: 0.5rem;
--espacio-l: 1.5rem;
--espacio-xl: 2.5rem;
/* Tipografía */
--fuente: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
--texto-s: 0.875rem;
--texto-m: 1rem;
--texto-l: 1.25rem;
--texto-xl: 1.75rem;
/* Otros */
--ancho-maximo: 72rem;
--transicion: 150ms ease;
}
/* ---------- 3. Base ---------- */
body {
font-family: var(--fuente);
background-color: var(--color-fondo);
color: var(--color-texto);
}
/* Foco visible SIEMPRE. Nunca outline: none sin sustituto */
:focus-visible {
outline: 3px solid var(--color-foco);
outline-offset: 2px;
border-radius: 2px;
}
/* Respeto por quien pide menos movimiento */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}Tres decisiones que conviene razonar, porque se aplican en cada componente a partir de ahora:
- La escala de espaciado tiene cinco valores y ninguno más. En cuanto se admite un
padding: 13pxporque «quedaba mejor», el sistema deja de existir y cada pantalla acaba con su propio ritmo visual. Si un valor no encaja, se ajusta la escala, no el componente. :focus-visibleen el reinicio, con estilo propio. El anillo del navegador se pierde en cuanto cambias los colores de un botón; definirlo una vez aquí garantiza que ninguna parte de la aplicación puede quedarse sin indicador de foco, que es el fallo de accesibilidad más frecuente y más grave en aplicaciones de este tipo (03-06).prefers-reduced-motiondesde el primer día. Cuesta seis líneas, y evita que las transiciones provoquen molestias reales a quien las ha desactivado en su sistema.
- Tema claro y oscuro con
data-tema
data-temaEl tema se implementa con un atributo en el elemento raíz y una redefinición de variables. Ni una sola regla de componente cambia.
/* src/index.css — continuación */
:root[data-tema='oscuro'] {
--color-fondo: #131a22;
--color-superficie: #1b242f;
--color-texto: #e4ecf3;
--color-texto-suave: #9aa5b1;
--color-borde: #2c3846;
--color-marca: #1fa87a; /* más luminoso: sobre fondo oscuro necesita más contraste */
--color-alquilada: #d97706;
--color-mantenimiento: #ef4444;
--sombra: 0 1px 3px rgba(0, 0, 0, 0.4);
--sombra-elevada: 0 10px 25px rgba(0, 0, 0, 0.5);
}El proveedor, que en esta lección solo guarda el valor en memoria y en localStorage:
// src/contextos/ProveedorTema.jsx
import { createContext, useContext, useEffect, useMemo, useCallback } from 'react';
import { useAlmacenLocal } from '../hooks/useAlmacenLocal.js';
const ContextoTema = createContext(null);
export function ProveedorTema({ children }) {
const [tema, setTema] = useAlmacenLocal('ciclourbano:tema', 'claro');
// Efecto de sincronización con el DOM: el atributo vive fuera de React
useEffect(() => {
document.documentElement.dataset.tema = tema;
}, [tema]);
const alternarTema = useCallback(() => {
setTema((actual) => (actual === 'claro' ? 'oscuro' : 'claro'));
}, [setTema]);
// value estabilizado: sin esto, cada render del proveedor repinta TODOS los consumidores
const valor = useMemo(() => ({ tema, alternarTema }), [tema, alternarTema]);
return <ContextoTema.Provider value={valor}>{children}</ContextoTema.Provider>;
}
export function useTema() {
const contexto = useContext(ContextoTema);
if (contexto === null) {
throw new Error('useTema debe usarse dentro de ProveedorTema.');
}
return contexto;
}Dos detalles que ya se explicaron y aquí se aplican sin discusión: el value memorizado de 07-02, que evita que todo el árbol se repinte cuando el proveedor se renderice por cualquier motivo; y el guardián en el hook, que convierte el error silencioso de usar useTema fuera del proveedor —donde el contexto valdría null y reventaría con un mensaje incomprensible— en un error explícito que dice exactamente qué falta.
- Los componentes base y su contrato de props
Cinco componentes. Todo lo demás se construye encima. Su valor no está en el código, que es trivial, sino en que existan y se usen: sin ellos, en dos semanas hay siete botones distintos y ninguno se parece a los demás.
Primero, la utilidad que combina clases, ya conocida del módulo 2:
// src/utilidades/clases.js
export function clases(...valores) {
return valores.filter(Boolean).join(' ');
}Boton
// src/componentes/base/Boton.jsx
import { clases } from '../../utilidades/clases.js';
import estilos from './Boton.module.css';
function Boton({
variante = 'primario',
tamano = 'medio',
tipo = 'button',
cargando = false,
anchoCompleto = false,
children,
className,
...resto
}) {
return (
<button
type={tipo}
className={clases(
estilos.boton,
estilos[variante],
estilos[tamano],
anchoCompleto && estilos.anchoCompleto,
className
)}
aria-busy={cargando || undefined}
disabled={cargando || resto.disabled}
{...resto}
>
{cargando ? 'Un momento…' : children}
</button>
);
}
export default Boton;| Prop | Tipo | Por defecto | Para qué |
|---|---|---|---|
variante |
'primario' | 'secundario' | 'peligro' | 'texto' |
'primario' |
Jerarquía visual de la acción |
tamano |
'pequeno' | 'medio' | 'grande' |
'medio' |
Densidad según el contexto |
tipo |
'button' | 'submit' |
'button' |
Por defecto button: evita envíos accidentales |
cargando |
boolean |
false |
Deshabilita y marca aria-busy |
anchoCompleto |
boolean |
false |
Ocupa todo el ancho (móvil, formularios) |
...resto |
— | — | onClick, disabled, aria-*, data-testid |
Tres decisiones del contrato que merecen explicación:
type="button"por defecto. El valor por defecto del HTML essubmit, y ese detalle provoca uno de los fallos más difíciles de diagnosticar del desarrollo web: un botón secundario dentro de un formulario que lo envía al pulsarlo. Invertir el valor por defecto y exigirtipo="submit"explícito lo elimina de raíz.{...resto}al final. Permite pasar cualquier atributo nativo sin ampliar el contrato, y al ir después declassNamedeja que quien lo usa sobrescriba lo que necesite. Es composición, no configuración (04-02).cargandodeshabilita además de indicar. Un botón que dice «Un momento…» pero se puede seguir pulsando produce reservas duplicadas. El estado visual y el funcional no se separan.
Campo
El componente que garantiza que no puede existir un control sin etiqueta en toda la aplicación.
// src/componentes/base/Campo.jsx
import { useId } from 'react';
import { clases } from '../../utilidades/clases.js';
import estilos from './Campo.module.css';
function Campo({ etiqueta, error, ayuda, obligatorio = false, children }) {
const id = useId();
const idError = `${id}-error`;
const idAyuda = `${id}-ayuda`;
// Se describe con la ayuda y, si lo hay, con el error
const descritoPor = clases(ayuda && idAyuda, error && idError) || undefined;
return (
<div className={clases(estilos.campo, error && estilos.conError)}>
<label className={estilos.etiqueta} htmlFor={id}>
{etiqueta}
{obligatorio && (
<span className={estilos.obligatorio} aria-hidden="true">
{' '}*
</span>
)}
</label>
{ayuda && (
<p className={estilos.ayuda} id={idAyuda}>
{ayuda}
</p>
)}
{/* El control se recibe como función para poder inyectarle id y aria-* */}
{children({
id,
'aria-invalid': error ? true : undefined,
'aria-describedby': descritoPor,
'aria-required': obligatorio || undefined
})}
{error && (
<p className={estilos.error} id={idError} role="alert">
{error}
</p>
)}
</div>
);
}
export default Campo;// Uso
<Campo etiqueta="Duración (horas)" obligatorio error={errores.horas} ayuda="Entre 1 y 24 horas">
{(props) => (
<input {...props} type="number" min="1" max="24" value={horas} onChange={manejarHoras} />
)}
</Campo>El patrón de hijos como función (04-02) resuelve aquí un problema real: Campo necesita generar un id único con useId y pasárselo al control para el htmlFor, pero no sabe si el control es un input, un select o un textarea. Pasando las props calculadas a una función, quien lo usa decide el elemento y Campo garantiza el cableado accesible. El resultado es que es más fácil escribir un campo accesible que uno que no lo sea, que es la única forma de que la accesibilidad sobreviva a las prisas.
Panel, Etiqueta y Modal
// src/componentes/base/Panel.jsx
import { clases } from '../../utilidades/clases.js';
import estilos from './Panel.module.css';
function Panel({ titulo, nivel = 2, acciones, children, className }) {
const Encabezado = `h${nivel}`; // el nivel lo decide la pantalla, no el componente
return (
<section className={clases(estilos.panel, className)}>
{(titulo || acciones) && (
<header className={estilos.cabecera}>
{titulo && <Encabezado className={estilos.titulo}>{titulo}</Encabezado>}
{acciones && <div className={estilos.acciones}>{acciones}</div>}
</header>
)}
<div className={estilos.cuerpo}>{children}</div>
</section>
);
}
export default Panel;// src/componentes/base/Etiqueta.jsx (EtiquetaEstado es su especialización)
import { clases } from '../../utilidades/clases.js';
import estilos from './Etiqueta.module.css';
const TEXTOS = {
disponible: 'Disponible',
alquilada: 'Alquilada',
mantenimiento: 'En mantenimiento'
};
export function EtiquetaEstado({ estado }) {
return (
<span className={clases(estilos.etiqueta, estilos[estado])}>
{TEXTOS[estado] ?? estado}
</span>
);
}La prop nivel de Panel parece un detalle y no lo es: si Panel fijara siempre un <h2>, una pantalla con paneles anidados produciría una jerarquía de encabezados rota, que es como quien navega con lector de pantalla se pierde. El componente no puede saber en qué nivel del documento lo van a colocar; quien lo coloca, sí.
Y Modal, con las cuatro obligaciones de un diálogo (03-06):
// src/componentes/base/Modal.jsx
import { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { useEventoTeclado } from '../../hooks/useEventoTeclado.js';
import estilos from './Modal.module.css';
function Modal({ abierto, titulo, alCerrar, children }) {
const contenedor = useRef(null);
const elementoPrevio = useRef(null);
useEventoTeclado('Escape', alCerrar, { activo: abierto });
useEffect(() => {
if (!abierto) return;
// 1) Recordar el foco de origen y 2) llevarlo al diálogo
elementoPrevio.current = document.activeElement;
contenedor.current?.focus();
// 3) Bloquear el desplazamiento del fondo
const overflowPrevio = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = overflowPrevio;
// 4) Devolver el foco a donde estaba
elementoPrevio.current?.focus?.();
};
}, [abierto]);
if (!abierto) return null;
return createPortal(
<div className={estilos.velo} onClick={alCerrar}>
<div
ref={contenedor}
className={estilos.dialogo}
role="dialog"
aria-modal="true"
aria-labelledby="titulo-modal"
tabIndex={-1}
onClick={(evento) => evento.stopPropagation()}
>
<h2 id="titulo-modal" className={estilos.titulo}>{titulo}</h2>
{children}
</div>
</div>,
document.body
);
}
export default Modal;El createPortal (05-03) es lo que evita que el diálogo quede atrapado dentro de un contenedor con overflow: hidden o con un z-index que lo tape. El stopPropagation del interior impide que un clic dentro del diálogo llegue al velo y lo cierre.
Diseno: el marco de la aplicación
Diseno: el marco de la aplicación// src/componentes/Diseno.jsx
import { Outlet } from 'react-router';
import Cabecera from './Cabecera.jsx';
import MigasDePan from './MigasDePan.jsx';
import ListaAvisos from './ListaAvisos.jsx';
import PieDePagina from './PieDePagina.jsx';
import estilos from './Diseno.module.css';
function Diseno() {
return (
<div className={estilos.contenedor}>
<a className={estilos.saltar} href="#contenido">
Saltar al contenido principal
</a>
<Cabecera />
<main className={estilos.principal} id="contenido" tabIndex={-1}>
<MigasDePan />
<ListaAvisos />
<Outlet />
</main>
<PieDePagina />
</div>
);
}
export default Diseno;/* src/componentes/Diseno.module.css */
.contenedor {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.principal {
flex: 1;
width: 100%;
max-width: var(--ancho-maximo);
margin: 0 auto;
padding: var(--espacio-l) var(--espacio);
}
/* El enlace de salto: fuera de pantalla hasta que recibe el foco */
.saltar {
position: absolute;
left: -9999px;
z-index: 100;
padding: var(--espacio-s) var(--espacio);
background: var(--color-marca);
color: #fff;
border-radius: 0 0 var(--radio) 0;
text-decoration: none;
}
.saltar:focus {
left: 0;
top: 0;
}Cinco cosas de esta plantilla que valen para toda la aplicación:
- El enlace de salto es el primer elemento enfocable del documento. Quien navega con teclado no tiene que atravesar los seis enlaces de la navegación en cada pantalla. Está oculto visualmente y aparece al recibir el foco: es el patrón estándar y no molesta a nadie más.
min-height: 100vhconflex: 1en elmainmantiene el pie abajo aunque la página tenga tres líneas. Sin esto, la pantalla de «sin reservas» deja el pie flotando a media altura.tabIndex={-1}en elmainlo hace enfocable por programa —no por tabulación— para que el enlace de salto pueda llevar el foco de verdad. Sin ese atributo, el navegador mueve el desplazamiento pero deja el foco donde estaba, y la siguiente tabulación vuelve al principio.ListaAvisosva dentro delmainy antes delOutlet. Los mensajes de resultado deben aparecer en el flujo del contenido, no como una capa flotante que un lector de pantalla anuncie fuera de contexto.- El ancho máximo vive aquí, no en cada pantalla. Una pantalla que necesite todo el ancho lo pedirá explícitamente; el resto lo hereda.
- Navegación con
NavLink y migas de pan
NavLink y migas de pan// src/componentes/Cabecera.jsx
import { NavLink, Link } from 'react-router';
import MenuUsuario from './MenuUsuario.jsx';
import BotonTema from './BotonTema.jsx';
import { clases } from '../utilidades/clases.js';
import { NOMBRE_APP } from '../configuracion.js';
import estilos from './Cabecera.module.css';
const ENLACES = [
{ a: '/', texto: 'Catálogo', exacto: true },
{ a: '/estaciones', texto: 'Estaciones' },
{ a: '/reservas', texto: 'Mis reservas' },
{ a: '/taller', texto: 'Taller', soloOperario: true }
];
function Cabecera() {
// En 11-03 saldrá de la sesión real; ahora se fija para poder ver la pantalla
const esOperario = true;
const visibles = ENLACES.filter((enlace) => !enlace.soloOperario || esOperario);
return (
<header className={estilos.cabecera}>
<div className={estilos.interior}>
<Link to="/" className={estilos.marca}>
<span aria-hidden="true">🚲</span> {NOMBRE_APP}
</Link>
<nav className={estilos.navegacion} aria-label="Principal">
<ul className={estilos.lista}>
{visibles.map((enlace) => (
<li key={enlace.a}>
<NavLink
to={enlace.a}
end={enlace.exacto}
className={({ isActive }) =>
clases(estilos.enlace, isActive && estilos.activo)
}
>
{enlace.texto}
</NavLink>
</li>
))}
</ul>
</nav>
<div className={estilos.acciones}>
<BotonTema />
<MenuUsuario />
</div>
</div>
</header>
);
}
export default Cabecera;/* src/componentes/Cabecera.module.css — el estado activo */
.enlace {
display: block;
padding: var(--espacio-s) var(--espacio);
color: var(--color-texto-suave);
text-decoration: none;
border-radius: var(--radio);
transition: background-color var(--transicion);
}
.enlace:hover {
background-color: var(--color-fondo);
color: var(--color-texto);
}
.activo {
color: var(--color-marca);
font-weight: 600;
/* El color NO puede ser la única señal: se añade una marca de forma */
box-shadow: inset 0 -2px 0 var(--color-marca);
}Dos puntos que se decidieron en el módulo 6 y aquí se recogen:
enden el enlace al catálogo. Sin él,/coincide con todas las rutas —porqueNavLinkcompara por prefijo— y el enlace del catálogo aparecería activo estando en/reservas.- El estado activo no se señala solo con color. Una persona con daltonismo o con un monitor en escala de grises no distingue el enlace actual si la única diferencia es el tono. El subrayado grueso lo resuelve, y no cuesta nada.
Las migas de pan aprovechan el handle que ya llevaba cada ruta:
// src/componentes/MigasDePan.jsx
import { Link, useMatches } from 'react-router';
import estilos from './MigasDePan.module.css';
function MigasDePan() {
const coincidencias = useMatches().filter((m) => m.handle?.miga);
if (coincidencias.length <= 1) return null; // en el inicio no aportan nada
return (
<nav aria-label="Ruta de navegación" className={estilos.migas}>
<ol className={estilos.lista}>
{coincidencias.map((coincidencia, indice) => {
const esUltima = indice === coincidencias.length - 1;
const texto =
typeof coincidencia.handle.miga === 'function'
? coincidencia.handle.miga(coincidencia)
: coincidencia.handle.miga;
return (
<li key={coincidencia.id} className={estilos.miga}>
{esUltima ? (
<span aria-current="page">{texto}</span>
) : (
<>
<Link to={coincidencia.pathname}>{texto}</Link>
<span aria-hidden="true" className={estilos.separador}>/</span>
</>
)}
</li>
);
})}
</ol>
</nav>
);
}
export default MigasDePan;El separador / es decorativo y lleva aria-hidden: sin él, un lector de pantalla leería «Inicio barra Estaciones barra Plaza Mayor». El último elemento no es un enlace —no tiene sentido enlazar a la página en la que ya estás— y se marca con aria-current="page".
- Accesibilidad estructural: puntos de referencia y salto al contenido
Antes de escribir pantallas, conviene fijar la estructura del documento que todas van a compartir:
flowchart TD
BODY["body"]
BODY --> SALTO["a.saltar → #contenido"]
BODY --> HEAD["header · banner"]
HEAD --> NAV["nav aria-label='Principal'"]
HEAD --> ACC["Acciones: tema y usuario"]
BODY --> MAIN["main#contenido · main"]
MAIN --> MIGAS["nav aria-label='Ruta de navegación'"]
MAIN --> AVISOS["ListaAvisos · role='status'"]
MAIN --> OUT["Outlet: h1 de la pantalla + secciones"]
BODY --> PIE["footer · contentinfo"]
Las reglas que se aplican en todas las pantallas sin excepción:
| Regla | Por qué |
|---|---|
Un solo <h1> por pantalla, y es el título de la pantalla |
Es el ancla de orientación de cualquier lector de pantalla |
Los niveles no se saltan (h1 → h2 → h3) |
Saltar de h1 a h3 sugiere una sección inexistente |
Cada <nav> con su aria-label |
Hay dos navegaciones; sin etiqueta son indistinguibles en la lista de puntos de referencia |
Solo un <main> por documento |
Es el destino del enlace de salto |
Los iconos decorativos con aria-hidden="true" |
«Emoji bicicleta Catálogo» no ayuda a nadie |
| Los botones con texto real, no solo icono | Y si el icono va solo, aria-label obligatorio |
- Los datos de ejemplo mientras no hay red
// src/datos/dominio.js — copia estática de db.json para trabajar sin API
export const bicicletas = [
{ id: 'bici-001', modelo: 'Urbana Clásica', tipo: 'urbana', estado: 'disponible', estacionId: 'est-01', precioHora: 2.5 },
{ id: 'bici-002', modelo: 'Eléctrica Pro', tipo: 'electrica', estado: 'alquilada', estacionId: 'est-01', precioHora: 4.0 },
{ id: 'bici-003', modelo: 'Carga Max', tipo: 'carga', estado: 'mantenimiento', estacionId: 'est-02', precioHora: 5.5 },
{ id: 'bici-004', modelo: 'Urbana Clásica', tipo: 'urbana', estado: 'disponible', estacionId: 'est-03', precioHora: 2.5 },
{ id: 'bici-005', modelo: 'Eléctrica Pro', tipo: 'electrica', estado: 'disponible', estacionId: 'est-02', precioHora: 4.0 }
];
export const estaciones = [
{ id: 'est-01', nombre: 'Plaza Mayor', barrio: 'Centro', plazas: 20 },
{ id: 'est-02', nombre: 'Parque Norte', barrio: 'Norte', plazas: 15 },
{ id: 'est-03', nombre: 'Estación Central', barrio: 'Ensanche', plazas: 30 }
];
export const usuarios = [
{ id: 'usr-01', nombre: 'Ana Ribera', email: '[email protected]', rol: 'cliente' },
{ id: 'usr-02', nombre: 'Marc Solé', email: '[email protected]', rol: 'operario' }
];
export const reservas = [
{ id: 'res-01', bicicletaId: 'bici-002', usuario: 'usr-01', fechaInicio: '2026-05-04T09:00', horas: 2, estado: 'activa' }
];Es una copia literal de db.json, a propósito. Cuando 11-03 sustituya estas importaciones por hooks de consulta, la forma de los datos será idéntica y ningún componente tendrá que cambiar. Ese es el motivo de mantener la duplicación en lugar de inventar datos nuevos aquí.
El fichero no se borra en 11-03: pasa a usarse en las pruebas y en el db.json de siembra de Cypress.
- Los cuatro estados de cada pantalla
Cada pantalla que muestre datos tiene cuatro caras. Escribirlas ahora cuesta minutos; añadirlas después, cuando la lógica ya está entrelazada, cuesta horas.
| Estado | Qué se ve | Componente |
|---|---|---|
| Cargando | Bloques grises con la forma del contenido final | EsqueletoPagina |
| Vacío | Explicación de por qué no hay nada y qué hacer | Bloque propio de la pantalla |
| Error | Mensaje comprensible y botón de reintento | Aviso con role="alert" |
| Con datos | El contenido | La pantalla |
// src/componentes/EsqueletoPagina.jsx
import estilos from './EsqueletoPagina.module.css';
function EsqueletoPagina({ filas = 3, conCabecera = true }) {
return (
<div className={estilos.esqueleto} data-testid="esqueleto-pagina" aria-hidden="true">
{conCabecera && <div className={estilos.titulo} />}
{Array.from({ length: filas }, (_, indice) => (
<div key={indice} className={estilos.fila} />
))}
</div>
);
}
export default EsqueletoPagina;// El anuncio para lectores de pantalla va aparte, porque el esqueleto es aria-hidden
function IndicadorDeCarga({ texto = 'Cargando…' }) {
return (
<p role="status" aria-live="polite" className="visualmente-oculto">
{texto}
</p>
);
}El reparto es deliberado: el esqueleto es información puramente visual y para un lector de pantalla es ruido —anunciaría una docena de contenedores vacíos—, así que se oculta con aria-hidden. La información equivalente se da con un texto en role="status" que es invisible pero se anuncia. Dos canales, un mismo mensaje.
Y el estado vacío, que es el que más se descuida:
// Mal: no dice nada
<p>No hay reservas.</p>
// Bien: explica y ofrece la salida
<div className={estilos.vacio}>
<h2>Todavía no tienes reservas</h2>
<p>Cuando reserves una bicicleta aparecerá aquí con su hora de inicio y su duración.</p>
<Boton onClick={() => navegar('/')}>Ver el catálogo</Boton>
</div>
- Pantalla 1: el catálogo
La pantalla más visitada y la que más piezas compone. Aquí, con estado local; en 11-03, el filtro se muda a la URL y los datos a TanStack Query.
// src/paginas/PaginaCatalogo.jsx
import { useState, useMemo, useDeferredValue } from 'react';
import Panel from '../componentes/base/Panel.jsx';
import SelectorTipo from '../componentes/SelectorTipo.jsx';
import BuscadorBicicletas from '../componentes/BuscadorBicicletas.jsx';
import ListaBicicletas from '../componentes/ListaBicicletas.jsx';
import ResumenFlota from '../componentes/ResumenFlota.jsx';
import EsqueletoPagina from '../componentes/EsqueletoPagina.jsx';
import Aviso from '../componentes/Aviso.jsx';
import { bicicletas, estaciones } from '../datos/dominio.js';
import estilos from './PaginaCatalogo.module.css';
// Interruptores temporales para revisar los estados. En 11-03 los sustituyen isPending e isError
const CARGANDO = false;
const CON_ERROR = false;
function PaginaCatalogo() {
const [tipo, setTipo] = useState('todos');
const [termino, setTermino] = useState('');
// El término diferido mantiene el campo fluido con listas grandes (08-03)
const terminoDiferido = useDeferredValue(termino);
const visibles = useMemo(() => {
const texto = terminoDiferido.trim().toLowerCase();
return bicicletas
.filter((bici) => tipo === 'todos' || bici.tipo === tipo)
.filter((bici) => bici.modelo.toLowerCase().includes(texto));
}, [tipo, terminoDiferido]);
if (CARGANDO) return <EsqueletoPagina filas={5} />;
if (CON_ERROR) {
return (
<Aviso tono="error" titulo="No se han podido cargar las bicicletas">
Comprueba tu conexión e inténtalo de nuevo.
</Aviso>
);
}
return (
<>
<h1 className={estilos.titulo}>Catálogo de bicicletas</h1>
<p className={estilos.entradilla}>
{visibles.length} de {bicicletas.length} bicicletas
</p>
<ResumenFlota bicicletas={bicicletas} />
<Panel titulo="Filtros" nivel={2} className={estilos.filtros}>
<SelectorTipo tipoElegido={tipo} alCambiarTipo={setTipo} />
<BuscadorBicicletas termino={termino} alCambiarTermino={setTermino} />
</Panel>
{visibles.length === 0 ? (
<div className={estilos.vacio}>
<h2>Ninguna bicicleta coincide con la búsqueda</h2>
<p>Prueba con otro tipo o borra el texto del buscador.</p>
</div>
) : (
<ListaBicicletas bicicletas={visibles} estaciones={estaciones} />
)}
</>
);
}
export default PaginaCatalogo;SelectorTipo como grupo de botones, con useTransition para que el cambio de filtro no bloquee la interfaz (08-01):
// src/componentes/SelectorTipo.jsx
import { useTransition } from 'react';
import { clases } from '../utilidades/clases.js';
import estilos from './SelectorTipo.module.css';
const TIPOS = [
{ valor: 'todos', texto: 'Todas' },
{ valor: 'urbana', texto: 'Urbana' },
{ valor: 'electrica', texto: 'Eléctrica' },
{ valor: 'carga', texto: 'De carga' }
];
function SelectorTipo({ tipoElegido, alCambiarTipo }) {
const [pendiente, iniciarTransicion] = useTransition();
return (
<div
className={clases(estilos.grupo, pendiente && estilos.pendiente)}
role="group"
aria-label="Filtrar por tipo de bicicleta"
>
{TIPOS.map((tipo) => (
<button
key={tipo.valor}
type="button"
className={clases(estilos.boton, tipoElegido === tipo.valor && estilos.activo)}
aria-pressed={tipoElegido === tipo.valor}
onClick={() => iniciarTransicion(() => alCambiarTipo(tipo.valor))}
>
{tipo.texto}
</button>
))}
</div>
);
}
export default SelectorTipo;aria-pressed es lo que convierte cuatro botones sueltos en un grupo de alternancia comprensible: un lector de pantalla anuncia «Eléctrica, botón de alternancia, activado», que es exactamente lo que ve quien mira la pantalla.
Y la tarjeta, memorizada porque es el elemento que más se repite (08-02):
// src/componentes/TarjetaBicicleta.jsx
import { memo } from 'react';
import { Link } from 'react-router';
import { EtiquetaEstado } from './base/Etiqueta.jsx';
import Boton from './base/Boton.jsx';
import estilos from './TarjetaBicicleta.module.css';
function TarjetaBicicleta({ bicicleta, estacion, alReservar }) {
const disponible = bicicleta.estado === 'disponible';
return (
<article
className={estilos.tarjeta}
data-testid="tarjeta-bicicleta"
data-bicicleta={bicicleta.id}
>
<header className={estilos.cabecera}>
<h3 className={estilos.modelo}>
<Link to={`/bicicletas/${bicicleta.id}`}>{bicicleta.modelo}</Link>
</h3>
<EtiquetaEstado estado={bicicleta.estado} />
</header>
<dl className={estilos.datos}>
<div>
<dt>Estación</dt>
<dd>{estacion?.nombre ?? 'Sin asignar'}</dd>
</div>
<div>
<dt>Precio</dt>
<dd>{bicicleta.precioHora.toFixed(2)} €/h</dd>
</div>
</dl>
<Boton
variante="primario"
disabled={!disponible}
onClick={() => alReservar?.(bicicleta.id)}
aria-label={`Reservar ${bicicleta.modelo}`}
>
{disponible ? 'Reservar' : 'No disponible'}
</Boton>
</article>
);
}
export default memo(TarjetaBicicleta);Fíjate en el <dl>: modelo y precio no son párrafos sueltos, son pares de término y definición, y marcarlos así hace que un lector de pantalla anuncie «Estación: Plaza Mayor» en lugar de dos textos inconexos. El aria-label del botón resuelve el problema de los «cinco botones que dicen Reservar»: al navegar por la lista de botones, cada uno dice cuál es.
- Pantalla 2: la ficha de una bicicleta
// src/paginas/PaginaFichaBicicleta.jsx
import { useParams, useNavigate, Link } from 'react-router';
import Panel from '../componentes/base/Panel.jsx';
import Boton from '../componentes/base/Boton.jsx';
import { EtiquetaEstado } from '../componentes/base/Etiqueta.jsx';
import { bicicletas, estaciones } from '../datos/dominio.js';
import estilos from './PaginaFichaBicicleta.module.css';
function PaginaFichaBicicleta() {
const { bicicletaId } = useParams();
const navegar = useNavigate();
const bicicleta = bicicletas.find((b) => b.id === bicicletaId);
// El identificador inexistente NO es una pantalla en blanco (H3)
if (!bicicleta) {
return (
<div className={estilos.noEncontrada}>
<h1>Esta bicicleta no existe</h1>
<p>El identificador <code>{bicicletaId}</code> no corresponde a ninguna bicicleta.</p>
<Link to="/">Volver al catálogo</Link>
</div>
);
}
const estacion = estaciones.find((e) => e.id === bicicleta.estacionId);
const disponible = bicicleta.estado === 'disponible';
return (
<>
<header className={estilos.cabecera}>
<h1>{bicicleta.modelo}</h1>
<EtiquetaEstado estado={bicicleta.estado} />
</header>
<div className={estilos.columnas}>
<Panel titulo="Datos de la bicicleta" nivel={2}>
<dl className={estilos.datos}>
<div><dt>Identificador</dt><dd>{bicicleta.id}</dd></div>
<div><dt>Tipo</dt><dd>{bicicleta.tipo}</dd></div>
<div><dt>Precio por hora</dt><dd>{bicicleta.precioHora.toFixed(2)} €</dd></div>
</dl>
</Panel>
<Panel titulo="Ubicación" nivel={2}>
{estacion ? (
<p>
<Link to={`/estaciones/${estacion.id}`}>{estacion.nombre}</Link>
{' · '}Barrio {estacion.barrio} · {estacion.plazas} plazas
</p>
) : (
<p>Esta bicicleta no está asignada a ninguna estación.</p>
)}
</Panel>
</div>
<div className={estilos.acciones}>
<Boton
disabled={!disponible}
onClick={() => navegar(`/reservas/nueva?bicicleta=${bicicleta.id}`)}
>
{disponible ? 'Reservar esta bicicleta' : 'No disponible ahora mismo'}
</Boton>
{!disponible && (
<p className={estilos.motivo}>
{bicicleta.estado === 'alquilada'
? 'Está alquilada. Vuelve a consultarla más tarde.'
: 'Está en mantenimiento. El equipo del taller la está revisando.'}
</p>
)}
</div>
</>
);
}
export default PaginaFichaBicicleta;Dos detalles de producto, no de código: el botón deshabilitado explica por qué lo está. Un control apagado sin explicación es la forma más rápida de frustrar a alguien. Y la reserva se preselecciona por la URL (?bicicleta=bici-001), de modo que quien llega desde la ficha no tiene que volver a elegir la bicicleta en el formulario.
- Pantallas 3 y 4: estaciones y detalle con pestañas
// src/paginas/PaginaEstaciones.jsx
import TarjetaEstacion from '../componentes/TarjetaEstacion.jsx';
import { estaciones, bicicletas } from '../datos/dominio.js';
import estilos from './PaginaEstaciones.module.css';
function PaginaEstaciones() {
return (
<>
<h1>Estaciones</h1>
<p className={estilos.entradilla}>{estaciones.length} estaciones en servicio.</p>
<ul className={estilos.rejilla}>
{estaciones.map((estacion) => {
const deLaEstacion = bicicletas.filter((b) => b.estacionId === estacion.id);
return (
<li key={estacion.id}>
<TarjetaEstacion
estacion={estacion}
totalBicicletas={deLaEstacion.length}
disponibles={deLaEstacion.filter((b) => b.estado === 'disponible').length}
/>
</li>
);
})}
</ul>
</>
);
}
export default PaginaEstaciones;El detalle usa rutas anidadas para las pestañas (06-03), no estado local. Así la pestaña de incidencias es enlazable y el botón atrás funciona:
// src/paginas/PaginaDetalleEstacion.jsx
import { NavLink, Outlet, useParams } from 'react-router';
import { clases } from '../utilidades/clases.js';
import { estaciones } from '../datos/dominio.js';
import estilos from './PaginaDetalleEstacion.module.css';
function PaginaDetalleEstacion() {
const { estacionId } = useParams();
const estacion = estaciones.find((e) => e.id === estacionId);
if (!estacion) {
return <h1>Esta estación no existe</h1>;
}
const claseEnlace = ({ isActive }) => clases(estilos.pestana, isActive && estilos.activa);
return (
<>
<h1>{estacion.nombre}</h1>
<p className={estilos.entradilla}>
Barrio {estacion.barrio} · {estacion.plazas} plazas
</p>
<nav className={estilos.pestanas} aria-label="Secciones de la estación">
<NavLink to="." end className={claseEnlace}>Flota</NavLink>
<NavLink to="incidencias" className={claseEnlace}>Incidencias</NavLink>
</nav>
{/* El contenido de la pestaña activa */}
<Outlet context={{ estacion }} />
</>
);
}
export default PaginaDetalleEstacion;| Pestañas con estado local | Pestañas como rutas anidadas |
|---|---|
| No son enlazables | /estaciones/est-01/incidencias se puede compartir |
| El botón atrás sale de la pantalla | El botón atrás vuelve a la pestaña anterior |
| Se pierden al recargar | Sobreviven a la recarga |
| Todo el contenido se carga siempre | Cada pestaña puede cargarse con lazy |
- Pantalla 5: mis reservas
// src/paginas/PaginaReservas.jsx
import { useState } from 'react';
import { Link } from 'react-router';
import PanelReservas from '../componentes/PanelReservas.jsx';
import Modal from '../componentes/base/Modal.jsx';
import Boton from '../componentes/base/Boton.jsx';
import { reservas, bicicletas } from '../datos/dominio.js';
import estilos from './PaginaReservas.module.css';
function PaginaReservas() {
// Estado local puro: qué reserva está pendiente de confirmación de cancelación
const [reservaACancelar, setReservaACancelar] = useState(null);
return (
<>
<div className={estilos.cabecera}>
<h1>Mis reservas</h1>
<Link to="/reservas/nueva" className={estilos.enlaceAccion}>
Nueva reserva
</Link>
</div>
{reservas.length === 0 ? (
<div className={estilos.vacio}>
<h2>Todavía no tienes reservas</h2>
<p>Cuando reserves una bicicleta aparecerá aquí con su hora de inicio y su duración.</p>
<Link to="/">Ver el catálogo</Link>
</div>
) : (
<PanelReservas
reservas={reservas}
bicicletas={bicicletas}
alCancelar={setReservaACancelar}
/>
)}
<Modal
abierto={reservaACancelar !== null}
titulo="Cancelar la reserva"
alCerrar={() => setReservaACancelar(null)}
>
<p>¿Seguro que quieres cancelar esta reserva? Esta acción no se puede deshacer.</p>
<div className={estilos.accionesModal}>
<Boton variante="secundario" onClick={() => setReservaACancelar(null)}>
Volver
</Boton>
<Boton variante="peligro" onClick={() => setReservaACancelar(null)}>
Sí, cancelar
</Boton>
</div>
</Modal>
</>
);
}
export default PaginaReservas;La tabla de reservas, con el anclaje de prueba ya colocado:
// src/componentes/PanelReservas.jsx (extracto)
<table className={estilos.tabla}>
<caption className="visualmente-oculto">Lista de tus reservas</caption>
<thead>
<tr>
<th scope="col">Bicicleta</th>
<th scope="col">Inicio</th>
<th scope="col">Horas</th>
<th scope="col">Estado</th>
<th scope="col"><span className="visualmente-oculto">Acciones</span></th>
</tr>
</thead>
<tbody>
{reservas.map((reserva) => (
<tr key={reserva.id} data-testid="fila-reserva">
<td>{bicicletaDe(reserva)?.modelo ?? 'Desconocida'}</td>
<td>{formatearFecha(reserva.fechaInicio)}</td>
<td>{reserva.horas}</td>
<td><EtiquetaEstado estado={reserva.estado} /></td>
<td>
<Boton
variante="texto"
onClick={() => alCancelar(reserva)}
disabled={reserva.estado === 'cancelada'}
>
Cancelar
</Boton>
</td>
</tr>
))}
</tbody>
</table>Una tabla de datos debe ser una <table>, no una rejilla de <div>. Con scope="col" en las cabeceras, un lector de pantalla anuncia «Estado: activa» al recorrer la fila; con divs, lee cinco valores sueltos sin saber a qué corresponden. El <caption> oculto visualmente da el contexto de la tabla completa.
- Pantalla 6: nueva reserva
El formulario es la pantalla con más carga de accesibilidad de todo el proyecto.
// src/componentes/FormularioReserva.jsx
import { useState } from 'react';
import Campo from './base/Campo.jsx';
import Boton from './base/Boton.jsx';
import estilos from './FormularioReserva.module.css';
function FormularioReserva({ bicicletas, valorInicial, enviando = false, alEnviar }) {
const [datos, setDatos] = useState(valorInicial);
const [errores, setErrores] = useState({});
const reservables = bicicletas.filter((b) => b.estado === 'disponible');
const elegida = bicicletas.find((b) => b.id === datos.bicicletaId);
const total = elegida ? elegida.precioHora * Number(datos.horas || 0) : 0;
function manejarCambio(campo, valor) {
setDatos((actual) => ({ ...actual, [campo]: valor }));
}
function manejarEnviar(evento) {
evento.preventDefault();
// En 11-03 aquí entra validarReserva y la mutación
alEnviar?.(datos);
}
return (
<form className={estilos.formulario} onSubmit={manejarEnviar} noValidate>
<Campo etiqueta="Bicicleta" obligatorio error={errores.bicicletaId}>
{(props) => (
<select
{...props}
value={datos.bicicletaId}
onChange={(e) => manejarCambio('bicicletaId', e.target.value)}
>
<option value="">Elige una bicicleta</option>
{reservables.map((bici) => (
<option key={bici.id} value={bici.id}>
{bici.modelo} — {bici.precioHora.toFixed(2)} €/h
</option>
))}
</select>
)}
</Campo>
<Campo
etiqueta="Inicio de la reserva"
obligatorio
error={errores.fechaInicio}
ayuda="No puede empezar en el pasado"
>
{(props) => (
<input
{...props}
type="datetime-local"
value={datos.fechaInicio}
onChange={(e) => manejarCambio('fechaInicio', e.target.value)}
/>
)}
</Campo>
<Campo etiqueta="Duración (horas)" obligatorio error={errores.horas} ayuda="Entre 1 y 24">
{(props) => (
<input
{...props}
type="number"
min="1"
max="24"
value={datos.horas}
onChange={(e) => manejarCambio('horas', e.target.value)}
/>
)}
</Campo>
<Campo etiqueta="Acepto las condiciones de uso" error={errores.condiciones}>
{(props) => (
<input
{...props}
type="checkbox"
checked={datos.condiciones}
onChange={(e) => manejarCambio('condiciones', e.target.checked)}
/>
)}
</Campo>
<p className={estilos.total} data-testid="total-reserva">
Total estimado: <strong>{total.toFixed(2)} €</strong>
</p>
<Boton tipo="submit" cargando={enviando} anchoCompleto>
Crear reserva
</Boton>
</form>
);
}
export default FormularioReserva;Las decisiones del formulario, todas heredadas de 03-04 y 03-05:
| Decisión | Motivo |
|---|---|
noValidate en el <form> |
Se desactiva la validación del navegador para usar la propia, con mensajes en español, consistentes y traducibles |
El select solo lista bicicletas disponible |
Impedir el error es mejor que informar de él después |
| El total se calcula en el render | Nunca se guarda en estado un valor derivado (05-01) |
Los errores llegan por prop desde Campo |
El cableado aria-invalid / aria-describedby se resuelve en un solo sitio |
tipo="submit" explícito |
Consecuencia del valor por defecto invertido de Boton |
data-testid="total-reserva" |
Anclaje estable para 11-04 |
- Pantallas 7 y 8: acceso y taller
// src/paginas/PaginaAcceso.jsx (extracto)
function PaginaAcceso() {
const [correo, setCorreo] = useState('');
const [error, setError] = useState(null);
function manejarEnviar(evento) {
evento.preventDefault();
// En 11-03: despacho a sliceSesion y redirección al origen
}
return (
<div className={estilos.centro}>
<h1>Acceder a CicloUrbano</h1>
{error && (
<div role="alert" className={estilos.error}>
{error}
</div>
)}
<form onSubmit={manejarEnviar} noValidate>
<Campo etiqueta="Correo electrónico" obligatorio ayuda="Prueba con [email protected]">
{(props) => (
<input
{...props}
type="email"
autoComplete="email"
value={correo}
onChange={(e) => setCorreo(e.target.value)}
/>
)}
</Campo>
<Boton tipo="submit" anchoCompleto>Entrar</Boton>
</form>
</div>
);
}El autoComplete="email" no es opcional: es lo que permite que el gestor de contraseñas y el autorrelleno del navegador funcionen, y su ausencia es una de las quejas más frecuentes de las personas usuarias en formularios de acceso.
Y el taller, que en esta lección es accesible para poder revisarlo:
// src/componentes/RutaProtegida.jsx — versión de 11-02
import { Outlet } from 'react-router';
function RutaProtegida() {
// TODO(11-03): comprobar la sesión real y redirigir a /acceso conservando el origen
return <Outlet />;
}
export default RutaProtegida;// src/paginas/PaginaTaller.jsx (extracto)
function PaginaTaller() {
const enMantenimiento = bicicletas.filter((b) => b.estado === 'mantenimiento');
const resto = bicicletas.filter((b) => b.estado !== 'mantenimiento');
return (
<>
<h1>Taller</h1>
<p className={estilos.entradilla}>
Gestión del estado de la flota. Solo para personal operario.
</p>
<Panel titulo={`En mantenimiento (${enMantenimiento.length})`} nivel={2}>
{enMantenimiento.length === 0 ? (
<p>Toda la flota está operativa.</p>
) : (
<ul className={estilos.lista}>
{enMantenimiento.map((bici) => (
<li key={bici.id}>
{bici.modelo} <code>{bici.id}</code>
<Boton variante="secundario" tamano="pequeno">Devolver al servicio</Boton>
</li>
))}
</ul>
)}
</Panel>
<Panel titulo={`Resto de la flota (${resto.length})`} nivel={2}>
{/* mismo patrón, con el botón "Enviar a mantenimiento" */}
</Panel>
</>
);
}Fíjate en el orden: lo que el operario necesita ver primero está primero. Es la consecuencia directa del cuadro de personas usuarias de 11-01, y es el tipo de decisión que solo se toma bien si se ha hecho ese trabajo previo.
- Las pantallas de error
Tres, y cada una responde a una situación distinta:
// src/paginas/PaginaNoEncontrada.jsx — ruta * : la URL no existe
function PaginaNoEncontrada() {
return (
<div className={estilos.centro}>
<h1>Esta página no existe</h1>
<p>Puede que el enlace esté mal escrito o que la página se haya movido.</p>
<Link to="/">Ir al catálogo</Link>
</div>
);
}// src/paginas/PaginaSinPermisos.jsx — la URL existe, pero el rol no basta
function PaginaSinPermisos() {
return (
<div className={estilos.centro}>
<h1>No tienes permiso para ver esta página</h1>
<p>El área de taller está reservada al personal operario.</p>
<Link to="/">Volver al catálogo</Link>
</div>
);
}// src/paginas/PaginaErrorRuta.jsx — errorElement: algo ha fallado al renderizar la ruta
import { useRouteError, isRouteErrorResponse, Link } from 'react-router';
import { registrarError } from '../utilidades/monitorizacion.js';
function PaginaErrorRuta() {
const error = useRouteError();
registrarError(error, { origen: 'errorElement' });
const mensaje = isRouteErrorResponse(error)
? `Error ${error.status}: ${error.statusText}`
: 'Ha ocurrido un problema al mostrar esta pantalla.';
return (
<div className={estilos.centro} role="alert">
<h1>Algo no ha ido bien</h1>
<p>{mensaje}</p>
<Link to="/">Volver al catálogo</Link>
{import.meta.env.DEV && <pre className={estilos.detalle}>{String(error)}</pre>}
</div>
);
}| Pantalla | Cuándo aparece | Quién la activa |
|---|---|---|
PaginaNoEncontrada |
La URL no coincide con ninguna ruta | La ruta * |
PaginaSinPermisos |
Hay sesión, pero el rol no basta | RequiereRol (en 11-03) |
PaginaErrorRuta |
Excepción al renderizar una ruta | errorElement de React Router |
LimiteDeError |
Excepción fuera del enrutador | El límite de main.jsx |
El detalle técnico del error solo se muestra en desarrollo (import.meta.env.DEV). En producción, una traza de pila en pantalla no ayuda al usuario y sí orienta a quien busque agujeros.
- Diseño adaptable con CSS Modules
La rejilla del catálogo, sin un solo cálculo en JavaScript:
/* src/componentes/ListaBicicletas.module.css */
.lista {
list-style: none;
padding: 0;
display: grid;
gap: var(--espacio);
/* Tantas columnas como quepan, con un mínimo de 260px cada una */
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
}Esa única línea sustituye a lo que en otros tiempos eran tres media queries: auto-fill con minmax reparte el espacio disponible sin que nadie tenga que decidir puntos de ruptura. Cuando sí hacen falta, van así:
/* src/componentes/Cabecera.module.css */
.lista {
display: flex;
gap: var(--espacio-s);
list-style: none;
margin: 0;
padding: 0;
}
@media (max-width: 48rem) {
.interior {
flex-wrap: wrap;
}
.navegacion {
order: 3;
width: 100%;
}
.lista {
overflow-x: auto; /* la navegación se desplaza en horizontal */
scrollbar-width: none;
padding-bottom: var(--espacio-xs);
}
}Por qué las media queries van en el CSS y no en JavaScript, aunque el proyecto tenga un hook useAnchoVentana:
CSS (@media) |
JavaScript (useAnchoVentana) |
|
|---|---|---|
| Cuándo se aplica | En el primer pintado | Tras montar y medir: hay un parpadeo |
| Coste | Nulo: lo resuelve el motor de estilos | Un evento de resize y un repintado de React |
| Si el JavaScript falla o tarda | Sigue funcionando | La maquetación queda mal |
| Redimensionar la ventana | Instantáneo | Repinta todo el árbol afectado |
| Cuándo es la opción correcta | Casi siempre | Cuando hay que renderizar componentes distintos, no solo estilarlos |
En CicloUrbano, useAnchoVentana se reserva para un único caso: decidir si el menú de usuario se despliega hacia abajo o abre un panel a pantalla completa, porque son estructuras de DOM distintas y no una diferencia de estilos. Todo lo demás —rejilla, navegación, tipografías, espaciados— es CSS.
Los tres puntos de ruptura del proyecto, y ni uno más:
| Nombre | Ancho | Qué cambia |
|---|---|---|
| Móvil | < 48rem | Una columna, navegación desplazable, botones a ancho completo |
| Tableta | 48rem – 64rem | Dos columnas en el catálogo, navegación en línea |
| Escritorio | > 64rem | Rejilla completa, columnas laterales en la ficha |
- La lista de comprobación de accesibilidad por pantalla
Se aplica a cada pantalla antes de darla por terminada. Ocho puntos, cinco minutos:
| # | Comprobación | Cómo se verifica |
|---|---|---|
| 1 | Un solo <h1> y jerarquía sin saltos |
Extensión de accesibilidad del navegador, vista de encabezados |
| 2 | Se puede completar la tarea solo con el teclado | Guarda el ratón y recorre la pantalla con Tab, Enter y Escape |
| 3 | El foco es visible en todo momento | Mientras tabulas, siempre se ve dónde estás |
| 4 | El orden de tabulación sigue el orden visual | Si salta hacia atrás, el DOM y el CSS discrepan |
| 5 | Todo control tiene nombre accesible | Inspector de accesibilidad: ningún elemento sin nombre |
| 6 | Los errores se anuncian (role="alert") y se asocian (aria-describedby) |
Provoca un error y escucha con un lector de pantalla |
| 7 | El contraste llega a 4.5:1 en texto normal | Herramienta de contraste del navegador, en los dos temas |
| 8 | La información no depende solo del color | Mira la pantalla en escala de grises |
El punto 7 tiene trampa y conviene señalarla: un tema oscuro bien contrastado no se obtiene invirtiendo los colores. --color-marca: #12805c cumple sobre blanco y se queda corto sobre #131a22; por eso el tema oscuro lo sube a #1fa87a. Hay que medir los dos temas por separado.
- Recorrido de revisión de la interfaz terminada
Antes de conectar nada, se recorre la aplicación entera. Este es el guion, con lo que debe verse en cada parada:
| # | Ruta | Qué debe verse |
|---|---|---|
| 1 | / |
Cabecera con cuatro enlaces, «Catálogo» activo con su subrayado. Resumen de flota con 3 disponibles, 1 alquilada y 1 en mantenimiento. Rejilla de cinco tarjetas: bici-002 y bici-003 con el botón apagado y su etiqueta de color |
| 2 | / + filtro «Eléctrica» |
Dos tarjetas. El botón queda marcado como activado |
| 3 | / + buscar «carga» |
Una tarjeta. Borrando el texto, vuelven las cinco |
| 4 | / + filtro «De carga» + buscar «urbana» |
Estado vacío con su explicación, no una rejilla en blanco |
| 5 | /bicicletas/bici-001 |
Ficha con dos paneles, migas «Inicio / Ficha de bicicleta», botón de reserva activo |
| 6 | /bicicletas/bici-003 |
Botón apagado con el motivo escrito debajo |
| 7 | /bicicletas/no-existe |
Mensaje propio de bicicleta inexistente, con enlace de vuelta |
| 8 | /estaciones |
Tres tarjetas con su recuento de bicicletas y disponibles |
| 9 | /estaciones/est-01 |
Pestaña «Flota» activa, dos bicicletas. Al pulsar «Incidencias», la URL cambia y el botón atrás vuelve |
| 10 | /reservas |
Tabla con una fila. Pulsar «Cancelar» abre el modal, Escape lo cierra y el foco vuelve al botón |
| 11 | /reservas/nueva |
Formulario con los cuatro campos etiquetados y el total recalculándose al cambiar las horas |
| 12 | /acceso |
Formulario centrado, campo con autoComplete |
| 13 | /taller |
Sección de mantenimiento primero, resto de la flota después |
| 14 | /ruta-inventada |
PaginaNoEncontrada con la cabecera y el pie en su sitio |
| 15 | Botón de tema | Toda la aplicación cambia; al recargar, el tema se mantiene |
| 16 | Ventana a 375 px | Una columna, navegación desplazable, sin desplazamiento horizontal en el cuerpo |
Y las cinco comprobaciones transversales que cierran la revisión:
- [ ] Con el teclado, de punta a punta: primer
Taben «Saltar al contenido», y desde ahí se llega a todo. - [ ] Sin desplazamiento horizontal a 320 px de ancho en ninguna pantalla.
- [ ] Con el tema oscuro activado, ningún texto pierde contraste.
- [ ] Con la consola abierta, ni un aviso de React (claves duplicadas, props desconocidas, anidamiento inválido).
- [ ]
npm run linten verde, incluidas las reglas dejsx-a11y.
Es en este punto donde se enseña la aplicación a quien la va a usar. Los cambios que salgan de esa conversación —«el precio debería verse antes que la estación», «cancelar debería estar más escondido»— cuestan hoy diez minutos. Después de 11-03 costarían una tarde.
Errores Comunes y Consejos
- Empezar por las pantallas y no por el sistema de diseño. Es la vía rápida a tener seis grises distintos y cuatro tamaños de botón. Media hora en
index.cssy en los cinco componentes base ahorra días de armonización posterior. - Diseñar solo el caso feliz. Si el estado vacío, el de carga y el de error no se maquetan a la vez que el contenido, no se maquetan nunca: aparecen en producción como un hueco en blanco o un texto suelto en la esquina.
<div onClick>en lugar de<button>. No recibe foco, no responde aEnterni a espacio y no se anuncia como control.jsx-a11ylo caza, pero el hábito correcto es usar el elemento nativo y estilarlo.outline: nonepara «que quede más limpio». Deja sin indicador de foco a quien navega con teclado. Si el anillo por defecto no encaja, se sustituye por otro con:focus-visible; nunca se elimina.- Fijar el nivel de encabezado dentro de un componente reutilizable. Un
Panelque siempre pinta<h2>rompe la jerarquía en cuanto se anida. El nivel es una prop. - Media queries simuladas en JavaScript.
useAnchoVentanapara decidir el número de columnas produce parpadeo en la carga y repintados en cadaresize. CSS lo hace mejor, antes y gratis. - Placeholder en lugar de
<label>. El texto desaparece al escribir, muchos lectores de pantalla no lo anuncian como nombre y el contraste suele ser insuficiente.Campofuerza la etiqueta precisamente por esto. - Botón deshabilitado sin explicación. «No disponible» sin decir por qué convierte una limitación comprensible en una frustración.
- Consejo: revisa cada pantalla en escala de grises. Si dejas de distinguir el estado de una bicicleta o el enlace activo, el color está haciendo un trabajo que no puede hacer solo.
- Consejo: deja los interruptores
CARGANDOyCON_ERRORen el código mientras dure esta lección. Poner uno atruees la forma más rápida de revisar un estado que todavía no puede ocurrir, y en 11-03 se sustituyen porisPendingeisErrorsin tocar el marcado.
Ejercicios
Ejercicio 1. ResumenFlota muestra el recuento de bicicletas por estado. Escríbelo completo: recibe bicicletas, calcula cuántas hay en cada estado, y las presenta con una barra proporcional. Requisitos: debe estar memorizado, la información no puede depender solo del color, el recuento debe ser legible para un lector de pantalla, y no puede romperse con una lista vacía. Indica también qué nivel de encabezado usarías y por qué.
Ejercicio 2. La revisión del apartado 19 devuelve tres problemas de la pantalla /reservas:
(a) al cerrar el modal con Escape, el foco se pierde y la siguiente tabulación empieza por la cabecera;
(b) en un móvil de 360 px, la tabla provoca desplazamiento horizontal en toda la página;
(c) el estado de la reserva se distingue solo por el color de la etiqueta.
Diagnostica y corrige cada uno indicando el fichero que tocarías.
Ejercicio 3. El equipo propone sustituir el SelectorTipo de cuatro botones por un <select> desplegable «porque ocupa menos». Evalúa la propuesta desde tres ángulos —usabilidad, accesibilidad y lo que 11-03 hará con el filtro— y da una recomendación razonada. Si la aceptas en algún caso, escribe el código de la variante y explica en qué condiciones se usaría cada una.
Soluciones
Solución 1.
// src/componentes/ResumenFlota.jsx
import { memo, useMemo } from 'react';
import estilos from './ResumenFlota.module.css';
const ESTADOS = [
{ clave: 'disponible', texto: 'Disponibles', simbolo: '●' },
{ clave: 'alquilada', texto: 'Alquiladas', simbolo: '▲' },
{ clave: 'mantenimiento', texto: 'En mantenimiento', simbolo: '■' }
];
function ResumenFlota({ bicicletas }) {
const recuento = useMemo(() => {
return bicicletas.reduce(
(acumulado, bici) => ({ ...acumulado, [bici.estado]: (acumulado[bici.estado] ?? 0) + 1 }),
{ disponible: 0, alquilada: 0, mantenimiento: 0 }
);
}, [bicicletas]);
const total = bicicletas.length;
// Estado vacío: sin esto habría una división por cero en el porcentaje
if (total === 0) {
return (
<section className={estilos.resumen} aria-labelledby="titulo-resumen">
<h2 id="titulo-resumen" className={estilos.titulo}>Estado de la flota</h2>
<p>No hay bicicletas registradas.</p>
</section>
);
}
return (
<section className={estilos.resumen} aria-labelledby="titulo-resumen">
<h2 id="titulo-resumen" className={estilos.titulo}>Estado de la flota</h2>
<ul className={estilos.lista}>
{ESTADOS.map(({ clave, texto, simbolo }) => {
const cantidad = recuento[clave];
const porcentaje = Math.round((cantidad / total) * 100);
return (
<li key={clave} className={estilos.item}>
<span className={estilos.encabezado}>
{/* Símbolo: segunda señal además del color */}
<span className={estilos[clave]} aria-hidden="true">{simbolo}</span>
<span className={estilos.texto}>{texto}</span>
<span className={estilos.cantidad}>
{cantidad}
<span className="visualmente-oculto"> de {total} bicicletas</span>
</span>
</span>
{/* La barra es puramente visual: el dato ya está en el texto */}
<div className={estilos.pista} aria-hidden="true">
<div
className={`${estilos.barra} ${estilos[clave]}`}
style={{ inlineSize: `${porcentaje}%` }}
/>
</div>
</li>
);
})}
</ul>
</section>
);
}
export default memo(ResumenFlota);Las decisiones y su justificación:
| Requisito | Cómo se cumple |
|---|---|
| Memorizado | memo en la exportación y useMemo en el recuento. bicicletas llega como referencia estable desde el catálogo, así que memo funciona de verdad (08-02) |
| No solo color | Cada estado lleva símbolo distinto (●, ▲, ■) además de su color, y el nombre escrito |
| Legible por lector de pantalla | El dato está en texto (3 de 5 bicicletas), la barra es aria-hidden y el número lleva su contexto en un texto oculto visualmente |
| Sin romperse con lista vacía | Retorno anticipado con total === 0, que además evita NaN% en la barra |
| Nivel de encabezado | h2: el h1 es «Catálogo de bicicletas» y este es una sección de primer nivel dentro de la pantalla. Además aria-labelledby conecta la <section> con su título, para que aparezca nombrada en la lista de regiones |
Un detalle que suele pasarse por alto: el porcentaje se redondea para pintar, pero nunca sustituye al número. «60 %» no dice cuántas bicicletas son; «3 de 5» sí.
Solución 2.
(a) El foco perdido al cerrar el modal. El diagnóstico es que Modal guarda document.activeElement en el efecto que se ejecuta cuando abierto pasa a true, pero el componente ya está devolviendo null antes de eso si se monta cerrado… y sobre todo, el elemento que abrió el modal puede haberse desmontado. En PaginaReservas ocurre lo segundo de forma sutil: al cerrar, se llama a setReservaACancelar(null) y la fila se vuelve a renderizar, con lo que elementoPrevio.current puede apuntar a un nodo que ya no está en el documento. Enfocar un nodo desconectado es una operación silenciosa: no falla, simplemente no hace nada, y el foco cae al <body>.
La corrección, en src/componentes/base/Modal.jsx, con una comprobación de conexión y un destino de respaldo:
useEffect(() => {
if (!abierto) return;
elementoPrevio.current = document.activeElement;
contenedor.current?.focus();
const overflowPrevio = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = overflowPrevio;
const previo = elementoPrevio.current;
// Solo si sigue existiendo Y está conectado al documento
if (previo instanceof HTMLElement && previo.isConnected) {
previo.focus();
} else {
// Respaldo: el contenido principal, que es enfocable por tabIndex={-1}
document.getElementById('contenido')?.focus();
}
};
}, [abierto]);El respaldo al main no es un parche: es la conducta correcta cuando el origen del foco ha desaparecido, porque deja a la persona al principio del contenido y no en el limbo del <body>.
(b) Desplazamiento horizontal por la tabla. El error de diagnóstico habitual aquí es tocar el body. El problema no es la página: es que la tabla es más ancha que su contenedor y arrastra a todo lo demás. La regla es que el contenido ancho se desplaza dentro de su propia caja, nunca la página entera. En src/componentes/PanelReservas.module.css:
.contenedorTabla {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.tabla {
width: 100%;
min-width: 34rem; /* por debajo, las columnas se apelmazan: mejor desplazar */
border-collapse: collapse;
}
/* Y en móvil, columnas menos críticas fuera */
@media (max-width: 30rem) {
.columnaHoras {
display: none;
}
}// PanelReservas.jsx
<div className={estilos.contenedorTabla}>
<table className={estilos.tabla}>…</table>
</div>Ocultar la columna «Horas» en pantallas muy pequeñas es aceptable porque el dato sigue estando en el detalle de la reserva; si fuera la única forma de conocerlo, esconderlo sería perder información, no adaptarla.
(c) El estado solo por color. En src/componentes/base/Etiqueta.module.css y en el componente: la etiqueta ya lleva el texto («Disponible», «Alquilada»), así que si el problema persiste es porque en la tabla se redujo a un punto de color. La corrección es mantener siempre el texto y añadir una segunda señal de forma:
.etiqueta {
display: inline-flex;
align-items: center;
gap: var(--espacio-xs);
padding: 0.15rem var(--espacio-s);
border-radius: 999px;
font-size: var(--texto-s);
font-weight: 600;
border: 1px solid currentColor; /* el borde da forma además de color */
}
.disponible { color: var(--color-marca); background: color-mix(in srgb, var(--color-marca) 12%, transparent); }
.alquilada { color: var(--color-alquilada); background: color-mix(in srgb, var(--color-alquilada) 12%, transparent); }
.mantenimiento { color: var(--color-mantenimiento); background: color-mix(in srgb, var(--color-mantenimiento) 12%, transparent); }La prueba definitiva es la del apartado 18: poner la pantalla en escala de grises. Si con los colores anulados sigues distinguiendo los tres estados —porque el texto los nombra—, está bien resuelto.
Solución 3.
Recomendación: mantener el grupo de botones en el catálogo. El argumento tiene tres partes.
Usabilidad. El criterio establecido para elegir entre botones y desplegable es el número de opciones y la frecuencia de cambio:
| Grupo de botones | <select> |
|
|---|---|---|
| Opciones visibles sin interactuar | Todas | Solo la elegida |
| Clics para cambiar de filtro | 1 | 2 (abrir y elegir) |
| Descubrimiento de lo que se puede filtrar | Inmediato | Hay que abrirlo |
| Espacio ocupado | Más | Menos |
| Recomendado a partir de | ≤ 5-6 opciones | > 6-7 opciones |
Con cuatro opciones y siendo el control más usado de la pantalla más visitada, el desplegable empeora la tarea principal para ahorrar un espacio que en escritorio sobra.
Accesibilidad. Aquí no hay un ganador claro, y conviene ser honesto: un <select> nativo con su <label> es perfectamente accesible y en móvil abre el selector del sistema, que es una experiencia excelente. El grupo de botones también lo es si lleva role="group", aria-label y aria-pressed, como el del apartado 10. La diferencia real está en otro sitio: el desplegable anuncia solo la opción actual, mientras que los botones anuncian el estado de todas, lo que ayuda a construir el modelo mental de qué filtros existen.
Lo que hará 11-03 con el filtro. Este es el argumento que decide. El filtro se muda a la URL (?tipo=electrica) para ser compartible. Con botones, cada opción puede además renderizarse como un enlace real si algún día interesa —con su URL en la barra de estado y su «abrir en pestaña nueva»—; con un <select> eso es imposible sin JavaScript. El grupo de botones encaja mejor con la decisión A6 del acta.
Dónde sí lo aceptaría, y el código. En el formulario de nueva reserva y en cualquier pantalla estrecha con más de seis opciones. Y en el propio catálogo, si algún día los tipos pasan de cuatro a doce, la solución no es el desplegable sino un componente que se adapte:
// src/componentes/SelectorTipo.jsx — variante adaptable
function SelectorTipo({ tipoElegido, alCambiarTipo, variante = 'auto' }) {
const [pendiente, iniciarTransicion] = useTransition();
const cambiar = (valor) => iniciarTransicion(() => alCambiarTipo(valor));
if (variante === 'desplegable' || TIPOS.length > 6) {
return (
<Campo etiqueta="Tipo de bicicleta">
{(props) => (
<select {...props} value={tipoElegido} onChange={(e) => cambiar(e.target.value)}>
{TIPOS.map((tipo) => (
<option key={tipo.valor} value={tipo.valor}>{tipo.texto}</option>
))}
</select>
)}
</Campo>
);
}
return (
<div className={clases(estilos.grupo, pendiente && estilos.pendiente)}
role="group" aria-label="Filtrar por tipo de bicicleta">
{TIPOS.map((tipo) => (
<button key={tipo.valor} type="button"
className={clases(estilos.boton, tipoElegido === tipo.valor && estilos.activo)}
aria-pressed={tipoElegido === tipo.valor}
onClick={() => cambiar(tipo.valor)}>
{tipo.texto}
</button>
))}
</div>
);
}La condición que hace válida esta variante es que las dos formas comparten exactamente el mismo contrato de props (tipoElegido y alCambiarTipo). Quien lo usa no se entera de cuál se pinta, y las pruebas de 11-04 pueden interactuar con la que corresponda sin que la página cambie una línea. Si la variante obligara a cambiar el contrato, serían dos componentes distintos y habría que elegir uno.
Conclusión
CicloUrbano ya existe. Se puede abrir, recorrer, enseñar y criticar, y todavía no ha hecho ni una petición: exactamente el punto que se buscaba.
Lo primero que queda montado es el sistema de diseño, y es lo que sostiene todo lo demás: un index.css con reinicio, una escala de espaciado cerrada, variables de color y forma, :focus-visible definido una sola vez para que ninguna parte de la aplicación pueda quedarse sin indicador de foco, y prefers-reduced-motion respetado desde el primer día. El tema oscuro se resuelve redefiniendo variables bajo :root[data-tema='oscuro'], sin tocar una regla de componente, con el recordatorio de que un tema oscuro no es un tema claro invertido: los colores de marca hay que volver a medirlos sobre el fondo nuevo.
Encima viven los cinco componentes base, cuyo valor está en que existan y se usen. Boton invierte el valor por defecto de type para eliminar de raíz los envíos accidentales y une el estado visual al funcional en cargando. Campo usa hijos como función para generar el id con useId y cablear htmlFor, aria-invalid y aria-describedby en un solo sitio, de forma que escribir un campo accesible sea más fácil que escribir uno que no lo sea. Panel recibe el nivel de encabezado como prop, porque un componente no puede saber a qué profundidad lo van a colocar. Etiqueta nombra el estado además de colorearlo. Y Modal cumple las cuatro obligaciones de un diálogo: portal, aria-modal, cierre con Escape y devolución del foco al origen.
El marco aporta el enlace de salto como primer elemento enfocable, los puntos de referencia bien separados, el main enfocable por programa, la navegación con NavLink y su end en el catálogo, el estado activo señalado con algo más que color, y unas migas de pan que leen el handle de cada ruta y marcan la actual con aria-current.
De las ocho pantallas, lo que hay que llevarse no es el marcado sino el método: cada una se ha escrito con sus cuatro estados —cargando con EsqueletoPagina marcado como aria-hidden y su role="status" paralelo, vacío con explicación y salida, error con mensaje comprensible y reintento, y con datos—; las tablas son tablas de verdad con scope y caption; los botones deshabilitados dicen por qué lo están; las pestañas de estación son rutas anidadas y no estado local, para que sean enlazables y sobrevivan al botón atrás; y el orden del contenido responde a lo que cada persona usuaria necesita ver primero, que es una decisión tomada en 11-01 y cobrada aquí.
En adaptabilidad, la regla queda fijada: las media queries van en el CSS porque se aplican en el primer pintado, no cuestan nada y funcionan aunque el JavaScript tarde; useAnchoVentana se reserva para cuando hay que renderizar estructuras distintas, no estilos distintos. Y grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)) sustituye a tres puntos de ruptura con una línea.
Cierra la lección la lista de comprobación de accesibilidad de ocho puntos que se aplica pantalla a pantalla, y el recorrido de revisión de dieciséis paradas que se hace antes de conectar nada: es el momento más barato de todo el proyecto para cambiar de opinión.
Ahora la aplicación es una maqueta preciosa que no hace nada. Los datos son un fichero, los filtros solo viven en memoria, el formulario no envía, la sesión no existe y RutaProtegida deja pasar a todo el mundo. Gestión del Estado e Integración de API le da vida: la capa de acceso a datos que no sabe nada de React, TanStack Query con sus claves jerárquicas y sus valores por defecto justificados, la mutación completa de crear una reserva con validación, invalidación y redirección, la actualización optimista de confirmar y cancelar con su reversión, Redux para sesión y catálogo, el filtro mudándose a la URL, las rutas protegidas conectadas de verdad y una tabla de decisión que responde a la pregunta más difícil de todas: cuando algo falla, quién lo muestra.
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
