El módulo anterior cerró el recorrido técnico y terminó con una promesa: construir CicloUrbano entero, de principio a fin, con Vite y React Router. Esta lección es el primer paso, y es el que más gente se salta: antes de escribir una línea de JSX hay que saber qué se construye, para quién, con qué datos y con qué decisiones técnicas. Un proyecto que empieza por npm create vite sin haber respondido a esas preguntas acaba, sin excepción, refactorizándose a mitad de camino.

Vas a hacer tres cosas, en este orden. Primero definir el producto: el alcance en una frase, las dos personas usuarias, las historias con sus criterios de aceptación y —tan importante como lo anterior— la lista explícita de lo que no entra en la primera versión. Segundo, fijar las decisiones de arquitectura en un acta que justifique cada elección y nombre la alternativa descartada, para que dentro de tres meses nadie tenga que reconstruir el razonamiento. Y tercero, montar el andamiaje real: Vite, dependencias, scripts, linter, formateador, variables de entorno, estructura de carpetas, json-server sembrado y un primer commit que ya arranca.

Al terminar tendrás un repositorio que se clona, se instala y se ejecuta, con la API de desarrollo respondiendo y sin una sola pantalla escrita. Las pantallas son la lección siguiente.

Contenido

  1. El producto en una frase
  2. Personas usuarias y sus objetivos
  3. Historias de usuario y criterios de aceptación
  4. Lo que queda fuera de la primera versión, y por qué
  5. Modelo de datos definitivo
  6. El db.json inicial completo
  7. Mapa de pantallas y árbol de rutas
  8. El acta de decisiones de arquitectura
  9. Crear el proyecto con Vite
  10. Dependencias: qué se instala y por qué
  11. El package.json completo
  12. Calidad del código: ESLint, Prettier y .editorconfig
  13. Variables de entorno con import.meta.env
  14. Estructura de carpetas: por tipo o por funcionalidad
  15. La API de desarrollo: json-server en marcha
  16. Convenciones del equipo
  17. El plan de trabajo: incrementos verticales
  18. El primer commit

  1. El producto en una frase

Si no puedes describir lo que construyes en una frase, todavía no sabes lo que construyes. La de este proyecto:

CicloUrbano es una aplicación web privada que permite a una persona cliente reservar bicicletas de una red urbana por estaciones, y a una persona operaria mantener el estado de la flota.

Esa frase carga más información de la que parece:

Fragmento Qué decide
«aplicación web» No es móvil (aunque 10-05 dejó la puerta abierta)
«privada» Todo lo interesante ocurre tras identificarse: no hay contenido público que indexar
«reservar bicicletas» El camino del dinero del producto. Todo lo demás lo sirve
«por estaciones» Las estaciones son un eje de navegación, no un adorno
«mantener el estado de la flota» Hay un segundo rol con permisos distintos

La palabra «privada» es la que decide la arquitectura de renderizado, y lo hace antes de que se discuta ninguna herramienta. Volveremos a ella en el apartado 8.

  1. Personas usuarias y sus objetivos

Dos personas, ni una más. Un producto con cinco perfiles en la primera versión no tiene ninguno bien resuelto.

Cliente Operario
Ejemplo del dominio Ana Ribera (usr-01) Marc Solé (usr-02)
Objetivo principal Conseguir una bicicleta cuando la necesita Que la flota esté operativa
Frecuencia de uso Ráfagas cortas, varias veces por semana Sesiones largas, a diario
Contexto Móvil, en la calle, con prisa Escritorio, en el taller
Qué le frustra No saber si la bicicleta estará disponible al llegar No saber qué bicicleta lleva días parada
Qué necesita ver primero Bicicletas disponibles cerca Bicicletas en mantenimiento
Permisos Ver el catálogo, reservar, cancelar lo suyo Todo lo anterior más cambiar el estado de una bicicleta

De este cuadro salen dos consecuencias de diseño que ya no se discuten después:

  • La pantalla de inicio es el catálogo, no un panel de control. El cliente es quien más entra, y entra con prisa.
  • El taller es una pantalla aparte con acceso por rol, no un modo oculto del catálogo. Mezclar los dos usos en una pantalla obligaría a esconder controles por permiso en cada tarjeta, que es exactamente la clase de complejidad que se paga durante años.

  1. Historias de usuario y criterios de aceptación

Una historia de usuario tiene una forma fija —como X quiero Y para Z— y solo sirve si lleva criterios de aceptación comprobables. «Que el catálogo funcione bien» no es un criterio; «al filtrar por eléctrica solo aparecen las de tipo electrica» sí lo es. En 11-04 cada uno de estos criterios se convertirá en una prueba, y por eso están redactados así desde hoy.

Imprescindibles

ID Historia Criterios de aceptación
H1 Como cliente quiero ver el catálogo de bicicletas para saber qué hay disponible Se listan las 5 bicicletas con modelo, tipo, estado, estación y precio/hora · Mientras cargan se ve un esqueleto, no un texto · Si la API falla, hay mensaje de error y botón de reintento
H2 Como cliente quiero filtrar por tipo y buscar por modelo para encontrar rápido lo que necesito El filtro vive en la URL (?tipo=electrica) y es compartible por enlace · La búsqueda por texto es local y con retardo · Filtro y búsqueda se combinan
H3 Como cliente quiero ver la ficha de una bicicleta para decidir si la reservo Ruta /bicicletas/:bicicletaId · Muestra estación, precio y estado · Un identificador inexistente da un 404 propio, no una pantalla en blanco
H4 Como persona usuaria quiero identificarme para acceder a lo mío Formulario con validación accesible · Al entrar se vuelve a la pantalla de origen, no al inicio · La sesión sobrevive a una recarga
H5 Como cliente quiero reservar una bicicleta disponible para usarla Formulario con bicicleta, inicio, horas y condiciones · No se puede reservar una que no esté disponible · Al crearla, aviso de éxito y redirección a /reservas · El catálogo refleja el cambio
H6 Como cliente quiero ver y cancelar mis reservas para gestionar mis planes /reservas lista solo las del usuario identificado · Cancelar pide confirmación · El cambio se ve al instante y se confirma contra el servidor
H7 Como operario quiero cambiar el estado de una bicicleta para retirarla o devolverla al servicio /taller solo accesible con rol operario · Un cliente que entre por URL ve «sin permisos» · El cambio se refleja en el catálogo público
H8 Como persona usuaria quiero ver las estaciones y su flota para orientarme por la ciudad /estaciones con las 3 estaciones · /estaciones/:estacionId con pestañas de flota e incidencias · La pestaña activa está en la URL

Deseables

ID Historia Por qué es deseable y no imprescindible
D1 Tema claro/oscuro con preferencia recordada Mejora real, pero nadie deja de reservar por no tenerlo
D2 Avisos temporales de éxito y error en toda la aplicación Se puede empezar con mensajes en línea; los avisos globales pulen la experiencia
D3 Aviso al perder la conexión Solo importa en el móvil en la calle; el caso se degrada de forma aceptable sin él
D4 Resumen de flota por estado en el catálogo Informativo. Útil para el operario, prescindible para el cliente

La distinción no es cosmética: si el plazo se estrecha, se corta por D1-D4 sin tocar H1-H8. Tener esa línea trazada antes de empezar es lo que impide que un proyecto entregue diez cosas a medias en lugar de ocho terminadas.

  1. Lo que queda fuera de la primera versión, y por qué

Esta lista es tan importante como la anterior, y conviene escribirla y compartirla, porque lo que no está escrito se acaba pidiendo a mitad de proyecto.

Fuera de alcance Motivo
Pagos reales Requiere pasarela, cumplimiento normativo y auditoría. El precio se muestra y se calcula; no se cobra
Registro de usuarios y recuperación de contraseña Los usuarios se siembran en db.json. La identificación real es del servidor (11-05)
Mapa geográfico de estaciones Añade una biblioteca pesada y una clave de servicio. Se listan por barrio
Notificaciones push Exige servidor, permisos y service worker. Fuera
Panel de estadísticas Nadie de las dos personas usuarias lo ha pedido para su objetivo principal
Internacionalización La aplicación es de una ciudad y un idioma. Se anota como hoja de ruta en 11-05
Modo sin conexión Complejidad alta, valor incierto hasta tener usuarios reales
Escaparate público con SEO Es contenido público: si algún día se hace, se hace con Next.js (10-02), como proyecto aparte

Fíjate en la última fila. Decidir que algo está fuera no es decidir que nunca se hará: es decidir que no compite por el tiempo de esta versión, y de paso dejar anotado cómo se haría si llega el momento.

  1. Modelo de datos definitivo

Cuatro entidades. Las conoces de todo el curso; aquí quedan fijadas con sus relaciones.

erDiagram
    ESTACION ||--o{ BICICLETA : "alberga"
    BICICLETA ||--o{ RESERVA : "es objeto de"
    USUARIO ||--o{ RESERVA : "realiza"

    ESTACION {
        string id PK "est-01"
        string nombre "Plaza Mayor"
        string barrio "Centro"
        number plazas "20"
    }
    BICICLETA {
        string id PK "bici-001"
        string modelo "Urbana Clasica"
        string tipo "urbana | electrica | carga"
        string estado "disponible | alquilada | mantenimiento"
        string estacionId FK "est-01"
        number precioHora "2.5"
    }
    USUARIO {
        string id PK "usr-01"
        string nombre "Ana Ribera"
        string email "[email protected]"
        string rol "cliente | operario"
    }
    RESERVA {
        string id PK "res-01"
        string bicicletaId FK "bici-002"
        string usuario FK "usr-01"
        string fechaInicio "2026-05-04T09:00"
        number horas "2"
        string estado "activa | confirmada | cancelada"
    }

Tres decisiones del modelo que conviene entender antes de codificar:

  1. Las relaciones se guardan por identificador, nunca anidando el objeto entero. Una reserva guarda bicicletaId, no una copia de la bicicleta. Si se anidara, el precio de la reserva quedaría congelado en el momento de crearla y cualquier cambio en la bicicleta dejaría copias desincronizadas por toda la base de datos. Es la misma normalización que se aplicó al estado de Redux en 07-04, y por el mismo motivo.
  2. El estado es una cadena de un conjunto cerrado, no un booleano. disponible | alquilada | mantenimiento admite un cuarto estado el día que haga falta; disponible: true/false obliga a añadir otro booleano y a razonar sobre combinaciones imposibles.
  3. El campo de la reserva se llama usuario y contiene un identificador. Es una incoherencia de nombre respecto a bicicletaId, y se conserva porque así se fijó en todo el curso: cambiarlo ahora rompería validarReserva, las pruebas y los manejadores de MSW. Se anota como deuda técnica menor en el acta, que es exactamente lo que se hace en un proyecto real con una incoherencia inofensiva ya extendida.

  1. El db.json inicial completo

Este fichero es la base de datos de desarrollo y, a la vez, la semilla que 11-04 restaurará antes de cada prueba de extremo a extremo. Va en la raíz del repositorio.

{
  "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 }
  ],
  "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 }
  ],
  "usuarios": [
    { "id": "usr-01", "nombre": "Ana Ribera", "email": "[email protected]", "rol": "cliente" },
    { "id": "usr-02", "nombre": "Marc Solé", "email": "[email protected]", "rol": "operario" }
  ],
  "reservas": [
    { "id": "res-01", "bicicletaId": "bici-002", "usuario": "usr-01", "fechaInicio": "2026-05-04T09:00", "horas": 2, "estado": "activa" }
  ]
}

El conjunto está elegido para que cada estado y cada caso límite tenga al menos un representante, que es la propiedad que debe cumplir cualquier juego de datos de desarrollo:

Caso que hay que poder probar Dato que lo cubre
Bicicleta reservable bici-001, bici-004, bici-005
Bicicleta no reservable por estar alquilada bici-002
Bicicleta no reservable por mantenimiento bici-003
Dos bicicletas con el mismo modelo (claves de lista) bici-001 y bici-004
Estación con varias bicicletas est-01 y est-02
Estación sin bicicletas disponibles est-01 tiene una alquilada; est-02, una en mantenimiento
Usuario con reservas usr-01
Usuario sin ninguna reserva (estado vacío) usr-02
Los tres tipos de bicicleta urbana, eléctrica y de carga

Ese penúltimo caso es el que más se olvida y el que más fallos produce: si nunca ves la lista vacía en desarrollo, no la diseñas, y el usuario que estrena la aplicación se encuentra un hueco en blanco.

  1. Mapa de pantallas y árbol de rutas

Las rutas quedaron fijadas en el módulo 6. Aquí se comprueba que cada una sirve a una historia y que ninguna historia se queda sin pantalla.

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

Y la tabla de cobertura, que es la que se revisa para detectar huecos:

Ruta Pantalla Historia Acceso
/ PaginaCatalogo H1, H2 Público
/bicicletas/:bicicletaId PaginaFichaBicicleta H3 Público
/estaciones PaginaEstaciones H8 Público
/estaciones/:estacionId PaginaDetalleEstacion (+ pestañas) H8 Público
/acceso PaginaAcceso H4 Público
/reservas PaginaReservas H6 Identificado
/reservas/nueva PaginaNuevaReserva H5 Identificado
/taller PaginaTaller H7 Rol operario
* PaginaNoEncontrada Público
(error de ruta) PaginaErrorRuta vía errorElement
(sin permisos) PaginaSinPermisos H7

Un detalle que cambia respecto al módulo 6: /reservas entra dentro de la rama protegida. En su momento se dejó pública para no complicar los ejemplos; en el proyecto real, ver reservas exige sesión, porque son datos personales. Es el tipo de ajuste que aparece justo al hacer esta tabla, y por eso se hace la tabla.

  1. El acta de decisiones de arquitectura

Este es el documento más valioso de la lección. Cada fila registra qué se decide, por qué, y qué se descarta. Se guarda en el repositorio como DECISIONES.md y se actualiza cuando algo cambia; nunca se borra una fila, se añade la nueva con su fecha.

# Decisión Por qué Alternativa descartada y motivo
A1 Vite + React Router, aplicación de cliente La aplicación es privada, interactiva y tras identificación: no hay SEO que ganar y el primer pintado no es un factor de negocio. Arranque instantáneo del servidor de desarrollo y despliegue como estáticos Next.js: aporta SSR/SSG que aquí no se aprovechan, y añade un servidor que mantener. Criterio aplicado en 10-02
A2 React Router v7 en modo de datos (createBrowserRouter + RouterProvider) Rutas anidadas, errorElement por rama y carga perezosa declarativa BrowserRouter con <Routes>: sin errorElement ni API de datos. Descartado en 06-01
A3 TanStack Query para el estado del servidor Caché, deduplicación, revalidación, estados de carga y error e invalidación tras mutar: todo lo que habría que escribir a mano Redux para todo: obliga a reimplementar caché y ciclo de vida (07-06). useEffect + fetch: sin caché ni deduplicación
A4 Redux Toolkit para el estado del cliente compartido (sesión, catálogo) Selección granular, DevTools con viaje en el tiempo, reductores puros fáciles de probar Contexto para todo: repinta todos los consumidores ante cualquier cambio (07-02)
A5 Contexto para tema y avisos Cambian poco y los necesita todo el árbol. Es exactamente su caso de uso Redux: válido, pero añade ceremonia a dos datos triviales
A6 La URL para el filtro por tipo Un catálogo filtrado debe poder compartirse por enlace y sobrevivir a una recarga Estado local o Redux: se pierde al recargar y no es compartible
A7 CSS Modules Ámbito local sin dependencias, sin coste en ejecución, soportado por Vite de serie Tailwind: excelente, pero añade configuración y una curva propia. CSS-in-JS: coste en ejecución y fricción con RSC (10-03)
A8 Vitest + Testing Library + MSW + Cypress Vitest comparte configuración con Vite; el trofeo de pruebas de 09-01 aplicado tal cual Jest: exige transformador propio y duplicar la configuración. Sin e2e: deja fuera enrutado real, CSS y persistencia
A9 JavaScript, con migración a TypeScript prevista El equipo lo domina y la primera versión debe entregarse. La estructura ya es compatible: tipos en un fichero, validación centralizada TypeScript desde el día uno: mejor a medio plazo, pero frena el arranque. Se planifica en 11-05 con lo visto en 10-04
A10 json-server solo en desarrollo Da una API REST completa sobre un JSON en un minuto API propia: fuera de alcance. Se documenta en 11-05 qué haría falta de verdad

Y la asignación de estado, que es la aplicación literal de la taxonomía de 07-01 a este proyecto:

Tipo de estado Ejemplo en CicloUrbano Herramienta Por qué
Del servidor Bicicletas, estaciones, reservas, usuarios TanStack Query Es una copia local de algo remoto; necesita caché y revalidación
De cliente compartido Usuario identificado, término de búsqueda, orden Redux Toolkit Varios componentes lejanos lo leen y lo escriben
De interfaz global Tema, avisos Contexto Poco cambio, muchos consumidores
De URL ?tipo=electrica, pestaña activa React Router Debe ser compartible y navegable
Local Modal abierto, borrador del formulario useState Nadie más lo necesita

  1. Crear el proyecto con Vite

Con el acta firmada, el andamiaje es mecánico.

npm create vite@latest ciclourbano -- --template react
cd ciclourbano
npm install
npm run dev

Qué ha hecho cada línea:

  • npm create vite@latest descarga y ejecuta el generador oficial. El -- separa los argumentos de npm de los del generador; sin él, npm intentaría interpretar --template como suyo.
  • --template react elige la plantilla de React con JavaScript. Existe react-ts para TypeScript y react-swc para usar SWC en lugar de Babel; con React 19 y el complemento oficial, la diferencia de velocidad en un proyecto de este tamaño es irrelevante.
  • npm run dev levanta el servidor de desarrollo en http://localhost:5173.

Comprueba la versión de React antes de seguir, porque el proyecto asume React 19:

npm ls react
# [email protected]
# └── [email protected]

  1. Dependencias: qué se instala y por qué

Nada se instala «porque sí». Cada paquete responde a una fila del acta.

# Producción
npm install react-router @reduxjs/toolkit react-redux @tanstack/react-query

# Desarrollo
npm install -D @tanstack/react-query-devtools
npm install -D vitest jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event
npm install -D msw cypress start-server-and-test
npm install -D eslint-plugin-jsx-a11y prettier eslint-config-prettier
npm install -D json-server npm-run-all
Paquete Dónde Para qué Decisión
react-router Producción Enrutado en el cliente (v7, paquete único) A2
@reduxjs/toolkit Producción createSlice, configureStore, Immer incluido A4
react-redux Producción useSelector, useDispatch, Provider A4
@tanstack/react-query Producción Estado del servidor con caché A3
@tanstack/react-query-devtools Desarrollo Inspector de la caché. No entra en el paquete de producción A3
vitest + jsdom Desarrollo Ejecutor de pruebas y DOM simulado A8
@testing-library/* Desarrollo Renderizado, aserciones e interacción realista A8
msw Desarrollo Simulación de la red a nivel de petición A8
cypress Desarrollo Pruebas de extremo a extremo en navegador real A8
start-server-and-test Desarrollo Espera a que Vite y la API respondan antes de lanzar Cypress A8
eslint-plugin-jsx-a11y Desarrollo Reglas de accesibilidad en el linter Accesibilidad como requisito
prettier + eslint-config-prettier Desarrollo Formateo automático sin pelearse con ESLint Convenciones
json-server Desarrollo API REST de desarrollo A10
npm-run-all Desarrollo Ejecutar Vite y la API en paralelo con un solo comando Comodidad

La distinción entre dependencies y devDependencies no es burocracia: lo que está en dependencies puede acabar en el paquete que descarga el usuario. Poner json-server o Cypress ahí no rompería el build de Vite, pero sí engorda cualquier instalación de producción y da una señal falsa sobre lo que la aplicación necesita para funcionar.

  1. El package.json completo

{
  "name": "ciclourbano",
  "private": true,
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview",
    "api": "json-server --watch db.json --port 3001",
    "dev:todo": "npm-run-all --parallel dev api",
    "lint": "eslint . --max-warnings 0",
    "formato": "prettier --write \"src/**/*.{js,jsx,css,json}\"",
    "formato:comprobar": "prettier --check \"src/**/*.{js,jsx,css,json}\"",
    "test": "vitest",
    "test:ejecutar": "vitest run",
    "cobertura": "vitest run --coverage",
    "cy:abrir": "cypress open",
    "cy:ejecutar": "cypress run",
    "e2e": "start-server-and-test dev:todo \"http://localhost:5173|http://localhost:3001/bicicletas\" cy:ejecutar",
    "pruebas:todas": "npm-run-all lint test:ejecutar e2e"
  },
  "dependencies": {
    "@reduxjs/toolkit": "^2.5.0",
    "@tanstack/react-query": "^5.62.0",
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "react-redux": "^9.2.0",
    "react-router": "^7.1.0"
  },
  "devDependencies": {
    "@tanstack/react-query-devtools": "^5.62.0",
    "@testing-library/jest-dom": "^6.6.0",
    "@testing-library/react": "^16.1.0",
    "@testing-library/user-event": "^14.5.0",
    "@vitejs/plugin-react": "^4.3.0",
    "@vitest/coverage-v8": "^2.1.0",
    "cypress": "^13.17.0",
    "eslint": "^9.17.0",
    "eslint-config-prettier": "^9.1.0",
    "eslint-plugin-jsx-a11y": "^6.10.0",
    "eslint-plugin-react-hooks": "^5.1.0",
    "eslint-plugin-react-refresh": "^0.4.0",
    "jsdom": "^25.0.0",
    "json-server": "^0.17.4",
    "msw": "^2.7.0",
    "npm-run-all": "^4.1.5",
    "prettier": "^3.4.0",
    "start-server-and-test": "^2.0.0",
    "vite": "^6.0.0",
    "vitest": "^2.1.0"
  }
}

Los scripts merecen un comentario, porque son la interfaz del proyecto para cualquiera que llegue nuevo:

Script Qué hace Cuándo se usa
dev Servidor de Vite en el 5173 A diario
api json-server en el 3001 vigilando db.json A diario, en otra terminal
dev:todo Los dos en paralelo El comando de trabajo habitual
build Construye dist/ para producción Antes de desplegar (11-05)
preview Sirve dist/ para comprobarla Después de build
lint ESLint con cero avisos tolerados En cada commit y en CI
formato Prettier reescribe los ficheros Al guardar o antes de commit
formato:comprobar Prettier solo comprueba, sin escribir En CI
test Vitest en modo vigilancia Mientras se programa
test:ejecutar Vitest una vez y sale En CI
cobertura Informe de cobertura Revisiones periódicas
e2e Levanta todo, espera y lanza Cypress En CI y antes de una entrega
pruebas:todas Linter + unitarias + e2e, en ese orden La puerta de calidad completa

El --max-warnings 0 de lint es deliberado: un aviso que no rompe nada se acumula hasta que hay ciento veinte y nadie los mira. O importan y fallan, o se desactiva la regla.

  1. Calidad del código: ESLint, Prettier y .editorconfig

Vite genera un eslint.config.js básico. Este es el del proyecto, con las dos adiciones que exige el acta: accesibilidad y reglas de hooks.

// eslint.config.js
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import jsxA11y from 'eslint-plugin-jsx-a11y';
import prettier from 'eslint-config-prettier';

export default [
  { ignores: ['dist', 'coverage', 'cypress/videos', 'cypress/screenshots'] },
  {
    files: ['**/*.{js,jsx}'],
    languageOptions: {
      ecmaVersion: 2022,
      globals: globals.browser,
      parserOptions: {
        ecmaFeatures: { jsx: true },
        sourceType: 'module'
      }
    },
    plugins: {
      'react-hooks': reactHooks,
      'react-refresh': reactRefresh,
      'jsx-a11y': jsxA11y
    },
    rules: {
      ...js.configs.recommended.rules,
      ...reactHooks.configs.recommended.rules,
      ...jsxA11y.configs.recommended.rules,

      // Un import no usado es ruido; una variable de error sí puede quedarse
      'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],

      // Vite necesita que cada módulo exporte solo componentes para el refresco rápido
      'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],

      // Elevadas a error: son los fallos que el módulo 5 y el 3 costaron más de explicar
      'react-hooks/rules-of-hooks': 'error',
      'react-hooks/exhaustive-deps': 'error',
      'jsx-a11y/label-has-associated-control': 'error',
      'jsx-a11y/no-autofocus': 'warn'
    }
  },
  prettier   // SIEMPRE el último: desactiva las reglas de estilo que chocan con Prettier
];

Tres puntos que hay que entender, no copiar:

  • react-hooks/exhaustive-deps como error, no como warn. Es la decisión más discutida de cualquier configuración de React y aquí se toma a conciencia: en 05-02 se vio que una dependencia omitida produce datos obsoletos que no fallan en desarrollo y sí en producción. Como aviso, se ignora; como error, obliga a resolverlo o a justificar la excepción con un comentario eslint-disable-next-line que queda visible en la revisión de código.
  • jsx-a11y en modo recomendado. Caza en el editor la imagen sin alt, el onClick sobre un div sin rol ni teclado, y la etiqueta sin control asociado. No sustituye a la revisión manual de 03-06, pero elimina el 80 % de los fallos habituales antes de que existan.
  • prettier va el último del array. eslint-config-prettier no añade reglas: desactiva las de ESLint que se pisan con el formateo. Si se pusiera antes, las reglas posteriores volverían a activarlas y tendrías al linter y al formateador peleándose en cada guardado.
// .prettierrc
{
  "semi": true,
  "singleQuote": true,
  "printWidth": 100,
  "trailingComma": "none",
  "arrowParens": "always"
}
# .editorconfig
root = true

[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 2
trim_trailing_whitespace = true

[*.md]
trim_trailing_whitespace = false

.editorconfig es el que evita el clásico «el fichero entero aparece modificado en el diff» cuando alguien trabaja en Windows: fija los finales de línea y la indentación a nivel de editor, antes de que Prettier intervenga.

# .gitignore
node_modules
dist
dist-ssr
coverage
*.local

# Entorno: se ignoran los .env reales, se versiona el ejemplo
.env
.env.*
!.env.example

# Cypress
cypress/videos
cypress/screenshots
cypress/downloads

# Editor y sistema
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.log

  1. Variables de entorno con import.meta.env

La URL de la API no puede estar escrita a mano en quince ficheros. Vite expone las variables de entorno a través de import.meta.env, con una regla estricta: solo las que empiezan por VITE_ llegan al código del cliente.

# .env  (NO se versiona)
VITE_URL_API=http://localhost:3001
VITE_NOMBRE_APP=CicloUrbano
# .env.example  (SÍ se versiona: es la plantilla)
# Copia este fichero a .env y ajusta los valores.
# URL base de la API REST de desarrollo (json-server)
VITE_URL_API=http://localhost:3001
# Nombre visible de la aplicación
VITE_NOMBRE_APP=CicloUrbano
// src/configuracion.js
export const URL_API = import.meta.env.VITE_URL_API ?? 'http://localhost:3001';
export const NOMBRE_APP = import.meta.env.VITE_NOMBRE_APP ?? 'CicloUrbano';
export const ES_DESARROLLO = import.meta.env.DEV;   // booleano que Vite inyecta siempre

Por qué un módulo configuracion.js en lugar de leer import.meta.env allí donde haga falta:

  1. Un único punto de verdad. El día que la variable cambie de nombre, se toca un fichero.
  2. Valores por defecto en un solo sitio, con ??, para que clonar el repositorio y olvidarse del .env no rompa el arranque.
  3. Se puede sustituir en las pruebas con un vi.mock de un módulo propio; con import.meta.env esparcido, no.

Y el aviso que se repetirá en 11-05 con todas las letras: import.meta.env es texto que se incrusta en el JavaScript que descarga el navegador. Cualquier persona puede leerlo con dos clics. Una clave de API privada, una contraseña o un token de administración no van ahí jamás, ni siquiera «temporalmente mientras probamos».

  1. Estructura de carpetas: por tipo o por funcionalidad

Hay dos escuelas, y las dos tienen razón en su contexto:

Por tipo (componentes/, hooks/, paginas/) Por funcionalidad (reservas/, catalogo/, sesion/)
Encontrar un componente por su nombre Inmediato Hay que saber a qué funcionalidad pertenece
Trabajar en una funcionalidad completa Saltas entre cuatro carpetas Todo junto
Borrar una funcionalidad entera Hay que cazar ficheros por todas partes Se borra la carpeta
Proyecto pequeño (< 30 ficheros) Cómodo Sobredimensionado
Proyecto grande o con varios equipos Carpetas de 60 ficheros Escala mejor
Riesgo típico Un componentes/ inmanejable Discutir a qué funcionalidad pertenece cada cosa

CicloUrbano adopta un híbrido, que es lo que hace la mayoría de proyectos reales de este tamaño: por tipo para lo compartido, por funcionalidad para el estado de dominio. Esta última parte ya venía decidida desde 07-05.

ciclourbano/
├── cypress/
│   ├── e2e/                    # acceso.cy.js, reservar.cy.js, cancelar.cy.js
│   └── support/                # comandos.js: cy.porTestId, cy.sembrarDatos, cy.accederComo
├── public/                     # ficheros servidos tal cual (favicon, robots.txt)
├── src/
│   ├── api/                    # cliente.js y funciones por recurso. NO sabe nada de React
│   ├── almacen/                # almacen.js: configureStore y su composición
│   ├── componentes/            # reutilizables, sin ruta propia
│   │   └── base/               # sistema de diseño: Boton, Campo, Panel, Etiqueta, Modal
│   ├── consultas/              # clienteConsultas.js, claves.js y los hooks de TanStack Query
│   ├── contextos/              # ProveedorTema, contextos de avisos, Proveedores.jsx
│   ├── datos/                  # dominio.js: datos de ejemplo mientras no hay red (11-02)
│   ├── funcionalidades/        # estado de cliente organizado por dominio
│   │   ├── catalogo/           # sliceCatalogo.js y sus selectores
│   │   ├── reservas/           # sliceReservas.js y sus selectores
│   │   └── sesion/             # sliceSesion.js y sus selectores
│   ├── hooks/                  # useAlternar, useAlmacenLocal, useDebounce, useEventoTeclado…
│   ├── paginas/                # una por ruta: PaginaCatalogo, PaginaReservas…
│   ├── pruebas/                # configuracion.js, utilidades.jsx, manejadores.js, servidor.js
│   ├── utilidades/             # clases.js, validarReserva.js, monitorizacion.js, disponibilidad.js
│   ├── configuracion.js        # lectura única de import.meta.env
│   ├── rutas.jsx               # createBrowserRouter: el mapa completo
│   ├── index.css               # reinicio + variables :root + tema oscuro
│   └── main.jsx                # composición de proveedores
├── .editorconfig
├── .env.example
├── .gitignore
├── .prettierrc
├── DECISIONES.md               # el acta del apartado 8
├── db.json                     # base de datos de desarrollo y semilla de las pruebas
├── eslint.config.js
├── package.json
├── README.md
└── vite.config.js

Dos reglas de colocación que evitan la mayoría de las discusiones:

  • Un fichero de prueba vive junto al código que prueba (TarjetaBicicleta.jsx y TarjetaBicicleta.test.jsx en la misma carpeta). Solo la infraestructura de pruebas está en src/pruebas/. Así, al borrar un componente, su prueba se va con él.
  • Un componente sube a componentes/ cuando lo usa la segunda pantalla, no antes. Hasta entonces vive junto a su página. Generalizar con un solo caso de uso produce abstracciones equivocadas.

  1. La API de desarrollo: json-server en marcha

npm run api
  \{^_^}/ hi!
  Loading db.json
  Done

  Resources
  http://localhost:3001/bicicletas
  http://localhost:3001/estaciones
  http://localhost:3001/usuarios
  http://localhost:3001/reservas

Comprobación obligatoria antes de seguir. Si esto no responde, ninguna pantalla de 11-03 funcionará y perderás una hora buscando el fallo en React:

# Lista completa
curl http://localhost:3001/bicicletas

# Filtro por campo: json-server lo da gratis
curl "http://localhost:3001/bicicletas?tipo=electrica"

# Un recurso concreto
curl http://localhost:3001/bicicletas/bici-003

# Crear (lo que hará H5)
curl -X POST http://localhost:3001/reservas \
  -H "Content-Type: application/json" \
  -d '{"id":"res-99","bicicletaId":"bici-001","usuario":"usr-01","fechaInicio":"2026-06-01T10:00","horas":2,"estado":"activa"}'

# Y deshacer el experimento
curl -X DELETE http://localhost:3001/reservas/res-99

Lo que json-server da de serie y el proyecto va a aprovechar:

Capacidad Ejemplo Se usa en
Listado GET /bicicletas H1
Detalle GET /bicicletas/bici-001 H3
Filtro por campo GET /bicicletas?tipo=urbana H2
Filtro por relación GET /reservas?usuario=usr-01 H6
Creación POST /reservas H5
Modificación parcial PATCH /bicicletas/bici-001 H7
404 real GET /bicicletas/no-existe H3

Y lo que no da, y hay que tener presente desde ahora: no valida nada, no autentica a nadie y no comprueba permisos. Un PATCH desde la consola del navegador cambia el estado de cualquier bicicleta sin sesión. Es aceptable en desarrollo y es la razón por la que 11-05 insiste en que la autorización de verdad se comprueba siempre en el servidor.

  1. Convenciones del equipo

Escritas en el README.md, porque una convención que solo está en la cabeza de quien la inventó no es una convención.

Nombres

Elemento Convención Ejemplo
Componente PascalCase, fichero .jsx igual que el componente TarjetaBicicleta.jsx
Página Prefijo Pagina PaginaNuevaReserva.jsx
Hook use + camelCase useAlmacenLocal.js
Utilidad camelCase, exportación nombrada validarReserva.js
Estilos Componente.module.css junto al componente TarjetaBicicleta.module.css
Manejador interno manejarX manejarEnviar
Prop de callback alX alSeleccionar
Acción de Redux Sustantivo en pasado reservaConfirmada
Selector seleccionarX seleccionarUsuario
Ficheros Sin ñ ni tildes Diseno.jsx, no Diseño.jsx

Exportaciones: export default para componentes y páginas; exportación nombrada para hooks, utilidades, slices y selectores. Mezclar los dos criterios en el mismo tipo de fichero es lo que produce importaciones inconsistentes.

Orden de importaciones, siempre el mismo, con línea en blanco entre grupos:

// 1. React y bibliotecas externas
import { useState } from 'react';
import { useNavigate } from 'react-router';
import { useSelector } from 'react-redux';

// 2. Módulos propios, del más general al más concreto
import { useCrearReserva } from '../consultas/reservas.js';
import { validarReserva } from '../utilidades/validarReserva.js';
import FormularioReserva from '../componentes/FormularioReserva.jsx';

// 3. Estilos, siempre al final
import estilos from './PaginaNuevaReserva.module.css';

Mensajes de commit, en formato convencional, en español y en imperativo:

feat(catalogo): filtrar bicicletas por tipo desde la URL
fix(reservas): impedir reservar una bicicleta en mantenimiento
test(formulario): cubrir los mensajes de validación accesibles
refactor(api): extraer el envoltorio de fetch a src/api/cliente.js
docs(readme): documentar el arranque con dev:todo
chore(deps): actualizar vitest a la 2.1

El prefijo no es decoración: permite generar el registro de cambios automáticamente y, sobre todo, obliga a que un commit haga una sola cosa. Si dudas entre feat y fix, probablemente el commit contenga dos cambios y haya que partirlo.

  1. El plan de trabajo: incrementos verticales

Aquí está la decisión de método que más influye en cómo se siente el proyecto mientras se construye.

Por capas (horizontal) Por incrementos verticales
Orden de trabajo Todos los componentes → toda la API → todas las pruebas Una historia completa de punta a punta, luego la siguiente
Primera demo posible Al final Al terminar la primera historia
Riesgo detectado Tarde, cuando todo está escrito Pronto, en la primera integración
Sensación de avance Nula durante semanas Continua
Riesgo real Descubrir en la última semana que el modelo no encaja Refactorizar algo de lo hecho al llegar la tercera historia

Se elige vertical, con una salvedad honesta: este módulo está organizado por capas —interfaz, estado, pruebas, despliegue— porque explicar requiere agrupar conceptos, mientras que construir requiere entregar valor pronto. Es una diferencia importante y conviene tenerla clara: el orden del libro no es el orden del taller.

Aun así, dentro de cada lección se trabaja por historias completas. Y si estuvieras haciendo este proyecto en un equipo real, el orden sería este:

flowchart LR
    subgraph L1["11-01 · Andamiaje"]
        A1["Producto y acta"] --> A2["Vite, linter, entorno"] --> A3["db.json + API viva"]
    end
    subgraph L2["11-02 · Interfaz"]
        B1["Sistema de diseño"] --> B2["Diseno + rutas"] --> B3["Pantallas con datos estáticos"]
    end
    subgraph L3["11-03 · Estado y API"]
        C1["Capa de datos"] --> C2["Query + mutaciones"] --> C3["Redux, contexto, URL"]
    end
    subgraph L4["11-04 · Pruebas"]
        D1["Unitarias"] --> D2["Componentes"] --> D3["Integración"] --> D4["E2E + CI"]
    end
    subgraph L5["11-05 · Producción"]
        E1["build y entornos"] --> E2["Despliegue"] --> E3["Monitorización"]
    end
    L1 --> L2 --> L3 --> L4 --> L5

Y el orden de las historias, priorizado por riesgo primero:

  1. H4 (acceso) antes que nada: es la puerta de todo lo demás y toca sesión, formulario, redirección y persistencia. Si algo va a salir mal en la arquitectura, sale mal aquí.
  2. H1 + H2 (catálogo y filtro): la pantalla más visitada, y la que valida la capa de datos.
  3. H5 (reservar): el camino del dinero. Toca mutación, validación, invalidación y navegación.
  4. H6 (mis reservas y cancelar): reutiliza casi todo lo anterior.
  5. H3, H8 (ficha y estaciones): lectura pura, poco riesgo.
  6. H7 (taller): el rol se prueba con todo lo demás ya en pie.
  7. D1-D4 si queda tiempo.

La regla que gobierna la lista: lo que puede hundir el proyecto va primero. Dejar la identificación para el final es el error clásico, porque es justo la funcionalidad que atraviesa todas las capas y obliga a rehacer lo que ya se dio por bueno.

  1. El primer commit

Un commit inicial debe cumplir una condición: quien lo clone, puede arrancarlo.

git init
git add .
git commit -m "chore: andamiaje inicial de CicloUrbano con Vite, linter y API de desarrollo"

Y el README.md que lo acompaña, que es lo primero que lee cualquiera:

# CicloUrbano

Aplicación web de alquiler de bicicletas urbanas por estaciones.

## Requisitos
- Node.js 20 o superior

## Arranque

npm install cp .env.example .env npm run dev:todo # Vite en :5173 y json-server en :3001

## Scripts
| Comando | Qué hace |
|---|---|
| `npm run dev:todo` | Aplicación y API en paralelo |
| `npm run lint` | ESLint sin tolerancia a avisos |
| `npm test` | Vitest en modo vigilancia |
| `npm run e2e` | Cypress sobre la aplicación levantada |
| `npm run build` | Construcción de producción en `dist/` |

## Documentación
- `DECISIONES.md`: acta de decisiones de arquitectura
- `db.json`: datos de desarrollo y semilla de las pruebas

La lista de comprobación antes de dar el andamiaje por terminado:

  • [ ] npm install funciona en un clon limpio
  • [ ] npm run dev sirve la aplicación en el 5173
  • [ ] npm run api responde en el 3001 con los cuatro recursos
  • [ ] npm run lint termina sin errores ni avisos
  • [ ] .env está ignorado y .env.example versionado
  • [ ] DECISIONES.md tiene las diez filas del acta
  • [ ] El README.md permite arrancar sin preguntar nada a nadie

Errores Comunes y Consejos

  • Empezar por el código y planificar sobre la marcha. Es el error de fondo de esta lección. Sin historias con criterios de aceptación no sabes cuándo has terminado, y sin acta de decisiones cada discusión técnica se repite cada dos semanas. Media jornada de planificación ahorra semanas.
  • Historias sin criterios comprobables. «El catálogo debe ser rápido» no se puede verificar ni convertir en prueba. «El catálogo muestra el esqueleto mientras carga y la lista en cuanto llegan los datos» sí.
  • No escribir lo que queda fuera. El alcance que no se niega explícitamente se asume incluido. La tabla del apartado 4 es la que protege la entrega.
  • Anidar objetos en el modelo de datos. Guardar la bicicleta entera dentro de la reserva parece cómodo el primer día y produce datos desincronizados el segundo. Identificadores, siempre.
  • Un juego de datos de desarrollo sin casos límite. Si todas las bicicletas están disponibles y todos los usuarios tienen reservas, nunca verás el estado vacío ni el botón deshabilitado, y llegarán rotos a producción.
  • Poner herramientas de desarrollo en dependencies. Cypress y json-server en producción son un síntoma de que nadie ha mirado el package.json desde que se generó.
  • Dejar exhaustive-deps como aviso. Con el tiempo se acumulan decenas y dejan de leerse. Como error, se resuelve o se justifica con un comentario visible.
  • Poner eslint-config-prettier en cualquier posición menos la última. Deja de hacer efecto y acabas con el linter y el formateador en conflicto en cada guardado.
  • Secretos en variables VITE_. Todo lo que empieza por VITE_ viaja al navegador en texto plano. No hay excepción, ni «solo mientras probamos».
  • Consejo: crea DECISIONES.md desde el primer día y añade una fila cada vez que discutas una elección técnica. El valor no está en el documento, está en no volver a tener la misma conversación.
  • Consejo: verifica la API con curl antes de escribir el primer fetch. Descartar la mitad del sistema en treinta segundos ahorra depuraciones larguísimas.

Ejercicios

Ejercicio 1. El cliente pide una historia nueva: «como cliente quiero recibir un aviso 10 minutos antes de que termine mi reserva». Escríbela con el formato del apartado 3, con criterios de aceptación comprobables. Después decide si es imprescindible o deseable, y si entra o no en la primera versión, justificándolo con el alcance del apartado 1 y la tabla del apartado 4. Si decides dejarla fuera, redacta la fila correspondiente.

Ejercicio 2. El equipo propone añadir una entidad Incidencia para la pestaña de incidencias de /estaciones/:estacionId, con la información de una avería reportada. Define sus campos y sus relaciones, añádela al diagrama de entidad-relación y al db.json con dos registros de ejemplo coherentes con el canon, y explica qué recursos nuevos aparecerían en json-server. Indica además qué clave de TanStack Query le correspondería según la fábrica del proyecto.

Ejercicio 3. Un compañero propone tres cambios sobre el acta: (a) usar BrowserRouter con <Routes> porque «es más sencillo», (b) guardar la lista de bicicletas en Redux «para tenerlo todo en un sitio», y (c) poner la clave de un servicio de mapas en VITE_CLAVE_MAPAS para usarla desde el cliente. Responde a cada uno con el argumento técnico correspondiente y di qué fila del acta lo cubre. Para el tercero, explica además qué haría falta para usar ese servicio sin exponer la clave.

Soluciones

Solución 1.

ID Historia Criterios de aceptación
D5 Como cliente quiero recibir un aviso 10 minutos antes de que termine mi reserva para devolver la bicicleta a tiempo Con una reserva activa o confirmada cuya hora de fin esté a menos de 10 minutos, se muestra un aviso persistente con el tiempo restante y un enlace a la reserva · El aviso desaparece al finalizar la reserva o al cancelarla · Si hay varias reservas próximas a vencer, se muestra la más cercana · El cálculo usa fechaInicio + horas y se actualiza cada minuto

Clasificación: deseable (D5), fuera de la primera versión. Los tres argumentos:

  1. No bloquea el objetivo principal de ninguna de las dos personas. Ana puede reservar y usar la bicicleta sin el aviso; Marc no lo necesita en absoluto.
  2. El aviso útil de verdad es el que llega con la aplicación cerrada, y eso son notificaciones push, que están explícitamente fuera de alcance por exigir servidor, permisos y service worker. Un aviso que solo se ve con la pestaña abierta resuelve una fracción pequeña del problema real y puede dar falsa sensación de cobertura.
  3. Requiere un temporizador global que reevalúe cada minuto y una fuente de verdad de la hora actual, lo que complica las pruebas (hay que fijar el reloj) para un beneficio marginal en esta versión.

Fila para la tabla del apartado 4:

Fuera de alcance Motivo
Avisos de fin de reserva La versión valiosa exige notificaciones push, que ya están fuera de alcance. La variante dentro de la pestaña cubre pocos casos reales y añade un temporizador global difícil de probar. Se reconsidera cuando exista servidor propio (11-05)

Y la anotación honesta: cuando llegue la API real, esta historia sube a imprescindible, porque el negocio penaliza la devolución tardía y avisar es más barato que cobrar recargos.

Solución 2.

Campos y relaciones. Una incidencia pertenece a una bicicleta (que a su vez está en una estación) y la reporta un usuario:

erDiagram
    BICICLETA ||--o{ INCIDENCIA : "acumula"
    USUARIO ||--o{ INCIDENCIA : "reporta"
    INCIDENCIA {
        string id PK "inc-01"
        string bicicletaId FK "bici-003"
        string reportadaPor FK "usr-01"
        string fecha "2026-05-02T18:30"
        string tipo "frenos | rueda | bateria | otro"
        string descripcion "El freno trasero patina"
        string estado "abierta | en_curso | resuelta"
    }

Registros para db.json, coherentes con el canon —bici-003 está en mantenimiento, así que es la candidata natural a tener una incidencia abierta, y bici-002 puede tener una ya resuelta:

{
  "incidencias": [
    {
      "id": "inc-01",
      "bicicletaId": "bici-003",
      "reportadaPor": "usr-01",
      "fecha": "2026-05-02T18:30",
      "tipo": "frenos",
      "descripcion": "El freno trasero patina con carga.",
      "estado": "abierta"
    },
    {
      "id": "inc-02",
      "bicicletaId": "bici-002",
      "reportadaPor": "usr-02",
      "fecha": "2026-04-28T09:15",
      "tipo": "bateria",
      "descripcion": "La batería no llegaba al 60 % de autonomía anunciada.",
      "estado": "resuelta"
    }
  ]
}

Decisión de modelado importante: la incidencia se asocia a la bicicleta, no a la estación, aunque la pestaña que la muestra sea la de una estación. El motivo es que una avería viaja con la bicicleta: si bici-003 se traslada a est-03, su historial debe ir con ella. La pestaña de incidencias de una estación se obtiene entonces derivando: las bicicletas de esa estación, y las incidencias de esas bicicletas. Modelar la relación al revés obligaría a reescribir el estacionId de cada incidencia en cada traslado.

Recursos que aparecen en json-server:

Petición Uso
GET /incidencias Todas
GET /incidencias?bicicletaId=bici-003 Historial de una bicicleta
GET /incidencias?estado=abierta Cola de trabajo del taller (H7)
POST /incidencias Reportar una nueva
PATCH /incidencias/inc-01 Cambiar su estado

Y la clave de consulta, aprovechando que la fábrica del proyecto ya la había previsto:

// src/consultas/claves.js
estaciones: {
  todas: () => ['estaciones'],
  detalle: (id) => ['estaciones', id],
  incidencias: (id) => ['estaciones', id, 'incidencias']   // ya existía
},
incidencias: {
  todas: () => ['incidencias'],
  deBicicleta: (bicicletaId) => ['incidencias', { bicicleta: bicicletaId }]
}

La jerarquía importa: invalidar ['estaciones', 'est-02'] invalida también ['estaciones', 'est-02', 'incidencias'], porque TanStack Query compara las claves por prefijo. Es justo el comportamiento que se quiere al resolver una incidencia.

Solución 3.

(a) BrowserRouter con <Routes>. Cubierto por la fila A2. Es cierto que es más sencillo de escribir, pero el proyecto necesita tres cosas que ese modo no da:

  • errorElement por rama: sin él, un fallo al cargar la ficha de una bicicleta derriba toda la aplicación en lugar de dejar la cabecera y el menú en pie. Es la diferencia entre una pantalla en blanco y un error contenido.
  • lazy a nivel de ruta con el enrutador gestionando la carga, que es lo que hace posible la división de código de 08-04 sin envolver cada pantalla a mano.
  • La API de datos (useNavigation, useRouteError, handle para las migas de pan), que ya se usa en MigasDePan.

Y el argumento decisivo: migrar después cuesta más que empezar bien, porque implica reescribir el árbol de rutas entero cuando ya hay pantallas encima.

(b) La lista de bicicletas en Redux. Cubierto por A3 y por la tabla de asignación de estado. «Tenerlo todo en un sitio» suena a orden, pero mezcla dos cosas de naturaleza distinta: las bicicletas son estado del servidor, una copia local de un dato que vive en otra máquina y que puede cambiar sin que la aplicación se entere. Meterlas en Redux obliga a escribir a mano, y a mantener, todo esto:

Necesidad Con TanStack Query Con Redux a mano
Caché por clave Incluida slice con estructura propia
Deduplicación de peticiones simultáneas Incluida condition en el thunk
Estados de carga y error isPending, isError Tres campos por recurso en extraReducers
Revalidación al volver a la pestaña refetchOnWindowFocus Efecto propio con visibilitychange
Datos obsoletos y refresco en segundo plano staleTime No existe: o hay dato o no lo hay
Invalidar tras una mutación invalidateQueries Despachar y recargar a mano

Es exactamente el trabajo que 07-06 mostró que no merece la pena reescribir. Redux se queda con lo que sí es suyo: sesión, término de búsqueda y orden.

(c) La clave del servicio de mapas en VITE_CLAVE_MAPAS. Es el más grave de los tres. Todo lo que empieza por VITE_ se sustituye literalmente en el código durante la construcción y acaba en un fichero de dist/ que cualquiera puede abrir:

npm run build
grep -r "CLAVE" dist/assets/*.js    # ahí está, en texto plano

No hay ofuscación que lo arregle: el navegador tiene que poder leerla para usarla, luego el usuario también. Las consecuencias son consumo facturado a tu cuenta y, según el servicio, acceso a datos que no deberían salir.

Qué hacer en su lugar, en orden de preferencia:

  1. Que la petición al servicio salga del servidor. El cliente llama a tu API, tu API llama al servicio con la clave y devuelve el resultado. La clave nunca sale de la máquina.
  2. Si el servicio está pensado para el cliente —muchos proveedores de mapas ofrecen claves públicas—, usar ese tipo de clave y restringirla en el panel del proveedor por dominio de origen, por cuota y por permisos mínimos. Sigue siendo visible, pero solo funciona desde tu dominio y con un techo de gasto.
  3. Nunca usar una clave con permisos de escritura o de facturación desde el cliente, en ninguna circunstancia.

Y como el ejercicio parte de una historia que está fuera de alcance —el mapa geográfico—, la respuesta completa incluye recordarlo: no hay que resolver el problema de la clave todavía, porque la funcionalidad no entra en esta versión.

Conclusión

Esta lección ha convertido una idea en un proyecto que se puede empezar a construir, y lo ha hecho en el orden correcto: primero el producto, después las decisiones, y solo al final las herramientas.

Del producto queda fijado lo esencial: CicloUrbano en una frase que ya decide la arquitectura al declararse privada, dos personas usuarias con objetivos y contextos distintos que justifican por qué el inicio es el catálogo y el taller una pantalla aparte, ocho historias imprescindibles y cuatro deseables con criterios de aceptación comprobables —que en 11-04 se convertirán literalmente en pruebas—, y una lista explícita de lo que queda fuera con su motivo, que es la que protege la entrega cuando alguien proponga añadir un mapa a mitad de camino.

Del dominio queda el modelo definitivo con sus cuatro entidades y tres reglas que se respetan sin excepción: relaciones por identificador y nunca objetos anidados, estados como cadena de un conjunto cerrado en lugar de booleanos, y las incoherencias heredadas anotadas como deuda en vez de arregladas a destiempo. El db.json no es un montón de datos de relleno: cada estado, cada caso límite y el estado vacío tienen su representante, porque lo que no se ve en desarrollo no se diseña.

Del rumbo técnico queda el acta de decisiones, diez filas con su justificación y su alternativa descartada: Vite en lugar de Next.js porque la aplicación es privada e interactiva; React Router en modo de datos por errorElement, lazy y la API de datos; TanStack Query para el servidor y Redux Toolkit para el cliente, con contexto para tema y avisos y la URL para el filtro; CSS Modules; el cuarteto Vitest, Testing Library, MSW y Cypress; y JavaScript ahora con la puerta abierta a TypeScript. Junto al acta, la tabla de asignación de estado que responde de antemano a la pregunta que más veces se repite en un proyecto de React: ¿dónde vive este dato?

Y del andamiaje queda un repositorio funcionando: Vite con React 19, dependencias elegidas una a una y bien separadas entre producción y desarrollo, un package.json con catorce scripts que son la interfaz del proyecto, ESLint con jsx-a11y y exhaustive-deps elevado a error, Prettier el último de la cadena, .editorconfig, .gitignore, variables de entorno centralizadas en configuracion.js con .env.example versionado y ningún secreto, una estructura de carpetas híbrida —por tipo lo compartido, por funcionalidad el estado de dominio—, json-server sembrado y verificado con curl, las convenciones del equipo escritas en el README.md, y un plan de trabajo por incrementos verticales que ataca primero lo que más riesgo tiene.

Ahora hay un esqueleto que arranca y no hay nada que mirar. La siguiente lección lo llena: Construyendo la Interfaz de Usuario levanta CicloUrbano entero con datos todavía estáticos —el sistema de diseño con sus componentes base, el marco de la aplicación con Diseno y la navegación, las ocho pantallas con sus estados vacío, de carga y de error previstos desde el primer momento, la accesibilidad aplicada pantalla a pantalla y el diseño adaptable con CSS Modules—, para poder verla, recorrerla y validarla antes de conectar una sola petición.

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