Módulo 26: Proyecto final
Objetivos del módulo
- Construir de principio a fin una aplicación React completa contra una API real: la aplicación de gestión EMPRESA
- Organizar el código por funcionalidades y separar lo compartido de lo específico
- Iniciar sesión con JWT, guardar la sesión con Zustand y proteger rutas con React Router 8
- Usar TanStack Query para todo el estado de servidor: listas paginadas, detalles, mutaciones, invalidación y una actualización optimista
- Usar Zustand para el estado de cliente: sesión, tema y avisos
- Escribir formularios con React Hook Form y Zod, y llevar los errores 422 del servidor a cada campo
- Tratar los conflictos 409 de la API sin perder lo que el usuario estaba haciendo
- Dar estilo con Tailwind 4 y un modo oscuro basado en variables CSS
- Probar la aplicación con Vitest, Testing Library y MSW, y de extremo a extremo con Playwright
- Generar el build de producción y saber qué cambia si tu proyecto usa React 19.1 o anterior
💡 Consejo: Este módulo reúne lo que has aprendido en el curso. Aquí no se explica de nuevo cada API: cuando algo se usa por primera vez, tienes el enlace al módulo donde se vio a fondo. Lo que sí encontrarás son las decisiones de diseño, sus motivos y los errores que aparecen al juntar todas las piezas. Todo el código del proyecto está en el texto, fichero a fichero, y además puedes descargarlo entero: empresa-react.zip.
⚠️ Importante: El proyecto usa React 19.3.1 y React Router 8.5.1, que exige React 19.2.7 o superior. La sección 17 explica qué cambiar si tienes React 19.1, 19.0 o 18.3. Se ha comprobado instalando esas versiones sobre el mismo proyecto y pasando todas sus pruebas.
1. Qué vas a construir
La aplicación EMPRESA es el típico panel de gestión interna: una pantalla de entrada, un menú lateral y varias secciones con tablas y formularios. Funciona contra la API de ejemplo que has usado durante el curso.

Figura 1. Inicio de sesión. La contraseña incorrecta devuelve un 401 y su mensaje se muestra tal cual.
Lo que hace la aplicación:
| Pantalla | Ruta | Qué hace |
|---|---|---|
| Inicio de sesión | /login | Pide email y contraseña, guarda el token y te devuelve a donde ibas |
| Panel | / | Seis indicadores y dos gráficos de barras con /api/estadisticas |
| Empleados | /empleados | Búsqueda, filtro por departamento, orden por columnas y paginación, todo en la URL |
| Alta y edición de empleado | /empleados/nuevo, /empleados/:id/editar | Formulario con validación en cliente y errores del servidor por campo |
| Departamentos | /departamentos | Listado, alta, edición y borrado |
| Pedidos | /pedidos | Listado con filtro por estado, orden y paginación |
| Detalle de pedido | /pedidos/:id | Líneas, total y cambio de estado optimista |
| Nuevo pedido | /pedidos/nuevo | Líneas dinámicas con useFieldArray y total estimado en vivo |
Y además, en todas las pantallas:
- Si no has iniciado sesión, cualquier ruta te lleva a
/login?volver=..., y al entrar vuelves a esa ruta. - La sesión sobrevive a una recarga. Si caduca, la próxima petición que la API rechace te devuelve al login con un aviso.
- Solo el rol
ADMINve los botones de borrar, porque la API solo deja borrar a ese rol. - Un modo oscuro que se recuerda entre visitas.

Figura 2. El panel. Los gráficos son tablas HTML con barras dentro: un lector de pantalla los lee como una tabla normal.
Las piezas y por qué estas
| Necesidad | Herramienta | Módulo | Por qué esta y no otra |
|---|---|---|---|
| Rutas | React Router 8 (modo Datos) | 18 | createBrowserRouter permite carga perezosa por ruta y pantallas de error por rama |
| Datos de la API | TanStack Query 5 | 19 | Caché, reintentos, invalidación y optimismo ya resueltos |
| Sesión, tema, avisos | Zustand 5 | 20 | Se lee fuera de React (el cliente HTTP lo necesita) y persist lo guarda solo |
| Formularios | React Hook Form + Zod 4 | 21 | Validación declarativa, tipos derivados del esquema y setError para el servidor |
| Estilos | Tailwind 4 | 07 | Sin ficheros CSS por componente; el modo oscuro con variables |
| Pruebas | Vitest, Testing Library, MSW, Playwright | 22 | Unidad y componente sin API; extremo a extremo con la API real |
En el módulo 20 quedó dicho: este curso usa TanStack Query para los datos y Zustand o Context para lo demás. Aquí se cumple al pie de la letra. Ningún dato de la API vive en Zustand, y ningún dato de la interfaz vive en la caché de Query. Si dudas de dónde va algo, hazte esta pregunta: ¿quién tiene la verdad? Si la tiene el servidor, y tú solo guardas una copia que puede quedarse vieja, es estado de servidor. Si la tiene el navegador, es estado de cliente.
📘 Concepto: El estado de servidor es una copia local de algo que vive en otro sitio. Puede caducar, otro usuario puede cambiarlo y hay que pedirlo de forma asíncrona. El estado de cliente nace y muere en el navegador: el tema elegido, un aviso en pantalla, el token de esta sesión. Mezclarlos en un mismo store obliga a reescribir a mano la caché, los reintentos y la invalidación, que es justo lo que TanStack Query ya hace.
Prepara la API
Descarga empresa-api.zip, descomprímelo y arráncalo en una terminal aparte:
cd empresa-api
node server.mjs
API EMPRESA en http://localhost:3000/api
Usuarios: ana.garcia@empresa.es (ADMIN) y luis.martinez@empresa.es (USER), contraseña "secreto"
Los datos viven en memoria. Cada vez que reinicias la API vuelven al estado inicial, lo que viene muy bien para repetir las pruebas.
Antes de escribir una línea de React, comprueba que la API responde. Si no lo haces, el primer error que veas en la aplicación será un Failed to fetch y no sabrás si el fallo está en tu código o en la API:
curl http://localhost:3000/api
curl -X POST http://localhost:3000/api/auth/login -H "Content-Type: application/json" -d '{"email":"ana.garcia@empresa.es","password":"secreto"}'
La primera devuelve {"nombre":"API EMPRESA",...} y la segunda un objeto con token, usuario y expiraEn: 3600. En PowerShell, curl es un alias de otra orden: usa curl.exe o Invoke-RestMethod.
2. Arquitectura: organizar por funcionalidades
En los módulos anteriores cada ejemplo cabía en un fichero o dos. Una aplicación de este tamaño tiene unos 50, y hay que decidir dónde va cada uno. Hay dos grandes formas de hacerlo:
- Por tipo técnico:
components/,hooks/,services/,pages/. Es lo que sale al principio, pero para tocar “empleados” abres cinco carpetas. - Por funcionalidad:
funcionalidades/empleados/contiene su API, su esquema, sus páginas y sus pruebas. Lo que usan varias funcionalidades va acompartido/.
Este proyecto usa la segunda, que es la que se explica en el módulo 25.
Figura 3. Arquitectura. Las páginas usan TanStack Query para los datos y Zustand para el resto. Todas las peticiones pasan por un único cliente HTTP.
empresa-react/
├── e2e/flujo.spec.ts pruebas extremo a extremo (Playwright)
├── public/favicon.svg
├── src/
│ ├── app/ arranque: router y QueryClient
│ ├── compartido/
│ │ ├── api/ cliente HTTP, tipos y errores de formulario
│ │ ├── estado/ stores de Zustand: sesión, avisos, tema
│ │ ├── hooks/ useFiltrosUrl
│ │ ├── ui/ Boton, Campo, Tabla, DialogoConfirmar, Disposicion...
│ │ └── formato.ts moneda y fechas con Intl
│ ├── funcionalidades/
│ │ ├── sesion/ login y ruta protegida
│ │ ├── panel/
│ │ ├── empleados/
│ │ ├── departamentos/
│ │ └── pedidos/
│ ├── pruebas/ preparación de Vitest y MSW
│ ├── index.css
│ └── main.tsx
├── index.html
├── package.json
├── playwright.config.ts
├── tsconfig*.json
└── vite.config.ts
Tres reglas sencillas mantienen el orden:
compartido/no importa nada defuncionalidades/. Si un componente compartido necesita saber qué es un empleado, no es compartido.- Una funcionalidad puede usar otra solo a través de su
api.ts. El formulario de empleados importadepartamentosQueriespara rellenar el desplegable, pero no toca sus componentes. - Cada funcionalidad tiene un
api.tscon sus consultas y mutaciones. Las páginas nunca llaman afetchdirectamente.
💡 Consejo: Fíjate en que cada página termina con
export { PaginaX as Component }. Es el nombre que React Router busca cuando carga una ruta de forma perezosa conlazy. Así cada página acaba en su propio fichero JavaScript y solo se descarga cuando visitas esa ruta.
3. Crear el proyecto
Crea el proyecto con Vite e instala las dependencias, como en el módulo 01. En PowerShell escribe "--" entre comillas, o se pierde y obtienes la plantilla sin React:
npm create vite@latest empresa-react -- --template react-ts
cd empresa-react
npm install react-router @tanstack/react-query zustand react-hook-form @hookform/resolvers zod
npm install -D tailwindcss @tailwindcss/vite vitest jsdom @testing-library/react @testing-library/dom @testing-library/user-event @testing-library/jest-dom msw @playwright/test @types/node
npx playwright install chromium
Borra src/App.tsx, src/App.css, src/assets y public/icons.svg: no se usan. Después sustituye o crea los ficheros de configuración.
package.json
{
"name": "empresa-react",
"private": true,
"version": "1.0.0",
"type": "module",
"engines": {
"node": ">=22.22.0"
},
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "oxlint",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test"
},
"dependencies": {
"@hookform/resolvers": "^7.0.1",
"@tanstack/react-query": "^5.105.1",
"react": "^19.3.1",
"react-dom": "^19.3.1",
"react-hook-form": "^7.91.0",
"react-router": "^8.5.1",
"zod": "^4.6.7",
"zustand": "^5.0.16"
},
"devDependencies": {
"@playwright/test": "^1.64.1",
"@tailwindcss/vite": "^4.3.3",
"@testing-library/dom": "^10.4.2",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.8",
"@types/node": "^24.19.2",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.2",
"jsdom": "^30.1.3",
"msw": "^3.0.3",
"oxlint": "^1.89.0",
"tailwindcss": "^4.3.3",
"typescript": "~7.0.2",
"vite": "^8.3.5",
"vitest": "^5.0.5"
}
}
Dos detalles de este package.json:
enginesdeclara Node 22.22 o superior, que es lo que exige React Router 8. npm solo avisa si tu Node es anterior, no se niega a instalar, pero el aviso te ahorra buscar el origen de un error raro.npm testejecuta Vitest una vez y termina (vitest run). En desarrollo,npm run test:watchlo deja vigilando.
vite.config.ts
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [react(), tailwindcss()],
test: {
environment: 'jsdom',
setupFiles: './src/pruebas/preparacion.ts',
include: ['src/**/*.test.{ts,tsx}'],
},
});
defineConfig se importa de vitest/config y no de vite. Es la misma función, pero con los tipos de la clave test. Así la configuración de Vite y la de Vitest viven en un solo fichero y no se desincronizan.
tsconfig.json
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
tsconfig.app.json
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
tsconfig.node.json
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts", "playwright.config.ts", "e2e"]
}
tsconfig.app.json cubre todo src, pruebas incluidas, así que npm run build también comprueba los tipos de las pruebas. tsconfig.node.json cubre lo que se ejecuta en Node: la configuración de Vite y Playwright y las pruebas E2E. Con "strict": true explícito no dependes del valor por defecto de cada versión de TypeScript.
index.html
<!doctype html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>EMPRESA</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
public/favicon.svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#3949ab"/><path d="M10 8h12v3h-8.5v3.5H21v3h-7.5V21H22v3H10z" fill="#fff"/></svg>
.env.example
# Copia este fichero como .env.local si tu API no está en el puerto 3000
VITE_API_URL=http://localhost:3000/api
.gitignore
node_modules
dist
*.tsbuildinfo
.env.local
test-results
playwright-report
La URL de la API se lee de VITE_API_URL. Solo las variables que empiezan por VITE_ llegan al navegador, y se escriben en el JavaScript al compilar: si cambias la URL, tienes que volver a hacer el build. Nunca pongas un secreto en una variable VITE_, porque cualquiera lo leería en el código descargado.
src/main.tsx
import { QueryClientProvider } from '@tanstack/react-query';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { RouterProvider } from 'react-router/dom';
import { queryClient } from './app/queryClient';
import { router } from './app/router';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</StrictMode>,
);
QueryClientProvider envuelve al router, y no al revés, porque las páginas, que están dentro del router, usan useQuery.
4. Estilos con Tailwind 4 y modo oscuro
En el módulo 07 viste Tailwind 4 con su plugin de Vite. Aquí se añade una idea que ahorra mucho código: colores con significado.
src/index.css
@import 'tailwindcss';
/* El modo oscuro depende de la clase .dark, no de la preferencia del sistema */
@custom-variant dark (&:where(.dark, .dark *));
/* Colores con significado: los componentes no necesitan variantes dark: */
@theme inline {
--color-fondo: var(--fondo);
--color-superficie: var(--superficie);
--color-borde: var(--borde);
--color-texto: var(--texto);
--color-suave: var(--suave);
--color-enlace: var(--enlace);
--color-marca: #3949ab;
--color-peligro: var(--peligro);
--color-peligro-fondo: var(--peligro-fondo);
--color-exito: var(--exito);
--color-exito-fondo: var(--exito-fondo);
}
:root {
--fondo: #f5f6fa;
--superficie: #ffffff;
--borde: #c5cae9;
--texto: #263238;
--suave: #546e7a;
--enlace: #283593;
--peligro: #b71c1c;
--peligro-fondo: #ffebee;
--exito: #1b5e20;
--exito-fondo: #e8f5e9;
}
.dark {
--fondo: #12141c;
--superficie: #1d2030;
--borde: #3a3f5c;
--texto: #eceff1;
--suave: #b0bec5;
--enlace: #c5cae9;
--peligro: #ffcdd2;
--peligro-fondo: #4a1c1f;
--exito: #c8e6c9;
--exito-fondo: #1f3a24;
color-scheme: dark;
}
body {
@apply bg-fondo text-texto;
font-family: 'Segoe UI', system-ui, sans-serif;
}
Sin esta técnica, cada componente necesitaría dos clases por color: bg-white dark:bg-slate-900, text-gray-800 dark:text-gray-100… Con @theme inline, Tailwind genera utilidades como bg-superficie o text-suave que apuntan a una variable CSS. La clase .dark cambia el valor de las variables, y todos los componentes cambian a la vez sin una sola variante dark:.
@custom-variant darkhace quedark:dependa de la clase.darkdel<html>y no de la preferencia del sistema. Así el usuario elige con un botón.inlinees importante: sin él, Tailwind guardaría el valor de la variable al compilar, y el cambio de tema no tendría efecto en esas utilidades.- Los colores del modo oscuro no son los del claro invertidos. Se han elegido para mantener el contraste: el rojo de error pasa de
#b71c1csobre rosa claro a#ffcdd2sobre granate.

Figura 4. El mismo panel con la clase .dark. Ningún componente tiene variantes dark:, salvo las barras del gráfico.
5. El cliente de la API
Todas las peticiones pasan por una sola función. Es la pieza más importante del proyecto, porque decide qué es un error y qué hacer con él.
src/compartido/api/tipos.ts
// Lo que devuelve la API, tal como lo documenta su README.
export interface Pagina<T> {
contenido: T[];
pagina: number;
tamano: number;
total: number;
paginas: number;
}
export type Rol = 'ADMIN' | 'USER';
export interface Usuario {
email: string;
nombre: string;
rol: Rol;
}
export interface RespuestaLogin {
token: string;
usuario: Usuario;
expiraEn: number;
}
export interface Departamento {
deptId: number;
nombre: string;
localidad: string | null;
presupuesto: number | null;
}
export interface Empleado {
empId: number;
nombre: string;
apellidos: string;
email: string;
telefono: string | null;
fechaAlta: string;
oficio: string;
salario: number;
comision: number | null;
jefeId: number | null;
deptId: number | null;
departamento: string | null;
}
export interface Cliente {
clienteId: number;
nombre: string;
ciudad: string;
}
export interface Producto {
productoId: number;
nombre: string;
precio: number;
stock: number;
}
export const ESTADOS_PEDIDO = ['PENDIENTE', 'ENVIADO', 'ENTREGADO', 'CANCELADO'] as const;
export type EstadoPedido = (typeof ESTADOS_PEDIDO)[number];
export interface LineaPedido {
numLinea: number;
productoId: number;
producto: string | null;
cantidad: number;
precioUnitario: number;
descuento: number;
importe: number;
}
export interface Pedido {
pedidoId: number;
clienteId: number;
cliente: string | null;
fechaPedido: string;
fechaEnvio: string | null;
estado: EstadoPedido;
lineas: LineaPedido[];
total: number;
}
export interface Estadisticas {
empleados: number;
departamentos: number;
clientes: number;
productos: number;
pedidos: number;
facturacion: number;
porDepartamento: { deptId: number; nombre: string; empleados: number; masaSalarial: number; salarioMedio: number }[];
porEstado: Record<EstadoPedido, number>;
}
Los tipos copian lo que documenta la API, incluidos los campos que pueden ser null. Son reales: el departamento 70, INVESTIGACIÓN, tiene presupuesto: null, y hay empleados sin teléfono o sin departamento. Si declaras presupuesto: number, TypeScript te deja escribir formatearEuros(d.presupuesto) y la tabla muestra “NaN €”. Con number | null el compilador te obliga a decidir qué mostrar.
src/compartido/api/cliente.ts
import { useSesion } from '../estado/sesion';
const BASE = import.meta.env.VITE_API_URL ?? 'http://localhost:3000/api';
// Un único tipo de error para toda la aplicación. El status 0 significa
// que ni siquiera hubo respuesta: la API está parada o no hay red.
export class ErrorApi extends Error {
status: number;
detalles?: Record<string, string>;
constructor(status: number, mensaje: string, detalles?: Record<string, string>) {
super(mensaje);
this.name = 'ErrorApi';
this.status = status;
this.detalles = detalles;
}
}
interface Opciones {
metodo?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
cuerpo?: unknown;
senal?: AbortSignal;
}
export async function api<T>(ruta: string, { metodo = 'GET', cuerpo, senal }: Opciones = {}): Promise<T> {
// getState() lee el store fuera de React: aquí no hay ningún componente.
const { token, cerrarSesion } = useSesion.getState();
const cabeceras: Record<string, string> = {};
if (cuerpo !== undefined) cabeceras['Content-Type'] = 'application/json';
if (token) cabeceras.Authorization = `Bearer ${token}`;
let respuesta: Response;
try {
respuesta = await fetch(BASE + ruta, {
method: metodo,
headers: cabeceras,
body: cuerpo === undefined ? undefined : JSON.stringify(cuerpo),
signal: senal,
});
} catch (error) {
// Una petición cancelada no es un fallo: TanStack Query la gestiona.
if (error instanceof DOMException && error.name === 'AbortError') throw error;
throw new ErrorApi(0, 'No se puede conectar con la API. Comprueba que está arrancada.');
}
if (respuesta.status === 204) return undefined as T;
const datos = await respuesta.json().catch(() => null);
if (!respuesta.ok) {
// Enviamos un token y la API lo rechaza: la sesión ya no vale.
// Sin token, un 401 es solo una contraseña incorrecta en el login.
if (respuesta.status === 401 && token) cerrarSesion('caducada');
throw new ErrorApi(respuesta.status, datos?.mensaje ?? `Error ${respuesta.status}`, datos?.detalles);
}
return datos as T;
}
export function textoError(error: unknown): string {
return error instanceof Error ? error.message : 'Ha ocurrido un error inesperado';
}
Lo que resuelve este fichero:
- Un único tipo de error.
fetchno lanza una excepción con un 404 ni con un 500; solo cuando no hay respuesta. Aquí todo se convierte enErrorApicon sustatus, y el resto de la aplicación solo mira eso. - El código 0 para “sin respuesta”. Así la configuración de reintentos y los mensajes pueden distinguir un fallo de red de un error de la API.
- El token se lee con
getState().api()no es un componente ni un hook, así que no puede usaruseSesion(). Zustand permite leer el store desde cualquier función, y es uno de los motivos para elegirlo en lugar de Context, que solo se lee dentro de React. - El 401 cierra la sesión, pero solo si había token. Sin esa condición, una contraseña incorrecta en el login, que también es un 401, marcaría la sesión como caducada.
AbortErrorse relanza tal cual. TanStack Query cancela las peticiones que ya no hacen falta (cambias de página antes de que llegue la respuesta). Si lo convirtieras en un error, verías “No se puede conectar” cada vez que cambias rápido de página.
Figura 5. Un único punto convierte las respuestas en datos o en ErrorApi, y cada código tiene un destino en la interfaz.
6. Estado de cliente con Zustand
Hay tres stores, y los tres son pequeños. Ese es el tamaño normal del estado de cliente cuando los datos de la API viven en TanStack Query.
La sesión
src/compartido/estado/sesion.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { RespuestaLogin, Usuario } from '../api/tipos';
interface EstadoSesion {
token: string | null;
usuario: Usuario | null;
expiraEl: number | null;
motivoCierre: 'caducada' | null;
abrirSesion: (respuesta: RespuestaLogin) => void;
cerrarSesion: (motivo?: 'caducada') => void;
}
export const useSesion = create<EstadoSesion>()(
persist(
(set) => ({
token: null,
usuario: null,
expiraEl: null,
motivoCierre: null,
abrirSesion: ({ token, usuario, expiraEn }) =>
set({ token, usuario, expiraEl: Date.now() + expiraEn * 1000, motivoCierre: null }),
cerrarSesion: (motivo) => set({ token: null, usuario: null, expiraEl: null, motivoCierre: motivo ?? null }),
}),
{
name: 'empresa-sesion',
// Las funciones no se guardan, y el motivo solo tiene sentido en esta visita.
partialize: ({ token, usuario, expiraEl }) => ({ token, usuario, expiraEl }),
// Al recargar, una sesión ya caducada no llega a usarse.
onRehydrateStorage: () => (estado) => {
if (estado?.expiraEl && estado.expiraEl <= Date.now()) estado.cerrarSesion('caducada');
},
},
),
);
export const useEsAdmin = () => useSesion((s) => s.usuario?.rol === 'ADMIN');
persistguarda el store enlocalStoragecon la claveempresa-sesion. Al recargar, la sesión sigue ahí. Lo viste en el módulo 20.partializeelige qué se guarda.motivoCierreno se guarda a propósito: el aviso de “sesión caducada” tiene sentido en esta visita, no la semana que viene.expiraElconvierte los segundos relativos de la API (expiraEn: 3600) en un instante absoluto. Si guardaras3600, al recargar no sabrías cuánto queda.onRehydrateStoragese ejecuta al leerlocalStorage. Si la sesión guardada ya caducó, se descarta antes de que ninguna pantalla llegue a usarla.
⚠️ Importante: Guardar el token en
localStoragetiene un riesgo: si alguien consigue ejecutar JavaScript en tu página (un ataque XSS), puede leerlo. Es la opción habitual en una SPA servida como ficheros estáticos, y React ya escapa el texto por defecto, como viste en el módulo 02. Cuando tienes un servidor propio, la opción más segura es una cookiehttpOnly, que JavaScript no puede leer; el módulo 23 lo explica. En cualquier caso, la seguridad real está en la API: ocultar un botón en React no impide que alguien llame a la API a mano.
Avisos y tema
src/compartido/estado/avisos.ts
import { create } from 'zustand';
export interface Aviso {
id: number;
tipo: 'exito' | 'error';
texto: string;
}
interface EstadoAvisos {
avisos: Aviso[];
avisar: (tipo: Aviso['tipo'], texto: string) => void;
quitar: (id: number) => void;
}
let siguienteId = 1;
export const useAvisos = create<EstadoAvisos>((set, get) => ({
avisos: [],
avisar: (tipo, texto) => {
const id = siguienteId++;
set((s) => ({ avisos: [...s.avisos, { id, tipo, texto }] }));
setTimeout(() => get().quitar(id), 5000);
},
quitar: (id) => set((s) => ({ avisos: s.avisos.filter((a) => a.id !== id) })),
}));
src/compartido/estado/tema.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface EstadoTema {
oscuro: boolean;
alternar: () => void;
}
export const useTema = create<EstadoTema>()(
persist(
(set) => ({
oscuro: false,
alternar: () => set((s) => ({ oscuro: !s.oscuro })),
}),
{ name: 'empresa-tema' },
),
);
// La clase se aplica fuera de React para que no haya parpadeo al cargar.
const aplicar = (oscuro: boolean) => document.documentElement.classList.toggle('dark', oscuro);
aplicar(useTema.getState().oscuro);
useTema.subscribe((s) => aplicar(s.oscuro));
El store de tema aplica la clase .dark fuera de React, en el momento en que se importa el módulo y con subscribe. Si lo hicieras en un useEffect de un componente, la primera pintura saldría en claro y luego cambiaría a oscuro: el típico parpadeo. Además, así el tema funciona también en la pantalla de login, que no está dentro de Disposicion.
Los avisos se quitan solos a los 5 segundos con un setTimeout dentro de la acción. Una acción de Zustand es una función normal, y puede hacer cosas asíncronas sin ningún middleware.
7. Estado de servidor con TanStack Query
src/app/queryClient.ts
import { QueryClient } from '@tanstack/react-query';
import { ErrorApi } from '../compartido/api/cliente';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
// Durante 30 segundos, los datos en caché se consideran frescos.
staleTime: 30_000,
retry: (intentos, error) => {
// Repetir un 404 o un 403 no lo arregla; un fallo de red, quizá sí.
if (error instanceof ErrorApi && error.status >= 400 && error.status < 500) return false;
return intentos < 2;
},
},
},
});
Dos decisiones globales:
staleTimede 30 segundos. Con el valor por defecto, 0, cada vez que vuelves a una pantalla se repite la petición. Con 30 segundos, ir de Empleados al Panel y volver es instantáneo. Tras una mutación no hay que esperar a que caduque: se invalida a mano, como verás.- Sin reintentos para los 4xx. Un 404 o un 403 no se arreglan repitiendo la petición. Por defecto TanStack Query reintenta tres veces con espera creciente, y la pantalla tardaría varios segundos en mostrar “Empleado no encontrado”. Los fallos de red (status 0) y los 5xx sí se reintentan dos veces.
En cada funcionalidad, el fichero api.ts sigue el mismo patrón, el de las fábricas de consultas del módulo 19:
export const empleadosQueries = {
lista: (filtros) => queryOptions({ queryKey: ['empleados', 'lista', filtros], queryFn: ... }),
detalle: (id) => queryOptions({ queryKey: ['empleados', 'detalle', id], queryFn: ... }),
};
Las claves forman un árbol. Todas las de empleados empiezan por 'empleados', así que invalidateQueries({ queryKey: ['empleados'] }) invalida a la vez la lista con cualquier filtro, las opciones del desplegable y todos los detalles. Es la forma más sencilla de no olvidarte de ninguna.
8. Rutas, inicio de sesión y rutas protegidas
src/app/router.tsx
import { createBrowserRouter } from 'react-router';
import { Disposicion } from '../compartido/ui/Disposicion';
import { PaginaError, PaginaNoEncontrada } from '../compartido/ui/PaginaError';
import { Cargando } from '../compartido/ui/Estados';
import { RutaProtegida } from '../funcionalidades/sesion/RutaProtegida';
export const router = createBrowserRouter(
[
{
errorElement: <PaginaError />,
hydrateFallbackElement: <Cargando />,
children: [
{ path: '/login', lazy: () => import('../funcionalidades/sesion/PaginaLogin') },
{
element: <RutaProtegida />,
children: [
{
element: <Disposicion />,
// Un error de render en una página no se lleva por delante el menú.
errorElement: <PaginaError />,
children: [
{ path: '/', lazy: () => import('../funcionalidades/panel/PaginaPanel') },
{ path: '/empleados', lazy: () => import('../funcionalidades/empleados/PaginaEmpleados') },
{ path: '/empleados/nuevo', lazy: () => import('../funcionalidades/empleados/PaginaFormEmpleado') },
{ path: '/empleados/:id/editar', lazy: () => import('../funcionalidades/empleados/PaginaFormEmpleado') },
{ path: '/departamentos', lazy: () => import('../funcionalidades/departamentos/PaginaDepartamentos') },
{ path: '/departamentos/nuevo', lazy: () => import('../funcionalidades/departamentos/PaginaFormDepartamento') },
{ path: '/departamentos/:id/editar', lazy: () => import('../funcionalidades/departamentos/PaginaFormDepartamento') },
{ path: '/pedidos', lazy: () => import('../funcionalidades/pedidos/PaginaPedidos') },
{ path: '/pedidos/nuevo', lazy: () => import('../funcionalidades/pedidos/PaginaNuevoPedido') },
{ path: '/pedidos/:id', lazy: () => import('../funcionalidades/pedidos/PaginaPedido') },
{ path: '*', element: <PaginaNoEncontrada /> },
],
},
],
},
],
},
],
{ basename: import.meta.env.BASE_URL },
);
La estructura de rutas tiene tres niveles sin path, que solo agrupan:
- La raíz pone una pantalla de error para cualquier fallo y un
hydrateFallbackElement. Este último se muestra mientras se descarga la primera página perezosa, en lugar de una pantalla en blanco. RutaProtegidadeja pasar solo con sesión.Disposicionpinta el menú y la cabecera, y sus hijas aparecen en su<Outlet />. Tiene su propioerrorElement: si una página falla al renderizar, el error se muestra dentro del marco y el menú sigue funcionando.
basename: import.meta.env.BASE_URL sirve para publicar la aplicación en una subcarpeta, como en GitHub Pages: basta con cambiar base en vite.config.ts y el router lo sigue solo.
La ruta protegida
src/funcionalidades/sesion/api.ts
import { api } from '../../compartido/api/cliente';
import type { RespuestaLogin } from '../../compartido/api/tipos';
export interface Credenciales {
email: string;
password: string;
}
export const iniciarSesion = (credenciales: Credenciales) =>
api<RespuestaLogin>('/auth/login', { metodo: 'POST', cuerpo: credenciales });
// Solo admite rutas internas: /login?volver=https://otra-web.com no debe sacarte de aquí.
export function destinoSeguro(volver: string | null): string {
return volver && volver.startsWith('/') && !volver.startsWith('//') ? volver : '/';
}
src/funcionalidades/sesion/RutaProtegida.tsx
import { Navigate, Outlet, useLocation } from 'react-router';
import { useSesion } from '../../compartido/estado/sesion';
// Es un componente, no un loader: al suscribirse al store, reacciona
// también cuando la sesión se cierra en mitad de una pantalla.
export function RutaProtegida() {
const token = useSesion((s) => s.token);
const { pathname, search } = useLocation();
if (!token) {
return <Navigate to={`/login?volver=${encodeURIComponent(pathname + search)}`} replace />;
}
return <Outlet />;
}
En el módulo 18 protegiste rutas con un loader y con middleware. Aquí se usa un componente, por un motivo concreto: la sesión puede cerrarse en mitad de una pantalla. Cuando la API responde 401, api() llama a cerrarSesion, el store cambia y RutaProtegida, que está suscrita a token, vuelve a renderizar y redirige. Un loader solo se ejecuta al navegar, y no se enteraría hasta el siguiente clic en el menú.
destinoSeguro evita una redirección abierta. Sin ella, un enlace como /login?volver=https://web-falsa.com llevaría a tu usuario, recién autenticado y confiado, a otra web. Por eso solo se aceptan rutas que empiezan por una sola / (//web-falsa.com también es una URL externa para el navegador).
La página de inicio de sesión
src/funcionalidades/sesion/PaginaLogin.tsx
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { Navigate, useSearchParams } from 'react-router';
import { z } from 'zod';
import { useSesion } from '../../compartido/estado/sesion';
import { Boton } from '../../compartido/ui/Boton';
import { Campo } from '../../compartido/ui/Campos';
import { MensajeError } from '../../compartido/ui/Estados';
import { destinoSeguro, iniciarSesion } from './api';
const esquema = z.object({
email: z.email('Escribe un email válido'),
password: z.string().min(1, 'Escribe la contraseña'),
});
function PaginaLogin() {
const [params] = useSearchParams();
const token = useSesion((s) => s.token);
const motivoCierre = useSesion((s) => s.motivoCierre);
const abrirSesion = useSesion((s) => s.abrirSesion);
const entrar = useMutation({ mutationFn: iniciarSesion, onSuccess: abrirSesion });
const {
register,
handleSubmit,
formState: { errors },
} = useForm({ resolver: zodResolver(esquema), defaultValues: { email: '', password: '' } });
// Al abrir la sesión cambia el token, y este mismo render nos lleva al destino.
if (token) return <Navigate to={destinoSeguro(params.get('volver'))} replace />;
return (
<main className="flex min-h-screen items-center justify-center p-4">
<title>Iniciar sesión · EMPRESA</title>
<form
noValidate
onSubmit={handleSubmit((datos) => entrar.mutate(datos))}
className="flex w-96 max-w-full flex-col gap-4 rounded-xl border border-borde bg-superficie p-6"
>
<h1 className="text-center text-2xl font-bold tracking-wide text-enlace">EMPRESA</h1>
{motivoCierre === 'caducada' && (
<p role="status" className="rounded-md bg-peligro-fondo p-3 text-sm text-peligro">
Tu sesión ha caducado. Vuelve a entrar.
</p>
)}
{entrar.isError && <MensajeError error={entrar.error} />}
<Campo etiqueta="Email" type="email" autoComplete="username" error={errors.email?.message} {...register('email')} />
<Campo
etiqueta="Contraseña"
type="password"
autoComplete="current-password"
error={errors.password?.message}
{...register('password')}
/>
<Boton type="submit" variante="primario" disabled={entrar.isPending}>
{entrar.isPending ? 'Entrando...' : 'Entrar'}
</Boton>
</form>
</main>
);
}
export { PaginaLogin as Component };
- El inicio de sesión es una mutación de TanStack Query: cambia algo en el servidor (crea una sesión) y no se guarda en caché.
entrar.isPendingyentrar.errordan los estados sin unuseState. - No hay
navigate()tras el éxito.onSuccessguarda la sesión; el store cambia, la página vuelve a renderizar contokeny el<Navigate>del principio lleva al destino. Así el mismo código sirve si llegas a/logincon la sesión ya abierta. useFormno lleva tipos explícitos: los deduce dezodResolver(esquema). En los formularios con números sí harán falta, como verás.
9. Componentes compartidos
Son los ladrillos de todas las pantallas. Ninguno sabe nada de empleados ni de pedidos.
src/compartido/ui/Boton.tsx
import type { ComponentProps } from 'react';
import { Link, type LinkProps } from 'react-router';
const BASE =
'inline-flex items-center justify-center rounded-md px-3 py-1.5 text-sm font-medium transition-colors ' +
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-marca ' +
'disabled:cursor-not-allowed disabled:opacity-50';
const VARIANTES = {
primario: 'bg-marca text-white hover:bg-indigo-900',
secundario: 'border border-borde bg-superficie text-enlace hover:bg-fondo',
peligro: 'bg-red-700 text-white hover:bg-red-800',
};
type Variante = keyof typeof VARIANTES;
export function Boton({ variante = 'secundario', type = 'button', ...resto }: ComponentProps<'button'> & { variante?: Variante }) {
return <button type={type} className={`${BASE} ${VARIANTES[variante]}`} {...resto} />;
}
// Un enlace que parece un botón sigue siendo un enlace: navega, no ejecuta.
export function EnlaceBoton({ variante = 'secundario', ...resto }: LinkProps & { variante?: Variante }) {
return <Link className={`${BASE} ${VARIANTES[variante]}`} {...resto} />;
}
EnlaceBoton existe porque “Nuevo empleado” parece un botón pero navega. Si fuera un <button> con navigate(), no podrías abrirlo en otra pestaña con el botón central del ratón, y un lector de pantalla lo anunciaría como botón. La regla es sencilla: si cambia la URL, es un enlace.
Campos de formulario y ref como prop 🆕 19.0
🆕 Desde React 19.0. En React 18.3,
refno llega a un componente función como prop. Si usas una versión anterior: envuelveCampoyDesplegableconforwardRef, como se muestra en la sección 17. Sin ese cambio, React Hook Form no recibe la referencia del<input>y el formulario se envía siempre vacío.
src/compartido/ui/Campos.tsx
import { useId, type ComponentProps, type ReactNode } from 'react';
const CLASES_CONTROL =
'mt-1 w-full rounded-md border border-borde bg-superficie px-2.5 py-1.5 text-sm text-texto ' +
'focus:outline-2 focus:outline-marca aria-invalid:border-peligro';
interface Comunes {
etiqueta: string;
error?: string;
}
function Envoltura({ id, etiqueta, error, children }: Comunes & { id: string; children: ReactNode }) {
return (
<div>
<label htmlFor={id} className="block text-sm font-medium">
{etiqueta}
</label>
{children}
{error && (
<p id={`${id}-error`} className="mt-1 text-sm text-peligro">
{error}
</p>
)}
</div>
);
}
// `ref` llega dentro de `resto`: desde React 19 es una prop normal,
// y por eso funciona con {...register('campo')} sin forwardRef.
export function Campo({ etiqueta, error, ...resto }: ComponentProps<'input'> & Comunes) {
const id = useId();
return (
<Envoltura id={id} etiqueta={etiqueta} error={error}>
<input
id={id}
className={CLASES_CONTROL}
aria-invalid={error ? true : undefined}
aria-describedby={error ? `${id}-error` : undefined}
{...resto}
/>
</Envoltura>
);
}
export function Desplegable({ etiqueta, error, children, ...resto }: ComponentProps<'select'> & Comunes) {
const id = useId();
return (
<Envoltura id={id} etiqueta={etiqueta} error={error}>
<select
id={id}
className={CLASES_CONTROL}
aria-invalid={error ? true : undefined}
aria-describedby={error ? `${id}-error` : undefined}
{...resto}
>
{children}
</select>
</Envoltura>
);
}
register('email') devuelve { name, onChange, onBlur, ref }. Al escribir {...register('email')} sobre <Campo>, esa ref llega a Campo dentro de resto y de ahí al <input>. React Hook Form la usa para leer el valor y para mover el foco al primer campo con error. Lo viste en el módulo 03.
useId genera un id único para enlazar la etiqueta con su campo (htmlFor), y el mensaje de error con aria-describedby. Así un lector de pantalla lee “Email, no válido, Ese email ya está en uso” al llegar al campo, como en el módulo 24. Gracias a eso, las pruebas pueden buscar los campos con getByLabelText('Email'), igual que lo haría una persona.
Estados de carga, error y búsqueda
src/compartido/ui/Estados.tsx
import { useEffect, useState, type ReactNode } from 'react';
import { textoError } from '../api/cliente';
import { Boton } from './Boton';
export function Cargando() {
return (
<p role="status" className="p-6 text-sm text-suave">
Cargando...
</p>
);
}
export function MensajeError({ error, onReintentar }: { error: unknown; onReintentar?: () => void }) {
return (
<div role="alert" className="rounded-md bg-peligro-fondo p-3 text-sm text-peligro">
<p>{textoError(error)}</p>
{onReintentar && (
<div className="mt-2">
<Boton onClick={onReintentar}>Reintentar</Boton>
</div>
)}
</div>
);
}
export function Vacio({ children }: { children: ReactNode }) {
return <p className="p-6 text-center text-sm text-suave">{children}</p>;
}
// Escribe en la URL 300 ms después de la última tecla, no en cada una.
export function Buscador({ valor, onCambiar, etiqueta }: { valor: string; onCambiar: (texto: string) => void; etiqueta: string }) {
const [texto, setTexto] = useState(valor);
useEffect(() => {
if (texto === valor) return;
const temporizador = setTimeout(() => onCambiar(texto), 300);
return () => clearTimeout(temporizador);
}, [texto, valor, onCambiar]);
return (
<input
type="search"
aria-label={etiqueta}
placeholder={etiqueta}
value={texto}
onChange={(e) => setTexto(e.target.value)}
className="w-64 max-w-full rounded-md border border-borde bg-superficie px-2.5 py-1.5 text-sm"
/>
);
}
Buscador guarda el texto en un estado local y solo lo escribe en la URL 300 ms después de la última tecla. Si escribiera en cada tecla, cada letra crearía una petición y una entrada en el historial. El efecto se limpia con clearTimeout: si llega otra tecla antes de tiempo, el temporizador anterior se cancela. Es el debounce del módulo 12.
Títulos de documento 🆕 19.0
🆕 Desde React 19.0. React 18.3 no sube
<title>al<head>: lo pinta dentro del<body>y el título de la pestaña no cambia. Si usas una versión anterior: sustituye la etiqueta poruseEffect(() => { document.title = ... }, [titulo]).
src/compartido/ui/Encabezado.tsx
import type { ReactNode } from 'react';
// <title> dentro de un componente: React lo sube solo al <head>.
export function Encabezado({ titulo, children }: { titulo: string; children?: ReactNode }) {
return (
<header className="mb-5 flex flex-wrap items-center justify-between gap-3">
<title>{`${titulo} · EMPRESA`}</title>
<h1 className="text-2xl font-semibold text-enlace">{titulo}</h1>
{children && <div className="flex flex-wrap items-center gap-2">{children}</div>}
</header>
);
}
export function Tarjeta({ children }: { children: ReactNode }) {
return <section className="rounded-xl border border-borde bg-superficie p-5">{children}</section>;
}
El <title> lleva un único valor con plantilla de texto, {`${titulo} · EMPRESA`}, y no {titulo} · EMPRESA. Lo segundo crea varios nodos de texto hijos, y <title> debe recibir un único texto.
Tablas, orden y paginación
src/compartido/ui/Tabla.tsx
import type { ReactNode } from 'react';
import { Boton } from './Boton';
export const CLASE_CELDA = 'border-t border-borde px-3 py-2 text-sm';
export function Tabla({ titulo, cabecera, children, atenuada = false }: { titulo: string; cabecera: ReactNode; children: ReactNode; atenuada?: boolean }) {
return (
<div className="overflow-x-auto rounded-xl border border-borde bg-superficie">
<table className={`w-full text-left ${atenuada ? 'opacity-60' : ''}`}>
<caption className="sr-only">{titulo}</caption>
<thead>
<tr>{cabecera}</tr>
</thead>
<tbody>{children}</tbody>
</table>
</div>
);
}
interface PropsColumna {
campo?: string;
orden?: string;
onOrdenar?: (orden: string) => void;
children?: ReactNode;
}
// La columna activa se marca con aria-sort. Pulsar de nuevo invierte el orden.
export function Columna({ campo, orden, onOrdenar, children }: PropsColumna) {
const clases = 'px-3 py-2 text-xs font-semibold tracking-wide text-suave uppercase';
if (!campo || !onOrdenar) return <th scope="col" className={clases}>{children}</th>;
const ascendente = orden === campo;
const descendente = orden === `-${campo}`;
return (
<th scope="col" className={clases} aria-sort={ascendente ? 'ascending' : descendente ? 'descending' : 'none'}>
<button type="button" className="cursor-pointer uppercase hover:text-enlace" onClick={() => onOrdenar(ascendente ? `-${campo}` : campo)}>
{children} {ascendente ? '▲' : descendente ? '▼' : ''}
</button>
</th>
);
}
export function Paginacion({ pagina, paginas, total, onCambiar }: { pagina: number; paginas: number; total: number; onCambiar: (pagina: number) => void }) {
return (
<nav aria-label="Paginación" className="mt-3 flex items-center justify-between gap-3 text-sm text-suave">
<span>
{total} {total === 1 ? 'resultado' : 'resultados'}, página {pagina} de {Math.max(paginas, 1)}
</span>
<span className="flex gap-2">
<Boton disabled={pagina <= 1} onClick={() => onCambiar(pagina - 1)}>
Anterior
</Boton>
<Boton disabled={pagina >= paginas} onClick={() => onCambiar(pagina + 1)}>
Siguiente
</Boton>
</span>
</nav>
);
}
<caption className="sr-only">da nombre a la tabla para los lectores de pantalla sin ocupar sitio en pantalla.- La cabecera ordenable es un
<button>dentro del<th>, y la columna activa llevaaria-sort. Un<th onClick>no se puede usar con el teclado. atenuadabaja la opacidad mientras llega la página siguiente. Lo usarás conisPlaceholderData.
El diálogo de confirmación
src/compartido/ui/DialogoConfirmar.tsx
import { useEffect, useRef, type ReactNode } from 'react';
import { Boton } from './Boton';
import { MensajeError } from './Estados';
interface Props {
titulo: string;
children: ReactNode;
pendiente: boolean;
error: unknown;
onConfirmar: () => void;
onCancelar: () => void;
}
// <dialog> con showModal() ya bloquea el fondo, atrapa el foco y cierra con Esc.
export function DialogoConfirmar({ titulo, children, pendiente, error, onConfirmar, onCancelar }: Props) {
const dialogo = useRef<HTMLDialogElement>(null);
useEffect(() => {
const nodo = dialogo.current;
nodo?.showModal();
return () => nodo?.close();
}, []);
return (
<dialog
ref={dialogo}
aria-labelledby="dialogo-titulo"
onCancel={(e) => {
e.preventDefault();
onCancelar();
}}
className="m-auto w-[28rem] max-w-[90vw] rounded-xl border border-borde bg-superficie p-5 text-texto backdrop:bg-black/50"
>
<h2 id="dialogo-titulo" className="mb-2 text-lg font-semibold">
{titulo}
</h2>
<div className="mb-4 text-sm">{children}</div>
{error != null && (
<div className="mb-4">
<MensajeError error={error} />
</div>
)}
<div className="flex justify-end gap-2">
<Boton onClick={onCancelar}>Cancelar</Boton>
<Boton variante="peligro" disabled={pendiente} onClick={onConfirmar}>
{pendiente ? 'Borrando...' : 'Borrar'}
</Boton>
</div>
</dialog>
);
}
El elemento nativo <dialog> con showModal() hace solo lo que antes costaba una librería: oscurece el fondo (::backdrop), atrapa el foco dentro, lo devuelve al cerrar y cierra con Esc. onCancel intercepta la tecla Esc para que sea React quien decida cerrarlo, desmontando el componente.
El diálogo recibe el error de la mutación y lo muestra dentro. Es la clave para los 409: si el borrado falla, el diálogo no se cierra, y el usuario ve el motivo en el mismo sitio donde pulsó.
Avisos, marco de la aplicación y errores de ruta
src/compartido/ui/ZonaAvisos.tsx
import { useAvisos } from '../estado/avisos';
export function ZonaAvisos() {
const avisos = useAvisos((s) => s.avisos);
return (
<div aria-live="polite" className="fixed right-4 bottom-4 z-10 flex w-80 max-w-[90vw] flex-col gap-2">
{avisos.map((aviso) => (
<p
key={aviso.id}
role={aviso.tipo === 'error' ? 'alert' : 'status'}
className={`rounded-md p-3 text-sm shadow-md ${aviso.tipo === 'error' ? 'bg-peligro-fondo text-peligro' : 'bg-exito-fondo text-exito'}`}
>
{aviso.texto}
</p>
))}
</div>
);
}
src/compartido/ui/Disposicion.tsx
import { useQueryClient } from '@tanstack/react-query';
import { NavLink, Outlet } from 'react-router';
import { useSesion } from '../estado/sesion';
import { useTema } from '../estado/tema';
import { Boton } from './Boton';
import { ZonaAvisos } from './ZonaAvisos';
const ENLACES = [
{ a: '/', texto: 'Panel' },
{ a: '/empleados', texto: 'Empleados' },
{ a: '/departamentos', texto: 'Departamentos' },
{ a: '/pedidos', texto: 'Pedidos' },
];
export function Disposicion() {
const usuario = useSesion((s) => s.usuario);
const cerrarSesion = useSesion((s) => s.cerrarSesion);
const oscuro = useTema((s) => s.oscuro);
const alternarTema = useTema((s) => s.alternar);
const queryClient = useQueryClient();
function salir() {
cerrarSesion();
// La caché es de este usuario: el siguiente no debe verla.
queryClient.clear();
}
return (
<div className="flex min-h-screen flex-col md:flex-row">
<aside className="border-b border-borde bg-superficie p-4 md:w-52 md:shrink-0 md:border-r md:border-b-0">
<p className="mb-4 text-lg font-bold tracking-wide text-enlace">EMPRESA</p>
<nav aria-label="Principal" className="flex gap-1 md:flex-col">
{ENLACES.map(({ a, texto }) => (
<NavLink
key={a}
to={a}
end={a === '/'}
className={({ isActive }) =>
`rounded-md px-3 py-1.5 text-sm ${isActive ? 'bg-marca font-medium text-white' : 'text-texto hover:bg-fondo'}`
}
>
{texto}
</NavLink>
))}
</nav>
</aside>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-end gap-3 border-b border-borde bg-superficie px-5 py-2 text-sm">
<span>
{usuario?.nombre} <span className="text-suave">({usuario?.rol})</span>
</span>
<Boton aria-pressed={oscuro} onClick={alternarTema}>
Tema oscuro
</Boton>
<Boton onClick={salir}>Salir</Boton>
</div>
<main className="mx-auto max-w-5xl p-5">
<Outlet />
</main>
</div>
<ZonaAvisos />
</div>
);
}
Al salir se llama a queryClient.clear(). Las lecturas de esta API son públicas, pero en una aplicación real la caché puede contener datos que el siguiente usuario no debe ver.
src/compartido/ui/PaginaError.tsx
import { isRouteErrorResponse, Link, useRouteError } from 'react-router';
import { textoError } from '../api/cliente';
function Mensaje({ titulo, texto }: { titulo: string; texto: string }) {
return (
<div className="mx-auto max-w-lg p-8 text-center">
<title>{`${titulo} · EMPRESA`}</title>
<h1 className="mb-2 text-2xl font-semibold text-enlace">{titulo}</h1>
<p className="mb-4 text-suave">{texto}</p>
<Link to="/" className="text-enlace underline">
Volver al panel
</Link>
</div>
);
}
export function PaginaNoEncontrada() {
return <Mensaje titulo="Página no encontrada" texto="Esa dirección no existe en la aplicación." />;
}
export function PaginaError() {
const error = useRouteError();
if (isRouteErrorResponse(error) && error.status === 404) return <PaginaNoEncontrada />;
return <Mensaje titulo="Algo ha fallado" texto={textoError(error)} />;
}
Utilidades
src/compartido/formato.ts
const euros = new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' });
const fechas = new Intl.DateTimeFormat('es-ES', { dateStyle: 'medium', timeZone: 'UTC' });
export const formatearEuros = (valor: number) => euros.format(valor);
export const formatearFecha = (iso: string) => fechas.format(new Date(iso));
src/compartido/hooks/useFiltrosUrl.ts
import { useCallback } from 'react';
import { useSearchParams } from 'react-router';
// Los filtros viven en la URL: se pueden compartir y sobreviven a una recarga.
export function useFiltrosUrl(ordenInicial: string) {
const [params, setParams] = useSearchParams();
const cambiar = useCallback(
(cambios: Record<string, string>) => {
setParams(
(anteriores) => {
const nuevos = new URLSearchParams(anteriores);
for (const [clave, valor] of Object.entries(cambios)) {
if (valor) nuevos.set(clave, valor);
else nuevos.delete(clave);
}
// Cualquier filtro nuevo vuelve a la primera página.
if (!('pagina' in cambios)) nuevos.delete('pagina');
return nuevos;
},
{ replace: true },
);
},
[setParams],
);
return {
q: params.get('q') ?? '',
orden: params.get('orden') ?? ordenInicial,
pagina: Number(params.get('pagina') ?? 1),
leer: (clave: string) => params.get(clave) ?? '',
cambiar,
};
}
Los filtros viven en la URL con useSearchParams, como en el módulo 18. Así puedes compartir un enlace a “empleados de VENTAS ordenados por salario”, y una recarga no pierde nada. Tres detalles:
setParamsrecibe una función que parte de los parámetros anteriores. Si construyeras los nuevos desde cero, cambiar el orden borraría la búsqueda.replace: truesustituye la entrada del historial en lugar de añadir una. Si no, el botón Atrás recorrería cada letra que escribiste.- Cambiar cualquier filtro borra
pagina. Si estás en la página 3 y buscas “garcia”, que tiene un solo resultado, la página 3 estaría vacía.
10. El panel
src/funcionalidades/panel/api.ts
import { queryOptions } from '@tanstack/react-query';
import { api } from '../../compartido/api/cliente';
import type { Estadisticas } from '../../compartido/api/tipos';
export const consultaEstadisticas = () =>
queryOptions({
queryKey: ['estadisticas'],
queryFn: ({ signal }) => api<Estadisticas>('/estadisticas', { senal: signal }),
});
src/funcionalidades/panel/GraficoBarras.tsx
interface Fila {
etiqueta: string;
valor: number;
texto: string;
}
// Una tabla real con barras dentro: se lee con lector de pantalla y sin colores.
export function GraficoBarras({ titulo, filas }: { titulo: string; filas: Fila[] }) {
const maximo = Math.max(...filas.map((f) => f.valor), 1);
return (
<figure>
<figcaption className="mb-3 text-sm font-semibold">{titulo}</figcaption>
<table className="w-full text-sm">
<tbody>
{filas.map(({ etiqueta, valor, texto }) => (
<tr key={etiqueta} title={`${etiqueta}: ${texto}`} className="hover:bg-fondo">
<th scope="row" className="w-32 py-1 pr-2 text-left text-xs font-normal text-suave">
{etiqueta}
</th>
<td className="py-1">
<div className="h-3 min-w-1 rounded-r bg-marca dark:bg-indigo-300" style={{ width: `${(valor / maximo) * 100}%` }} />
</td>
<td className="w-24 py-1 pl-2 text-right text-xs tabular-nums">{texto}</td>
</tr>
))}
</tbody>
</table>
</figure>
);
}
src/funcionalidades/panel/PaginaPanel.tsx
import { useQuery } from '@tanstack/react-query';
import { ESTADOS_PEDIDO } from '../../compartido/api/tipos';
import { formatearEuros } from '../../compartido/formato';
import { Encabezado, Tarjeta } from '../../compartido/ui/Encabezado';
import { Cargando, MensajeError } from '../../compartido/ui/Estados';
import { consultaEstadisticas } from './api';
import { GraficoBarras } from './GraficoBarras';
function Dato({ etiqueta, valor }: { etiqueta: string; valor: string | number }) {
return (
<div className="rounded-xl border border-borde bg-superficie p-4">
<p className="text-xs text-suave">{etiqueta}</p>
<p className="mt-1 text-2xl font-semibold tabular-nums">{valor}</p>
</div>
);
}
function PaginaPanel() {
const { data, isPending, isError, error, refetch } = useQuery(consultaEstadisticas());
return (
<>
<Encabezado titulo="Panel" />
{isPending && <Cargando />}
{isError && <MensajeError error={error} onReintentar={refetch} />}
{data && (
<>
<div className="mb-5 grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-6">
<Dato etiqueta="Empleados" valor={data.empleados} />
<Dato etiqueta="Departamentos" valor={data.departamentos} />
<Dato etiqueta="Clientes" valor={data.clientes} />
<Dato etiqueta="Productos" valor={data.productos} />
<Dato etiqueta="Pedidos" valor={data.pedidos} />
<Dato etiqueta="Facturación" valor={formatearEuros(data.facturacion)} />
</div>
<div className="grid gap-4 lg:grid-cols-2">
<Tarjeta>
<GraficoBarras
titulo="Masa salarial mensual por departamento"
filas={data.porDepartamento.map((d) => ({
etiqueta: d.nombre,
valor: d.masaSalarial,
texto: formatearEuros(d.masaSalarial),
}))}
/>
</Tarjeta>
<Tarjeta>
<GraficoBarras
titulo="Pedidos por estado"
filas={ESTADOS_PEDIDO.map((estado) => ({
etiqueta: estado,
valor: data.porEstado[estado],
texto: String(data.porEstado[estado]),
}))}
/>
</Tarjeta>
</div>
</>
)}
</>
);
}
export { PaginaPanel as Component };
Para dos gráficos de barras sencillos no hace falta una librería. Cada gráfico es una <table> con tres columnas: la etiqueta, la barra (un div con width en porcentaje) y el valor escrito. Esto tiene ventajas reales:
- Es accesible sin esfuerzo. Un lector de pantalla lo recorre como una tabla, fila a fila.
- El valor siempre se lee. La barra solo sirve para comparar de un vistazo; el número exacto está escrito al lado, y el atributo
titlelo repite al pasar el ratón. - Un solo color. Cada gráfico tiene una única serie de datos, y no hace falta leyenda: el título dice qué se mide.
Fíjate en que ESTADOS_PEDIDO fija el orden de las barras. Object.entries(data.porEstado) también funcionaría hoy, pero dependería del orden en que la API escriba las claves.
11. Departamentos: el primer CRUD
El CRUD de departamentos es el más sencillo y sirve de modelo para el de empleados.
Esquema y conversión de tipos
src/funcionalidades/departamentos/esquema.ts
import { z } from 'zod';
// Un campo numérico opcional llega como cadena: '' significa null.
export const numeroOpcional = (mensaje: string) =>
z
.string()
.transform((v) => (v.trim() === '' ? null : Number(v)))
.refine((v) => v === null || (Number.isFinite(v) && v >= 0), mensaje);
export const esquemaDepartamento = z.object({
nombre: z.string().trim().min(1, 'El nombre es obligatorio'),
localidad: z
.string()
.trim()
.transform((v) => v || null),
presupuesto: numeroOpcional('El presupuesto no puede ser negativo'),
});
export type EntradaDepartamento = z.input<typeof esquemaDepartamento>;
export type DatosDepartamento = z.output<typeof esquemaDepartamento>;
Un <input type="number"> entrega siempre una cadena, y el campo vacío es ''. La API espera un número o null. El esquema hace la conversión:
| El usuario escribe | El formulario tiene | Zod entrega |
|---|---|---|
| (nada) | '' | null |
1500 | '1500' | 1500 |
-3 | '-3' | error: “El presupuesto no puede ser negativo” |
Por eso el esquema tiene dos tipos: z.input (lo que maneja el formulario, todo cadenas) y z.output (lo que recibe tu código al enviar). En el módulo 21 usaste valueAsNumber, que convierte el vacío en NaN. Con un número opcional, el esquema es más claro: '' significa “sin valor”.
⚠️ Importante: Si en
defaultValuesponespresupuesto: departamento.presupuestosin convertir, el departamento 70 meteríanullen un campo de texto. React avisa de que un<input>pasa de no controlado a controlado, y TypeScript no lo detecta si el tipo de entrada no está bien declarado. Por eso se escribedepartamento?.presupuesto?.toString() ?? ''.
Consultas y mutaciones
src/funcionalidades/departamentos/api.ts
import { queryOptions, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '../../compartido/api/cliente';
import type { Departamento, Pagina } from '../../compartido/api/tipos';
import type { DatosDepartamento } from './esquema';
const RAIZ = ['departamentos'];
export const departamentosQueries = {
lista: () =>
queryOptions({
queryKey: [...RAIZ, 'lista'],
queryFn: ({ signal }) => api<Pagina<Departamento>>('/departamentos?orden=nombre&tamano=100', { senal: signal }),
select: (pagina) => pagina.contenido,
}),
detalle: (id: string) =>
queryOptions({
queryKey: [...RAIZ, 'detalle', id],
queryFn: ({ signal }) => api<Departamento>(`/departamentos/${id}`, { senal: signal }),
}),
};
export function useGuardarDepartamento(id?: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (datos: DatosDepartamento) =>
id
? api<Departamento>(`/departamentos/${id}`, { metodo: 'PUT', cuerpo: datos })
: api<Departamento>('/departamentos', { metodo: 'POST', cuerpo: datos }),
onSuccess: () =>
Promise.all([
queryClient.invalidateQueries({ queryKey: RAIZ }),
queryClient.invalidateQueries({ queryKey: ['estadisticas'] }),
// El listado de empleados incluye el nombre del departamento.
queryClient.invalidateQueries({ queryKey: ['empleados'] }),
]),
});
}
export function useBorrarDepartamento() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => api<void>(`/departamentos/${id}`, { metodo: 'DELETE' }),
onSuccess: () =>
Promise.all([
queryClient.invalidateQueries({ queryKey: RAIZ }),
queryClient.invalidateQueries({ queryKey: ['estadisticas'] }),
]),
});
}
Tras guardar un departamento se invalidan tres familias de claves: los departamentos, las estadísticas (cuentan departamentos) y los empleados, porque cada empleado del listado muestra el nombre de su departamento. Si renombras VENTAS y no invalidas empleados, el listado de empleados seguirá diciendo VENTAS durante 30 segundos. Este es el error más típico de TanStack Query: no es un fallo de la librería, es una dependencia entre datos que solo tú conoces.
El listado
src/funcionalidades/departamentos/PaginaDepartamentos.tsx
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import { Link } from 'react-router';
import type { Departamento } from '../../compartido/api/tipos';
import { useAvisos } from '../../compartido/estado/avisos';
import { useEsAdmin } from '../../compartido/estado/sesion';
import { formatearEuros } from '../../compartido/formato';
import { Boton, EnlaceBoton } from '../../compartido/ui/Boton';
import { DialogoConfirmar } from '../../compartido/ui/DialogoConfirmar';
import { Encabezado } from '../../compartido/ui/Encabezado';
import { Cargando, MensajeError } from '../../compartido/ui/Estados';
import { CLASE_CELDA, Columna, Tabla } from '../../compartido/ui/Tabla';
import { departamentosQueries, useBorrarDepartamento } from './api';
function PaginaDepartamentos() {
const { data, isPending, isError, error, refetch } = useQuery(departamentosQueries.lista());
const esAdmin = useEsAdmin();
const avisar = useAvisos((s) => s.avisar);
const borrar = useBorrarDepartamento();
const [aBorrar, setABorrar] = useState<Departamento | null>(null);
function cerrarDialogo() {
setABorrar(null);
borrar.reset();
}
function confirmarBorrado() {
if (!aBorrar) return;
borrar.mutate(aBorrar.deptId, {
onSuccess: () => {
avisar('exito', `Departamento ${aBorrar.nombre} borrado`);
cerrarDialogo();
},
});
}
return (
<>
<Encabezado titulo="Departamentos">
<EnlaceBoton to="/departamentos/nuevo" variante="primario">
Nuevo departamento
</EnlaceBoton>
</Encabezado>
{isPending && <Cargando />}
{isError && <MensajeError error={error} onReintentar={refetch} />}
{data && (
<Tabla
titulo="Departamentos"
cabecera={
<>
<Columna>Nombre</Columna>
<Columna>Localidad</Columna>
<Columna>Presupuesto</Columna>
<Columna>Acciones</Columna>
</>
}
>
{data.map((departamento) => (
<tr key={departamento.deptId}>
<td className={CLASE_CELDA}>{departamento.nombre}</td>
<td className={CLASE_CELDA}>{departamento.localidad ?? '—'}</td>
<td className={`${CLASE_CELDA} tabular-nums`}>
{departamento.presupuesto === null ? '—' : formatearEuros(departamento.presupuesto)}
</td>
<td className={`${CLASE_CELDA} flex flex-wrap gap-2`}>
<EnlaceBoton to={`/departamentos/${departamento.deptId}/editar`} aria-label={`Editar ${departamento.nombre}`}>
Editar
</EnlaceBoton>
<Link to={`/empleados?deptId=${departamento.deptId}`} className="self-center text-sm text-enlace underline">
Ver empleados
</Link>
{esAdmin && (
<Boton aria-label={`Borrar ${departamento.nombre}`} onClick={() => setABorrar(departamento)}>
Borrar
</Boton>
)}
</td>
</tr>
))}
</Tabla>
)}
{aBorrar && (
<DialogoConfirmar
titulo="Borrar departamento"
pendiente={borrar.isPending}
error={borrar.error}
onConfirmar={confirmarBorrado}
onCancelar={cerrarDialogo}
>
¿Seguro que quieres borrar <strong>{aBorrar.nombre}</strong>?
</DialogoConfirmar>
)}
</>
);
}
export { PaginaDepartamentos as Component };
El flujo del borrado:
- El botón Borrar guarda el departamento en
aBorrar, y eso abre el diálogo. - Confirmar llama a
borrar.mutate. Mientras tanto,borrar.isPendingdesactiva el botón para que un doble clic no envíe dos peticiones. - Si va bien, aviso de éxito y diálogo cerrado.
- Si la API responde 409,
borrar.errorllega al diálogo y se muestra dentro. - Al cancelar,
borrar.reset()borra el error. Si no lo hicieras, al abrir el diálogo para otro departamento verías el error del anterior.

Figura 6. Un 409 real de la API. El diálogo sigue abierto y explica por qué no se ha borrado.
Errores del servidor en el formulario
src/compartido/api/formularios.ts
import type { FieldValues, Path, UseFormSetError } from 'react-hook-form';
import { ErrorApi, textoError } from './cliente';
// Lleva cada error de un 422 a su campo. Lo que no tenga campo en este
// formulario (o cualquier otro error, como un 409) va a la raíz.
export function aplicarErrores<T extends FieldValues>(
error: unknown,
setError: UseFormSetError<T>,
campos: readonly Path<T>[],
) {
const detalles = error instanceof ErrorApi ? (error.detalles ?? {}) : {};
const conCampo = campos.filter((campo) => detalles[campo]);
conCampo.forEach((campo, indice) => {
setError(campo, { type: 'servidor', message: detalles[campo] }, { shouldFocus: indice === 0 });
});
const sinCampo = Object.keys(detalles).filter((clave) => !conCampo.some((c) => c === clave));
if (conCampo.length === 0 || sinCampo.length > 0) {
const mensaje = sinCampo.length > 0 ? sinCampo.map((c) => detalles[c]).join('. ') : textoError(error);
setError('root', { type: 'servidor', message: mensaje });
}
}
Esta función reparte los errores de la API:
- Si es un 422 con
detalles, cada error va a su campo consetError, y el foco se mueve al primero. - Lo que no tiene campo en este formulario (o un 409, o un fallo de red) va a
errors.root, que se muestra arriba del formulario.
Es necesaria porque la validación del cliente no puede saberlo todo. Zod comprueba que el nombre no esté vacío, pero solo la API sabe si ya existe otro departamento llamado VENTAS. Los errores de root se borran solos en el siguiente envío.
El formulario
src/funcionalidades/departamentos/PaginaFormDepartamento.tsx
import { zodResolver } from '@hookform/resolvers/zod';
import { useQuery } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { useNavigate, useParams } from 'react-router';
import { aplicarErrores } from '../../compartido/api/formularios';
import type { Departamento } from '../../compartido/api/tipos';
import { useAvisos } from '../../compartido/estado/avisos';
import { Boton, EnlaceBoton } from '../../compartido/ui/Boton';
import { Campo } from '../../compartido/ui/Campos';
import { Encabezado, Tarjeta } from '../../compartido/ui/Encabezado';
import { Cargando, MensajeError } from '../../compartido/ui/Estados';
import { departamentosQueries, useGuardarDepartamento } from './api';
import { esquemaDepartamento, type DatosDepartamento, type EntradaDepartamento } from './esquema';
const CAMPOS = ['nombre', 'localidad', 'presupuesto'] as const;
function Formulario({ id, departamento }: { id?: string; departamento?: Departamento }) {
const navegar = useNavigate();
const guardar = useGuardarDepartamento(id);
const avisar = useAvisos((s) => s.avisar);
const {
register,
handleSubmit,
setError,
formState: { errors, isSubmitting },
} = useForm<EntradaDepartamento, unknown, DatosDepartamento>({
resolver: zodResolver(esquemaDepartamento),
defaultValues: {
nombre: departamento?.nombre ?? '',
localidad: departamento?.localidad ?? '',
presupuesto: departamento?.presupuesto?.toString() ?? '',
},
});
const alEnviar = handleSubmit(async (datos) => {
try {
await guardar.mutateAsync(datos);
avisar('exito', `Departamento ${datos.nombre} guardado`);
navegar('/departamentos');
} catch (error) {
aplicarErrores(error, setError, CAMPOS);
}
});
return (
<Tarjeta>
<form noValidate onSubmit={alEnviar} className="flex max-w-md flex-col gap-4">
{errors.root && <MensajeError error={new Error(errors.root.message)} />}
<Campo etiqueta="Nombre" error={errors.nombre?.message} {...register('nombre')} />
<Campo etiqueta="Localidad" error={errors.localidad?.message} {...register('localidad')} />
<Campo etiqueta="Presupuesto (€)" type="number" step="any" min="0" error={errors.presupuesto?.message} {...register('presupuesto')} />
<div className="flex gap-2">
<Boton type="submit" variante="primario" disabled={isSubmitting}>
{isSubmitting ? 'Guardando...' : 'Guardar'}
</Boton>
<EnlaceBoton to="/departamentos">Cancelar</EnlaceBoton>
</div>
</form>
</Tarjeta>
);
}
function PaginaFormDepartamento() {
const { id } = useParams();
const consulta = useQuery({ ...departamentosQueries.detalle(id ?? ''), enabled: id !== undefined });
return (
<>
<Encabezado titulo={id ? 'Editar departamento' : 'Nuevo departamento'} />
{id === undefined ? (
<Formulario />
) : consulta.isError ? (
<MensajeError error={consulta.error} />
) : consulta.data ? (
<Formulario key={id} id={id} departamento={consulta.data} />
) : (
<Cargando />
)}
</>
);
}
export { PaginaFormDepartamento as Component };
- Una sola página sirve para crear y para editar. Si la URL tiene
:id, se carga el departamento. useQueryllevaenabled: id !== undefined: en/departamentos/nuevono hay nada que pedir.- El formulario se monta solo cuando los datos han llegado.
defaultValuesse lee una vez, al montar; si montaras el formulario vacío y luego llegaran los datos, se quedaría vacío. key={id}fuerza un formulario nuevo si cambias de un departamento a otro sin pasar por el listado. Es la técnica de reinicio conkeydel módulo 05.mutateAsyncen lugar demutate: devuelve una promesa, y asíhandleSubmitespera a la respuesta yisSubmittinges verdadero todo el tiempo.
12. Empleados: listado con filtros y formulario completo
src/funcionalidades/empleados/esquema.ts
import { z } from 'zod';
import { numeroOpcional } from '../departamentos/esquema';
const obligatorio = (mensaje: string) => z.string().trim().min(1, mensaje);
export const esquemaEmpleado = z.object({
nombre: obligatorio('El nombre es obligatorio'),
apellidos: obligatorio('Los apellidos son obligatorios'),
email: z.string().trim().pipe(z.email('El email no es válido')),
telefono: z
.string()
.trim()
.transform((v) => v || null),
oficio: obligatorio('El oficio es obligatorio'),
salario: z.coerce.number<string>('Escribe un número').positive('El salario debe ser mayor que cero'),
comision: numeroOpcional('La comisión no puede ser negativa'),
deptId: numeroOpcional('Elige un departamento'),
jefeId: numeroOpcional('Elige un jefe'),
});
export type EntradaEmpleado = z.input<typeof esquemaEmpleado>;
export type DatosEmpleado = z.output<typeof esquemaEmpleado>;
z.coerce.number<string>() convierte la cadena en número. El parámetro de tipo <string> le dice a TypeScript que la entrada es una cadena; sin él, el tipo de entrada sería unknown y defaultValues aceptaría cualquier cosa. coerce convierte '' en 0, así que un salario vacío falla con “El salario debe ser mayor que cero”, que es justo lo que queremos.
z.string().trim().pipe(z.email(...)) primero quita espacios y después valida. Si alguien pega " eva@empresa.es ", se acepta y se envía limpio.
src/funcionalidades/empleados/api.ts
import { keepPreviousData, queryOptions, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '../../compartido/api/cliente';
import type { Empleado, Pagina } from '../../compartido/api/tipos';
import type { DatosEmpleado } from './esquema';
const RAIZ = ['empleados'];
export interface FiltrosEmpleados {
q: string;
orden: string;
pagina: number;
deptId: string;
}
export const empleadosQueries = {
// Los filtros forman parte de la clave: cada combinación tiene su entrada en la caché.
lista: (filtros: FiltrosEmpleados) =>
queryOptions({
queryKey: [...RAIZ, 'lista', filtros],
queryFn: ({ signal }) => {
const { q, orden, pagina, deptId } = filtros;
const params = new URLSearchParams({ q, orden, pagina: String(pagina), tamano: '10' });
if (deptId) params.set('deptId', deptId);
return api<Pagina<Empleado>>(`/empleados?${params}`, { senal: signal });
},
// Al cambiar de página, la tabla anterior sigue visible hasta tener la nueva.
placeholderData: keepPreviousData,
}),
opciones: () =>
queryOptions({
queryKey: [...RAIZ, 'opciones'],
queryFn: ({ signal }) => api<Pagina<Empleado>>('/empleados?orden=apellidos&tamano=100', { senal: signal }),
select: (pagina) => pagina.contenido,
}),
detalle: (id: string) =>
queryOptions({
queryKey: [...RAIZ, 'detalle', id],
queryFn: ({ signal }) => api<Empleado>(`/empleados/${id}`, { senal: signal }),
}),
};
function useInvalidarEmpleados() {
const queryClient = useQueryClient();
return () =>
Promise.all([
// Invalida lista, opciones y detalle: todas las claves empiezan por 'empleados'.
queryClient.invalidateQueries({ queryKey: RAIZ }),
queryClient.invalidateQueries({ queryKey: ['estadisticas'] }),
]);
}
export function useGuardarEmpleado(id?: string) {
const invalidar = useInvalidarEmpleados();
return useMutation({
mutationFn: (datos: DatosEmpleado) =>
id
? api<Empleado>(`/empleados/${id}`, { metodo: 'PUT', cuerpo: datos })
: api<Empleado>('/empleados', { metodo: 'POST', cuerpo: datos }),
onSuccess: invalidar,
});
}
export function useBorrarEmpleado() {
const invalidar = useInvalidarEmpleados();
return useMutation({
mutationFn: (id: number) => api<void>(`/empleados/${id}`, { metodo: 'DELETE' }),
onSuccess: invalidar,
});
}
- Los filtros forman parte de la clave.
['empleados', 'lista', { q: 'garcia', pagina: 1, ... }]y['empleados', 'lista', { q: '', pagina: 2, ... }]son entradas distintas. Si vuelves a una combinación que ya visitaste, aparece al instante. placeholderData: keepPreviousDatamantiene la tabla anterior mientras llega la nueva página, en vez de mostrar “Cargando…” y hacer saltar la pantalla.isPlaceholderDataindica que lo que ves es la página anterior, y la tabla lo muestra atenuada.tamano: '10'fija las páginas de 10 empleados. Los datos tienen 24, así que hay tres páginas para probar la paginación.

Figura 7. El listado de empleados. Búsqueda, filtro, orden y página viven en la URL.
src/funcionalidades/empleados/PaginaEmpleados.tsx
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import { useLocation } from 'react-router';
import type { Empleado } from '../../compartido/api/tipos';
import { useAvisos } from '../../compartido/estado/avisos';
import { useEsAdmin } from '../../compartido/estado/sesion';
import { formatearEuros } from '../../compartido/formato';
import { useFiltrosUrl } from '../../compartido/hooks/useFiltrosUrl';
import { Boton, EnlaceBoton } from '../../compartido/ui/Boton';
import { DialogoConfirmar } from '../../compartido/ui/DialogoConfirmar';
import { Encabezado } from '../../compartido/ui/Encabezado';
import { Buscador, Cargando, MensajeError, Vacio } from '../../compartido/ui/Estados';
import { CLASE_CELDA, Columna, Paginacion, Tabla } from '../../compartido/ui/Tabla';
import { departamentosQueries } from '../departamentos/api';
import { empleadosQueries, useBorrarEmpleado } from './api';
function PaginaEmpleados() {
const { q, orden, pagina, leer, cambiar } = useFiltrosUrl('apellidos');
const deptId = leer('deptId');
// El formulario la recibe para volver a esta misma vista al guardar.
const volver = { busqueda: useLocation().search };
const { data, isPending, isError, error, refetch, isPlaceholderData } = useQuery(
empleadosQueries.lista({ q, orden, pagina, deptId }),
);
const { data: departamentos = [] } = useQuery(departamentosQueries.lista());
const esAdmin = useEsAdmin();
const avisar = useAvisos((s) => s.avisar);
const borrar = useBorrarEmpleado();
const [aBorrar, setABorrar] = useState<Empleado | null>(null);
function cerrarDialogo() {
setABorrar(null);
borrar.reset();
}
function confirmarBorrado() {
if (!aBorrar) return;
borrar.mutate(aBorrar.empId, {
onSuccess: () => {
avisar('exito', `${aBorrar.nombre} ${aBorrar.apellidos} borrado`);
cerrarDialogo();
},
});
}
const ordenar = (nuevo: string) => cambiar({ orden: nuevo });
return (
<>
<Encabezado titulo="Empleados">
<EnlaceBoton to="/empleados/nuevo" state={volver} variante="primario">
Nuevo empleado
</EnlaceBoton>
</Encabezado>
<div className="mb-3 flex flex-wrap gap-2">
<Buscador etiqueta="Buscar empleados" valor={q} onCambiar={(texto) => cambiar({ q: texto })} />
<select
aria-label="Filtrar por departamento"
value={deptId}
onChange={(e) => cambiar({ deptId: e.target.value })}
className="rounded-md border border-borde bg-superficie px-2 py-1.5 text-sm"
>
<option value="">Todos los departamentos</option>
{departamentos.map((d) => (
<option key={d.deptId} value={d.deptId}>
{d.nombre}
</option>
))}
</select>
</div>
{isPending && <Cargando />}
{isError && <MensajeError error={error} onReintentar={refetch} />}
{data && data.total === 0 && <Vacio>No hay empleados con esos filtros.</Vacio>}
{data && data.total > 0 && (
<>
<Tabla
titulo="Empleados"
atenuada={isPlaceholderData}
cabecera={
<>
<Columna campo="apellidos" orden={orden} onOrdenar={ordenar}>Nombre</Columna>
<Columna campo="oficio" orden={orden} onOrdenar={ordenar}>Oficio</Columna>
<Columna>Departamento</Columna>
<Columna campo="salario" orden={orden} onOrdenar={ordenar}>Salario</Columna>
<Columna>Acciones</Columna>
</>
}
>
{data.contenido.map((empleado) => {
const nombre = `${empleado.nombre} ${empleado.apellidos}`;
return (
<tr key={empleado.empId}>
<td className={CLASE_CELDA}>
{nombre}
<span className="block text-xs text-suave">{empleado.email}</span>
</td>
<td className={CLASE_CELDA}>{empleado.oficio}</td>
<td className={CLASE_CELDA}>{empleado.departamento ?? '—'}</td>
<td className={`${CLASE_CELDA} tabular-nums`}>{formatearEuros(empleado.salario)}</td>
<td className={`${CLASE_CELDA} flex gap-2`}>
<EnlaceBoton to={`/empleados/${empleado.empId}/editar`} state={volver} aria-label={`Editar a ${nombre}`}>
Editar
</EnlaceBoton>
{esAdmin && (
<Boton aria-label={`Borrar a ${nombre}`} onClick={() => setABorrar(empleado)}>
Borrar
</Boton>
)}
</td>
</tr>
);
})}
</Tabla>
<Paginacion
pagina={data.pagina}
paginas={data.paginas}
total={data.total}
onCambiar={(nueva) => cambiar({ pagina: String(nueva) })}
/>
</>
)}
{aBorrar && (
<DialogoConfirmar
titulo="Borrar empleado"
pendiente={borrar.isPending}
error={borrar.error}
onConfirmar={confirmarBorrado}
onCancelar={cerrarDialogo}
>
¿Seguro que quieres borrar a <strong>{aBorrar.nombre} {aBorrar.apellidos}</strong>?
</DialogoConfirmar>
)}
</>
);
}
export { PaginaEmpleados as Component };
Los enlaces de alta y edición llevan state={volver}: el estado de navegación de React Router, que viaja con la navegación pero no aparece en la URL. El formulario lo lee con useLocation().state para volver al listado con los mismos filtros. Sin esto, al editar un empleado de la página 3 de una búsqueda volverías a la página 1 sin filtros, y tendrías que buscarlo otra vez. Si alguien entra al formulario con un enlace directo, state es null y se vuelve al listado sin filtros.
El formulario de empleado
src/funcionalidades/empleados/PaginaFormEmpleado.tsx
import { zodResolver } from '@hookform/resolvers/zod';
import { useQuery } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { useLocation, useNavigate, useParams } from 'react-router';
import { aplicarErrores } from '../../compartido/api/formularios';
import type { Departamento, Empleado } from '../../compartido/api/tipos';
import { useAvisos } from '../../compartido/estado/avisos';
import { Boton, EnlaceBoton } from '../../compartido/ui/Boton';
import { Campo, Desplegable } from '../../compartido/ui/Campos';
import { Encabezado, Tarjeta } from '../../compartido/ui/Encabezado';
import { Cargando, MensajeError } from '../../compartido/ui/Estados';
import { departamentosQueries } from '../departamentos/api';
import { empleadosQueries, useGuardarEmpleado } from './api';
import { esquemaEmpleado, type DatosEmpleado, type EntradaEmpleado } from './esquema';
const CAMPOS = ['nombre', 'apellidos', 'email', 'telefono', 'oficio', 'salario', 'comision', 'deptId', 'jefeId'] as const;
interface PropsFormulario {
id?: string;
empleado?: Empleado;
departamentos: Departamento[];
personas: Empleado[];
}
function Formulario({ id, empleado, departamentos, personas }: PropsFormulario) {
const navegar = useNavigate();
// Si venimos del listado, volvemos con sus filtros; si no, al listado sin filtros.
const estado: unknown = useLocation().state;
const listado = `/empleados${(estado as { busqueda?: string } | null)?.busqueda ?? ''}`;
const guardar = useGuardarEmpleado(id);
const avisar = useAvisos((s) => s.avisar);
const {
register,
handleSubmit,
setError,
formState: { errors, isSubmitting },
} = useForm<EntradaEmpleado, unknown, DatosEmpleado>({
resolver: zodResolver(esquemaEmpleado),
defaultValues: {
nombre: empleado?.nombre ?? '',
apellidos: empleado?.apellidos ?? '',
email: empleado?.email ?? '',
telefono: empleado?.telefono ?? '',
oficio: empleado?.oficio ?? '',
salario: empleado?.salario.toString() ?? '',
comision: empleado?.comision?.toString() ?? '',
deptId: empleado?.deptId?.toString() ?? '',
jefeId: empleado?.jefeId?.toString() ?? '',
},
});
const alEnviar = handleSubmit(async (datos) => {
try {
await guardar.mutateAsync(datos);
avisar('exito', `${datos.nombre} ${datos.apellidos} guardado`);
navegar(listado);
} catch (error) {
aplicarErrores(error, setError, CAMPOS);
}
});
return (
<Tarjeta>
<form noValidate onSubmit={alEnviar} className="grid gap-4 md:grid-cols-2">
{errors.root && (
<div className="md:col-span-2">
<MensajeError error={new Error(errors.root.message)} />
</div>
)}
<Campo etiqueta="Nombre" error={errors.nombre?.message} {...register('nombre')} />
<Campo etiqueta="Apellidos" error={errors.apellidos?.message} {...register('apellidos')} />
<Campo etiqueta="Email" type="email" error={errors.email?.message} {...register('email')} />
<Campo etiqueta="Teléfono" type="tel" error={errors.telefono?.message} {...register('telefono')} />
<Campo etiqueta="Oficio" error={errors.oficio?.message} {...register('oficio')} />
<Campo etiqueta="Salario (€)" type="number" step="any" min="0" error={errors.salario?.message} {...register('salario')} />
<Campo etiqueta="Comisión (€)" type="number" step="any" min="0" error={errors.comision?.message} {...register('comision')} />
<Desplegable etiqueta="Departamento" error={errors.deptId?.message} {...register('deptId')}>
<option value="">Sin departamento</option>
{departamentos.map((d) => (
<option key={d.deptId} value={d.deptId}>
{d.nombre}
</option>
))}
</Desplegable>
<Desplegable etiqueta="Jefe" error={errors.jefeId?.message} {...register('jefeId')}>
<option value="">Sin jefe</option>
{personas
.filter((p) => p.empId !== empleado?.empId)
.map((p) => (
<option key={p.empId} value={p.empId}>
{p.apellidos}, {p.nombre}
</option>
))}
</Desplegable>
<div className="flex gap-2 md:col-span-2">
<Boton type="submit" variante="primario" disabled={isSubmitting}>
{isSubmitting ? 'Guardando...' : 'Guardar'}
</Boton>
<EnlaceBoton to={listado}>Cancelar</EnlaceBoton>
</div>
</form>
</Tarjeta>
);
}
function PaginaFormEmpleado() {
const { id } = useParams();
const consulta = useQuery({ ...empleadosQueries.detalle(id ?? ''), enabled: id !== undefined });
const departamentos = useQuery(departamentosQueries.lista());
const personas = useQuery(empleadosQueries.opciones());
const error = consulta.error ?? departamentos.error ?? personas.error;
// El formulario se monta cuando todo ha llegado: register() fija el valor del
// <select> al montar, y sin sus <option> se quedaría en la primera opción.
const listo = (id === undefined || consulta.data) && departamentos.data && personas.data;
return (
<>
<Encabezado titulo={id ? 'Editar empleado' : 'Nuevo empleado'} />
{error ? (
<MensajeError error={error} />
) : listo ? (
<Formulario key={id} id={id} empleado={consulta.data} departamentos={departamentos.data} personas={personas.data} />
) : (
<Cargando />
)}
</>
);
}
export { PaginaFormEmpleado as Component };
El error más difícil de ver de este proyecto está en el comentario de listo. register() fija el valor de un <select> cuando se monta. Si el formulario se monta antes de que lleguen los departamentos, el <select> no tiene la opción 20 y el navegador muestra la primera, “Sin departamento”. Lo peor es que React Hook Form sigue teniendo internamente deptId: '20': la pantalla y los datos no coinciden. Si el usuario guarda sin tocar el desplegable, se envía 20, pero si lo toca, cambia. La solución es esperar a todas las consultas antes de montar el formulario. Las pruebas E2E comprueban que, al editar, el desplegable muestra el departamento correcto.

Figura 8. Un 422 real: el email ya existe. El error aparece bajo su campo, el campo se marca con aria-invalid y recibe el foco.
13. Pedidos: listas dinámicas, conflictos y optimismo
src/funcionalidades/pedidos/esquema.ts
import { z } from 'zod';
const lineaPedido = z.object({
productoId: z.coerce.number<string>().min(1, 'Elige un producto'),
cantidad: z.coerce.number<string>('Escribe un número').int('Debe ser un entero').min(1, 'Como mínimo, 1'),
});
export const esquemaPedido = z.object({
clienteId: z.coerce.number<string>().min(1, 'Elige un cliente'),
lineas: z
.array(lineaPedido)
.min(1, 'Añade al menos una línea')
// La API comprueba el stock línea a línea: dos líneas del mismo producto
// podrían superarlo sin que ella lo detecte. Mejor impedirlo aquí.
.refine((lineas) => new Set(lineas.map((l) => l.productoId)).size === lineas.length, 'No repitas un producto: suma las cantidades'),
});
export type EntradaPedido = z.input<typeof esquemaPedido>;
export type DatosPedido = z.output<typeof esquemaPedido>;
La regla de no repetir productos merece una explicación. La API comprueba el stock línea a línea: si hay 9 impresoras y pides 6 en una línea y 6 en otra, cada línea por separado cabe en el stock, y el pedido se acepta con 12. Es un fallo de la API, pero la interfaz puede evitarlo. Validar en el cliente no es solo comodidad: a veces protege los datos.
src/funcionalidades/pedidos/api.ts
import { keepPreviousData, queryOptions, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '../../compartido/api/cliente';
import type { Cliente, EstadoPedido, Pagina, Pedido, Producto } from '../../compartido/api/tipos';
import type { DatosPedido } from './esquema';
const RAIZ = ['pedidos'];
export interface FiltrosPedidos {
estado: string;
orden: string;
pagina: number;
}
export const pedidosQueries = {
lista: ({ estado, orden, pagina }: FiltrosPedidos) =>
queryOptions({
queryKey: [...RAIZ, 'lista', { estado, orden, pagina }],
queryFn: ({ signal }) => {
const params = new URLSearchParams({ orden, pagina: String(pagina), tamano: '10' });
if (estado) params.set('estado', estado);
return api<Pagina<Pedido>>(`/pedidos?${params}`, { senal: signal });
},
placeholderData: keepPreviousData,
}),
detalle: (id: string) =>
queryOptions({
queryKey: [...RAIZ, 'detalle', id],
queryFn: ({ signal }) => api<Pedido>(`/pedidos/${id}`, { senal: signal }),
}),
clientes: () =>
queryOptions({
queryKey: ['clientes'],
queryFn: ({ signal }) => api<Pagina<Cliente>>('/clientes?orden=nombre&tamano=100', { senal: signal }),
select: (pagina) => pagina.contenido,
}),
productos: () =>
queryOptions({
queryKey: ['productos', 'disponibles'],
queryFn: ({ signal }) => api<Pagina<Producto>>('/productos?disponibles=true&orden=nombre&tamano=100', { senal: signal }),
select: (pagina) => pagina.contenido,
}),
};
export function useCrearPedido() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (datos: DatosPedido) => api<Pedido>('/pedidos', { metodo: 'POST', cuerpo: datos }),
onSuccess: () =>
Promise.all([
queryClient.invalidateQueries({ queryKey: RAIZ }),
queryClient.invalidateQueries({ queryKey: ['estadisticas'] }),
]),
// Tanto si sale bien (ha bajado) como con un 409 (estaba desfasado), el stock cambia.
onSettled: () => queryClient.invalidateQueries({ queryKey: ['productos'] }),
});
}
// Actualización optimista: la pantalla cambia antes de que responda la API,
// y si falla se deshace con la copia guardada en onMutate.
export function useCambiarEstado(id: string) {
const queryClient = useQueryClient();
const { queryKey } = pedidosQueries.detalle(id);
return useMutation({
mutationFn: (estado: EstadoPedido) => api<Pedido>(`/pedidos/${id}`, { metodo: 'PATCH', cuerpo: { estado } }),
onMutate: async (estado) => {
await queryClient.cancelQueries({ queryKey });
const anterior = queryClient.getQueryData(queryKey);
queryClient.setQueryData(queryKey, (pedido) => pedido && { ...pedido, estado });
return { anterior };
},
onError: (_error, _estado, contexto) => queryClient.setQueryData(queryKey, contexto?.anterior),
onSettled: () =>
Promise.all([
queryClient.invalidateQueries({ queryKey: RAIZ }),
queryClient.invalidateQueries({ queryKey: ['estadisticas'] }),
]),
});
}
useCrearPedido invalida los productos en onSettled, que se ejecuta tanto si sale bien como si falla. Si sale bien, el stock ha bajado. Si falla con un 409 de stock, es que el stock que tenía la pantalla estaba desfasado: otro usuario ha comprado mientras tanto. En los dos casos, el desplegable debe mostrar el stock nuevo.
Cambio de estado optimista
useCambiarEstado aplica el patrón optimista del módulo 19 paso a paso:
onMutatecancela las peticiones en curso de ese pedido (si una respuesta antigua llegara después, pisaría el cambio), guarda una copia y escribe el estado nuevo en la caché. La pantalla cambia al instante.onErrorrestaura la copia. El usuario ve cómo el estado vuelve atrás, y un aviso le dice por qué.onSettledinvalida pedidos y estadísticas para que todo se sincronice con el servidor.
La API admite cualquier cambio de estado, incluso de ENTREGADO a PENDIENTE. La tabla SIGUIENTES de la página de detalle pone las reglas de negocio: un pedido pendiente se envía o se cancela, uno enviado se entrega, y los entregados y cancelados ya no cambian.
src/funcionalidades/pedidos/InsigniaEstado.tsx
import type { EstadoPedido } from '../../compartido/api/tipos';
const CLASES: Record<EstadoPedido, string> = {
PENDIENTE: 'bg-amber-100 text-amber-900',
ENVIADO: 'bg-blue-100 text-blue-900',
ENTREGADO: 'bg-green-100 text-green-900',
CANCELADO: 'bg-gray-200 text-gray-800',
};
// El color acompaña al texto, nunca lo sustituye.
export function InsigniaEstado({ estado }: { estado: EstadoPedido }) {
return <span className={`rounded-full px-2 py-0.5 text-xs font-medium ${CLASES[estado]}`}>{estado}</span>;
}
src/funcionalidades/pedidos/PaginaPedidos.tsx
import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router';
import { ESTADOS_PEDIDO } from '../../compartido/api/tipos';
import { formatearEuros, formatearFecha } from '../../compartido/formato';
import { useFiltrosUrl } from '../../compartido/hooks/useFiltrosUrl';
import { EnlaceBoton } from '../../compartido/ui/Boton';
import { Encabezado } from '../../compartido/ui/Encabezado';
import { Cargando, MensajeError, Vacio } from '../../compartido/ui/Estados';
import { CLASE_CELDA, Columna, Paginacion, Tabla } from '../../compartido/ui/Tabla';
import { pedidosQueries } from './api';
import { InsigniaEstado } from './InsigniaEstado';
function PaginaPedidos() {
const { orden, pagina, leer, cambiar } = useFiltrosUrl('-pedidoId');
const estado = leer('estado');
const { data, isPending, isError, error, refetch, isPlaceholderData } = useQuery(
pedidosQueries.lista({ estado, orden, pagina }),
);
const ordenar = (nuevo: string) => cambiar({ orden: nuevo });
return (
<>
<Encabezado titulo="Pedidos">
<EnlaceBoton to="/pedidos/nuevo" variante="primario">
Nuevo pedido
</EnlaceBoton>
</Encabezado>
<div className="mb-3">
<select
aria-label="Filtrar por estado"
value={estado}
onChange={(e) => cambiar({ estado: e.target.value })}
className="rounded-md border border-borde bg-superficie px-2 py-1.5 text-sm"
>
<option value="">Todos los estados</option>
{ESTADOS_PEDIDO.map((e) => (
<option key={e} value={e}>
{e}
</option>
))}
</select>
</div>
{isPending && <Cargando />}
{isError && <MensajeError error={error} onReintentar={refetch} />}
{data && data.total === 0 && <Vacio>No hay pedidos en ese estado.</Vacio>}
{data && data.total > 0 && (
<>
<Tabla
titulo="Pedidos"
atenuada={isPlaceholderData}
cabecera={
<>
<Columna campo="pedidoId" orden={orden} onOrdenar={ordenar}>Número</Columna>
<Columna campo="cliente" orden={orden} onOrdenar={ordenar}>Cliente</Columna>
<Columna campo="fechaPedido" orden={orden} onOrdenar={ordenar}>Fecha</Columna>
<Columna>Estado</Columna>
<Columna campo="total" orden={orden} onOrdenar={ordenar}>Total</Columna>
</>
}
>
{data.contenido.map((pedido) => (
<tr key={pedido.pedidoId}>
<td className={CLASE_CELDA}>
<Link to={`/pedidos/${pedido.pedidoId}`} className="text-enlace underline">
{pedido.pedidoId}
</Link>
</td>
<td className={CLASE_CELDA}>{pedido.cliente}</td>
<td className={CLASE_CELDA}>{formatearFecha(pedido.fechaPedido)}</td>
<td className={CLASE_CELDA}>
<InsigniaEstado estado={pedido.estado} />
</td>
<td className={`${CLASE_CELDA} tabular-nums`}>{formatearEuros(pedido.total)}</td>
</tr>
))}
</Tabla>
<Paginacion
pagina={data.pagina}
paginas={data.paginas}
total={data.total}
onCambiar={(nueva) => cambiar({ pagina: String(nueva) })}
/>
</>
)}
</>
);
}
export { PaginaPedidos as Component };
src/funcionalidades/pedidos/PaginaPedido.tsx
import { useQuery } from '@tanstack/react-query';
import { useParams } from 'react-router';
import type { EstadoPedido } from '../../compartido/api/tipos';
import { useAvisos } from '../../compartido/estado/avisos';
import { textoError } from '../../compartido/api/cliente';
import { formatearEuros, formatearFecha } from '../../compartido/formato';
import { Boton, EnlaceBoton } from '../../compartido/ui/Boton';
import { Encabezado, Tarjeta } from '../../compartido/ui/Encabezado';
import { Cargando, MensajeError } from '../../compartido/ui/Estados';
import { CLASE_CELDA, Columna, Tabla } from '../../compartido/ui/Tabla';
import { pedidosQueries, useCambiarEstado } from './api';
import { InsigniaEstado } from './InsigniaEstado';
// La API acepta cualquier cambio; las reglas del negocio las pone la interfaz.
const SIGUIENTES: Record<EstadoPedido, { estado: EstadoPedido; texto: string }[]> = {
PENDIENTE: [
{ estado: 'ENVIADO', texto: 'Marcar como enviado' },
{ estado: 'CANCELADO', texto: 'Cancelar pedido' },
],
ENVIADO: [{ estado: 'ENTREGADO', texto: 'Marcar como entregado' }],
ENTREGADO: [],
CANCELADO: [],
};
function PaginaPedido() {
const { id = '' } = useParams();
const { data: pedido, isPending, isError, error, refetch } = useQuery(pedidosQueries.detalle(id));
const cambiar = useCambiarEstado(id);
const avisar = useAvisos((s) => s.avisar);
const cambiarA = (estado: EstadoPedido) =>
cambiar.mutate(estado, {
onError: (e) => avisar('error', textoError(e)),
});
return (
<>
<Encabezado titulo={`Pedido ${id}`}>
<EnlaceBoton to="/pedidos">Volver a pedidos</EnlaceBoton>
</Encabezado>
{isPending && <Cargando />}
{isError && <MensajeError error={error} onReintentar={refetch} />}
{pedido && (
<div className="flex flex-col gap-4">
<Tarjeta>
<dl className="grid gap-3 text-sm sm:grid-cols-4">
<div>
<dt className="text-suave">Cliente</dt>
<dd>{pedido.cliente}</dd>
</div>
<div>
<dt className="text-suave">Fecha</dt>
<dd>{formatearFecha(pedido.fechaPedido)}</dd>
</div>
<div>
<dt className="text-suave">Estado</dt>
<dd data-testid="estado-pedido">
<InsigniaEstado estado={pedido.estado} />
</dd>
</div>
<div>
<dt className="text-suave">Total</dt>
<dd className="font-semibold tabular-nums">{formatearEuros(pedido.total)}</dd>
</div>
</dl>
{SIGUIENTES[pedido.estado].length > 0 && (
<div className="mt-4 flex flex-wrap gap-2">
{SIGUIENTES[pedido.estado].map(({ estado, texto }) => (
<Boton key={estado} disabled={cambiar.isPending} onClick={() => cambiarA(estado)}>
{texto}
</Boton>
))}
</div>
)}
</Tarjeta>
<Tabla
titulo="Líneas del pedido"
cabecera={
<>
<Columna>Producto</Columna>
<Columna>Cantidad</Columna>
<Columna>Precio</Columna>
<Columna>Descuento</Columna>
<Columna>Importe</Columna>
</>
}
>
{pedido.lineas.map((linea) => (
<tr key={linea.numLinea}>
<td className={CLASE_CELDA}>{linea.producto}</td>
<td className={`${CLASE_CELDA} tabular-nums`}>{linea.cantidad}</td>
<td className={`${CLASE_CELDA} tabular-nums`}>{formatearEuros(linea.precioUnitario)}</td>
<td className={`${CLASE_CELDA} tabular-nums`}>{linea.descuento} %</td>
<td className={`${CLASE_CELDA} tabular-nums`}>{formatearEuros(linea.importe)}</td>
</tr>
))}
</Tabla>
</div>
)}
</>
);
}
export { PaginaPedido as Component };

Figura 9. Detalle de un pedido. Los botones dependen del estado actual.
Nuevo pedido con líneas dinámicas
src/funcionalidades/pedidos/PaginaNuevoPedido.tsx
import { zodResolver } from '@hookform/resolvers/zod';
import { useQuery } from '@tanstack/react-query';
import { useFieldArray, useForm, useWatch } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { aplicarErrores } from '../../compartido/api/formularios';
import type { Cliente, Producto } from '../../compartido/api/tipos';
import { useAvisos } from '../../compartido/estado/avisos';
import { formatearEuros } from '../../compartido/formato';
import { Boton, EnlaceBoton } from '../../compartido/ui/Boton';
import { Campo, Desplegable } from '../../compartido/ui/Campos';
import { Encabezado, Tarjeta } from '../../compartido/ui/Encabezado';
import { Cargando, MensajeError } from '../../compartido/ui/Estados';
import { pedidosQueries, useCrearPedido } from './api';
import { esquemaPedido, type DatosPedido, type EntradaPedido } from './esquema';
const LINEA_VACIA = { productoId: '', cantidad: '1' };
function Formulario({ clientes, productos }: { clientes: Cliente[]; productos: Producto[] }) {
const navegar = useNavigate();
const crear = useCrearPedido();
const avisar = useAvisos((s) => s.avisar);
const {
control,
register,
handleSubmit,
setError,
formState: { errors, isSubmitting },
} = useForm<EntradaPedido, unknown, DatosPedido>({
resolver: zodResolver(esquemaPedido),
defaultValues: { clienteId: '', lineas: [LINEA_VACIA] },
});
const { fields, append, remove } = useFieldArray({ control, name: 'lineas' });
const lineas = useWatch({ control, name: 'lineas' });
const total = lineas.reduce((suma, linea) => {
const producto = productos.find((p) => p.productoId === Number(linea.productoId));
return suma + (producto?.precio ?? 0) * (Number(linea.cantidad) || 0);
}, 0);
const alEnviar = handleSubmit(async (datos) => {
try {
const pedido = await crear.mutateAsync(datos);
avisar('exito', `Pedido ${pedido.pedidoId} creado`);
navegar(`/pedidos/${pedido.pedidoId}`);
} catch (error) {
aplicarErrores(error, setError, ['clienteId']);
}
});
const errorLineas = errors.lineas?.root?.message ?? errors.lineas?.message;
return (
<Tarjeta>
<form noValidate onSubmit={alEnviar} className="flex flex-col gap-4">
{errors.root && <MensajeError error={new Error(errors.root.message)} />}
<Desplegable etiqueta="Cliente" error={errors.clienteId?.message} {...register('clienteId')}>
<option value="">Elige un cliente</option>
{clientes.map((c) => (
<option key={c.clienteId} value={c.clienteId}>
{c.nombre} ({c.ciudad})
</option>
))}
</Desplegable>
{fields.map((campo, indice) => (
// campo.id, no el índice: al quitar una línea, cada fila conserva sus valores.
<fieldset key={campo.id} className="grid items-start gap-3 rounded-md border border-borde p-3 sm:grid-cols-[1fr_7rem_auto]">
<legend className="px-1 text-xs text-suave">Línea {indice + 1}</legend>
<Desplegable
etiqueta="Producto"
error={errors.lineas?.[indice]?.productoId?.message}
{...register(`lineas.${indice}.productoId`)}
>
<option value="">Elige un producto</option>
{productos.map((p) => (
<option key={p.productoId} value={p.productoId}>
{p.nombre}, {formatearEuros(p.precio)} (stock: {p.stock})
</option>
))}
</Desplegable>
<Campo
etiqueta="Cantidad"
type="number"
min="1"
error={errors.lineas?.[indice]?.cantidad?.message}
{...register(`lineas.${indice}.cantidad`)}
/>
<div className="sm:pt-6">
<Boton disabled={fields.length === 1} aria-label={`Quitar línea ${indice + 1}`} onClick={() => remove(indice)}>
Quitar
</Boton>
</div>
</fieldset>
))}
{errorLineas && (
<p role="alert" className="text-sm text-peligro">
{errorLineas}
</p>
)}
<div className="flex flex-wrap items-center justify-between gap-3">
<Boton onClick={() => append(LINEA_VACIA)}>Añadir línea</Boton>
<p className="text-sm">
Total estimado: <strong className="tabular-nums">{formatearEuros(total)}</strong>
</p>
</div>
<div className="flex gap-2">
<Boton type="submit" variante="primario" disabled={isSubmitting}>
{isSubmitting ? 'Creando...' : 'Crear pedido'}
</Boton>
<EnlaceBoton to="/pedidos">Cancelar</EnlaceBoton>
</div>
</form>
</Tarjeta>
);
}
function PaginaNuevoPedido() {
const clientes = useQuery(pedidosQueries.clientes());
const productos = useQuery(pedidosQueries.productos());
const error = clientes.error ?? productos.error;
return (
<>
<Encabezado titulo="Nuevo pedido" />
{error ? (
<MensajeError error={error} />
) : clientes.data && productos.data ? (
<Formulario clientes={clientes.data} productos={productos.data} />
) : (
<Cargando />
)}
</>
);
}
export { PaginaNuevoPedido as Component };
useFieldArraygestiona la lista de líneas. Cada fila usacampo.idcomokey, un identificador que genera React Hook Form. Con el índice, al quitar la primera línea, React reutilizaría sus campos para la segunda y verías valores cruzados, como en el módulo 06.useWatchse suscribe a las líneas para calcular el total en cada tecla. Es “estimado” porque el precio y el descuento definitivos los pone la API.- Con
useFieldArray, el error de la lista entera (la regla de no repetir) llega enerrors.lineas.root, y no enerrors.lineas. El código mira los dos sitios. - El error de cliente usa
role="alert"para que el lector de pantalla lo anuncie en cuanto aparece.

Figura 10. Se piden 5000 discos y hay 70: la API responde 409 y el mensaje aparece arriba. Lo escrito en el formulario se conserva.
14. Pruebas con Vitest, Testing Library y MSW
Las pruebas de este nivel no necesitan la API: MSW intercepta las peticiones y responde lo que cada prueba necesita. Así puedes provocar un 409 o un 422 sin preparar datos, y las pruebas tardan segundos. Todo esto se explicó en el módulo 22.
src/pruebas/preparacion.ts
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterAll, afterEach, beforeAll } from 'vitest';
import { useSesion } from '../compartido/estado/sesion';
import { servidor } from './servidor';
// jsdom no implementa estos dos métodos de <dialog>.
HTMLDialogElement.prototype.showModal ??= function (this: HTMLDialogElement) {
this.open = true;
};
HTMLDialogElement.prototype.close ??= function (this: HTMLDialogElement) {
this.open = false;
};
// Una petición sin manejador es un fallo de la prueba, no algo que ignorar.
beforeAll(() => servidor.listen({ onUnhandledFrame: 'error' }));
afterEach(() => {
cleanup();
servidor.resetHandlers();
localStorage.clear();
useSesion.setState({ token: null, usuario: null, expiraEl: null, motivoCierre: null });
});
afterAll(() => servidor.close());
src/pruebas/servidor.ts
import { http, HttpResponse } from 'msw/http';
import { setupServer } from 'msw/node';
export const API = 'http://localhost:3000/api';
export const paginaDe = <T,>(contenido: T[]) => ({
contenido,
pagina: 1,
tamano: 20,
total: contenido.length,
paginas: 1,
});
export const servidor = setupServer();
export { http, HttpResponse };
src/pruebas/utilidades.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render } from '@testing-library/react';
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router';
import type { Rol } from '../compartido/api/tipos';
import { useSesion } from '../compartido/estado/sesion';
export function entrarComo(rol: Rol) {
useSesion.setState({
token: 'token-de-prueba',
usuario: { email: 'prueba@empresa.es', nombre: 'Prueba', rol },
expiraEl: Date.now() + 60_000,
});
}
// Cada prueba tiene su caché y su router: nada se filtra de una a otra.
export function renderizarRutas(rutas: RouteObject[], inicial: string) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const router = createMemoryRouter(rutas, { initialEntries: [inicial] });
render(
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>,
);
return router;
}
onUnhandledFrame: 'error': en MSW 3 sustituye aonUnhandledRequest. Una petición que nadie esperaba hace fallar la prueba, en lugar de salir a la red.- El store de sesión se limpia tras cada prueba. Los stores de Zustand son módulos: viven mientras dura el fichero de pruebas, y una sesión abierta en una prueba seguiría abierta en la siguiente.
- jsdom 30.1.3 no implementa
showModal()niclose()en<dialog>(lo hemos comprobado), así que se añaden versiones mínimas. El operador??=solo las añade si faltan: el día que jsdom las incluya, se usarán las suyas. renderizarRutascrea unQueryClientsin reintentos para cada prueba. Con reintentos, una prueba de error tardaría varios segundos esperando.
src/compartido/api/cliente.test.ts
import { beforeEach, describe, expect, it } from 'vitest';
import { useSesion } from '../estado/sesion';
import { API, http, HttpResponse, servidor } from '../../pruebas/servidor';
import { entrarComo } from '../../pruebas/utilidades';
import { api, ErrorApi } from './cliente';
describe('cliente de la API', () => {
beforeEach(() => useSesion.setState({ motivoCierre: null }));
it('envía el token en la cabecera Authorization', async () => {
entrarComo('USER');
let cabecera: string | null = null;
servidor.use(
http.get(`${API}/prueba`, ({ request }) => {
cabecera = request.headers.get('Authorization');
return HttpResponse.json({ ok: true });
}),
);
await api('/prueba');
expect(cabecera).toBe('Bearer token-de-prueba');
});
it('convierte un 422 en ErrorApi con sus detalles', async () => {
servidor.use(
http.post(`${API}/prueba`, () =>
HttpResponse.json({ status: 422, mensaje: 'Hay datos no válidos', detalles: { email: 'Ese email ya está en uso' } }, { status: 422 }),
),
);
const error = await api<never>('/prueba', { metodo: 'POST', cuerpo: {} }).catch((e: ErrorApi) => e);
expect(error).toBeInstanceOf(ErrorApi);
expect(error.status).toBe(422);
expect(error.detalles?.email).toBe('Ese email ya está en uso');
});
it('cierra la sesión si la API rechaza el token', async () => {
entrarComo('USER');
servidor.use(http.get(`${API}/prueba`, () => HttpResponse.json({ mensaje: 'Debes iniciar sesión' }, { status: 401 })));
await expect(api('/prueba')).rejects.toThrow('Debes iniciar sesión');
expect(useSesion.getState().token).toBeNull();
expect(useSesion.getState().motivoCierre).toBe('caducada');
});
it('no marca sesión caducada en un 401 sin token', async () => {
servidor.use(http.post(`${API}/auth/login`, () => HttpResponse.json({ mensaje: 'Email o contraseña incorrectos' }, { status: 401 })));
await expect(api('/auth/login', { metodo: 'POST', cuerpo: {} })).rejects.toThrow('Email o contraseña incorrectos');
expect(useSesion.getState().motivoCierre).toBeNull();
});
it('traduce un fallo de red a status 0', async () => {
servidor.use(http.get(`${API}/prueba`, () => HttpResponse.error()));
const error = await api<never>('/prueba').catch((e: ErrorApi) => e);
expect(error.status).toBe(0);
});
});
src/funcionalidades/sesion/sesion.test.tsx
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it } from 'vitest';
import { API, http, HttpResponse, servidor } from '../../pruebas/servidor';
import { entrarComo, renderizarRutas } from '../../pruebas/utilidades';
import { destinoSeguro } from './api';
import { Component as PaginaLogin } from './PaginaLogin';
import { RutaProtegida } from './RutaProtegida';
const rutas = [
{ path: '/login', element: <PaginaLogin /> },
{
element: <RutaProtegida />,
children: [
{ path: '/', element: <h1>Inicio</h1> },
{ path: '/pedidos', element: <h1>Pedidos</h1> },
],
},
];
describe('sesión', () => {
it('sin sesión, una ruta protegida lleva al login y recuerda el destino', async () => {
const router = renderizarRutas(rutas, '/pedidos?estado=ENVIADO');
expect(await screen.findByRole('button', { name: 'Entrar' })).toBeInTheDocument();
expect(router.state.location.search).toBe('?volver=%2Fpedidos%3Festado%3DENVIADO');
});
it('con sesión, la ruta protegida se muestra', async () => {
entrarComo('USER');
renderizarRutas(rutas, '/pedidos');
expect(await screen.findByRole('heading', { name: 'Pedidos' })).toBeInTheDocument();
});
it('al entrar, vuelve al destino recordado', async () => {
servidor.use(
http.post(`${API}/auth/login`, () =>
HttpResponse.json({ token: 'abc', usuario: { email: 'a@b.es', nombre: 'Ana', rol: 'ADMIN' }, expiraEn: 3600 }),
),
);
renderizarRutas(rutas, '/login?volver=/pedidos');
const usuario = userEvent.setup();
await usuario.type(screen.getByLabelText('Email'), 'ana.garcia@empresa.es');
await usuario.type(screen.getByLabelText('Contraseña'), 'secreto');
await usuario.click(screen.getByRole('button', { name: 'Entrar' }));
expect(await screen.findByRole('heading', { name: 'Pedidos' })).toBeInTheDocument();
});
it('muestra el error de una contraseña incorrecta', async () => {
servidor.use(http.post(`${API}/auth/login`, () => HttpResponse.json({ mensaje: 'Email o contraseña incorrectos' }, { status: 401 })));
renderizarRutas(rutas, '/login');
const usuario = userEvent.setup();
await usuario.type(screen.getByLabelText('Email'), 'ana.garcia@empresa.es');
await usuario.type(screen.getByLabelText('Contraseña'), 'mal');
await usuario.click(screen.getByRole('button', { name: 'Entrar' }));
expect(await screen.findByRole('alert')).toHaveTextContent('Email o contraseña incorrectos');
});
it('solo acepta destinos internos', () => {
expect(destinoSeguro('/pedidos')).toBe('/pedidos');
expect(destinoSeguro('https://otra-web.com')).toBe('/');
expect(destinoSeguro('//otra-web.com')).toBe('/');
expect(destinoSeguro(null)).toBe('/');
});
});
src/funcionalidades/empleados/empleados.test.tsx
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it } from 'vitest';
import { API, http, HttpResponse, paginaDe, servidor } from '../../pruebas/servidor';
import { entrarComo, renderizarRutas } from '../../pruebas/utilidades';
import { esquemaEmpleado } from './esquema';
import { Component as PaginaFormEmpleado } from './PaginaFormEmpleado';
const VALIDO = {
nombre: ' Eva ',
apellidos: 'Ruiz',
email: 'eva@empresa.es',
telefono: '',
oficio: 'ANALISTA',
salario: '2500',
comision: '',
deptId: '20',
jefeId: '',
};
describe('esquema de empleado', () => {
it('limpia los textos y convierte los números', () => {
expect(esquemaEmpleado.parse(VALIDO)).toEqual({
...VALIDO,
nombre: 'Eva',
telefono: null,
salario: 2500,
comision: null,
deptId: 20,
jefeId: null,
});
});
it('rechaza un salario vacío o negativo', () => {
expect(esquemaEmpleado.safeParse({ ...VALIDO, salario: '' }).success).toBe(false);
expect(esquemaEmpleado.safeParse({ ...VALIDO, salario: '-5' }).success).toBe(false);
});
});
describe('formulario de empleado', () => {
it('lleva el 422 del servidor al campo email', async () => {
entrarComo('ADMIN');
servidor.use(
http.get(`${API}/departamentos`, () => HttpResponse.json(paginaDe([]))),
http.get(`${API}/empleados`, () => HttpResponse.json(paginaDe([]))),
http.post(`${API}/empleados`, () =>
HttpResponse.json({ status: 422, mensaje: 'Hay datos no válidos', detalles: { email: 'Ese email ya está en uso' } }, { status: 422 }),
),
);
renderizarRutas([{ path: '/empleados/nuevo', element: <PaginaFormEmpleado /> }], '/empleados/nuevo');
const usuario = userEvent.setup();
await usuario.type(await screen.findByLabelText('Nombre'), 'Eva');
await usuario.type(screen.getByLabelText('Apellidos'), 'Ruiz');
await usuario.type(screen.getByLabelText('Email'), 'ana.garcia@empresa.es');
await usuario.type(screen.getByLabelText('Oficio'), 'ANALISTA');
await usuario.type(screen.getByLabelText('Salario (€)'), '2500');
await usuario.click(screen.getByRole('button', { name: 'Guardar' }));
expect(await screen.findByText('Ese email ya está en uso')).toBeInTheDocument();
expect(screen.getByLabelText('Email')).toHaveAttribute('aria-invalid', 'true');
expect(screen.getByLabelText('Email')).toHaveFocus();
});
});
src/funcionalidades/departamentos/departamentos.test.tsx
import { screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it } from 'vitest';
import { API, http, HttpResponse, paginaDe, servidor } from '../../pruebas/servidor';
import { entrarComo, renderizarRutas } from '../../pruebas/utilidades';
import { Component as PaginaDepartamentos } from './PaginaDepartamentos';
const DEPARTAMENTOS = [{ deptId: 20, nombre: 'VENTAS', localidad: 'Valencia', presupuesto: 1000 }];
const rutas = [{ path: '/departamentos', element: <PaginaDepartamentos /> }];
describe('listado de departamentos', () => {
it('muestra el 409 dentro del diálogo y no lo cierra', async () => {
entrarComo('ADMIN');
servidor.use(
http.get(`${API}/departamentos`, () => HttpResponse.json(paginaDe(DEPARTAMENTOS))),
http.delete(`${API}/departamentos/20`, () =>
HttpResponse.json({ status: 409, mensaje: 'No se puede borrar un departamento con empleados' }, { status: 409 }),
),
);
renderizarRutas(rutas, '/departamentos');
const usuario = userEvent.setup();
await usuario.click(await screen.findByRole('button', { name: 'Borrar VENTAS' }));
const dialogo = screen.getByRole('dialog', { name: 'Borrar departamento' });
await usuario.click(within(dialogo).getByRole('button', { name: 'Borrar' }));
expect(await within(dialogo).findByRole('alert')).toHaveTextContent('No se puede borrar un departamento con empleados');
});
it('un usuario sin rol ADMIN no ve el botón de borrar', async () => {
entrarComo('USER');
servidor.use(http.get(`${API}/departamentos`, () => HttpResponse.json(paginaDe(DEPARTAMENTOS))));
renderizarRutas(rutas, '/departamentos');
expect(await screen.findByText('VENTAS')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Borrar VENTAS' })).not.toBeInTheDocument();
});
});
src/funcionalidades/pedidos/pedidos.test.tsx
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it } from 'vitest';
import { API, http, HttpResponse, paginaDe, servidor } from '../../pruebas/servidor';
import { entrarComo, renderizarRutas } from '../../pruebas/utilidades';
import { esquemaPedido } from './esquema';
import { Component as PaginaNuevoPedido } from './PaginaNuevoPedido';
const rutas = [{ path: '/pedidos/nuevo', element: <PaginaNuevoPedido /> }];
describe('esquema de pedido', () => {
it('rechaza dos líneas del mismo producto', () => {
const resultado = esquemaPedido.safeParse({
clienteId: '1001',
lineas: [
{ productoId: '1', cantidad: '1' },
{ productoId: '1', cantidad: '2' },
],
});
expect(resultado.error?.issues[0].message).toBe('No repitas un producto: suma las cantidades');
});
});
describe('nuevo pedido', () => {
beforeEach(() => {
entrarComo('USER');
servidor.use(
http.get(`${API}/clientes`, () => HttpResponse.json(paginaDe([{ clienteId: 1001, nombre: 'Talleres Martín', ciudad: 'Valencia' }]))),
http.get(`${API}/productos`, () => HttpResponse.json(paginaDe([{ productoId: 1, nombre: 'Portátil', precio: 100, stock: 3 }]))),
);
});
it('calcula el total y muestra el 409 de falta de stock', async () => {
servidor.use(
http.post(`${API}/pedidos`, () =>
HttpResponse.json({ status: 409, mensaje: 'No hay stock suficiente de Portátil' }, { status: 409 }),
),
);
renderizarRutas(rutas, '/pedidos/nuevo');
const usuario = userEvent.setup();
await usuario.selectOptions(await screen.findByLabelText('Cliente'), '1001');
await usuario.selectOptions(screen.getByLabelText('Producto'), '1');
const cantidad = screen.getByLabelText('Cantidad');
await usuario.clear(cantidad);
await usuario.type(cantidad, '9');
expect(screen.getByText(/900,00/)).toBeInTheDocument();
await usuario.click(screen.getByRole('button', { name: 'Crear pedido' }));
expect(await screen.findByRole('alert')).toHaveTextContent('No hay stock suficiente de Portátil');
});
it('exige elegir cliente y producto antes de llamar a la API', async () => {
renderizarRutas(rutas, '/pedidos/nuevo');
await userEvent.click(await screen.findByRole('button', { name: 'Crear pedido' }));
expect(await screen.findByText('Elige un cliente', { selector: 'p' })).toBeInTheDocument();
expect(screen.getByText('Elige un producto', { selector: 'p' })).toBeInTheDocument();
});
});
npm test
Test Files 5 passed (5)
Tests 18 passed (18)
Las pruebas importan las páginas con su nombre de exportación, Component, y las montan en un router de memoria con solo las rutas que necesitan. Fíjate en lo que comprueban: lo que ve el usuario (un texto, un rol, un atributo aria-invalid), nunca el estado interno de un componente.
Extremo a extremo con Playwright
Las pruebas E2E recorren la aplicación en un Chromium real contra la API real. Son más lentas, pero comprueban lo que las anteriores no pueden: que el build funciona, que la API devuelve lo que creías y que todas las piezas encajan.
playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
// Puertos por defecto: la web en 5173 y la API en 3000.
const puerto = process.env.E2E_PUERTO ?? '5173';
const apiUrl = process.env.E2E_API_URL ?? 'http://localhost:3000/api';
export default defineConfig({
testDir: './e2e',
// Comparten la misma API, y algunas pruebas cuentan registros.
workers: 1,
reporter: 'list',
use: {
baseURL: `http://127.0.0.1:${puerto}`,
locale: 'es-ES',
trace: 'retain-on-failure',
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
webServer: {
command: `npm run dev -- --host 127.0.0.1 --port ${puerto} --strictPort`,
url: `http://127.0.0.1:${puerto}`,
// Nunca reutilizamos un servidor ya abierto: podría apuntar a otra API.
reuseExistingServer: false,
env: { VITE_API_URL: apiUrl },
},
});
e2e/flujo.spec.ts
import { expect, test, type Page } from '@playwright/test';
const API = process.env.E2E_API_URL ?? 'http://localhost:3000/api';
const unico = Date.now();
async function entrar(page: Page, email = 'ana.garcia@empresa.es', password = 'secreto') {
await page.getByLabel('Email').fill(email);
await page.getByLabel('Contraseña').fill(password);
await page.getByRole('button', { name: 'Entrar' }).click();
}
// Antes de nada, una petición real: si la API no contesta, mejor saberlo ya.
test.beforeAll(async ({ request }) => {
const respuesta = await request.get(API);
expect(respuesta.ok(), `La API no responde en ${API}`).toBe(true);
expect((await respuesta.json()).nombre).toBe('API EMPRESA');
});
// Cualquier error de JavaScript o de React en la consola hace fallar la prueba.
// Las respuestas 4xx esperadas se registran como recursos fallidos: esas no cuentan.
test.beforeEach(async ({ page }) => {
const errores: string[] = [];
page.on('pageerror', (e) => errores.push(e.message));
page.on('console', (m) => {
if (m.type() === 'error' && !m.text().includes('Failed to load resource')) errores.push(m.text());
});
// @ts-expect-error guardamos la lista en la página para leerla al acabar
page.errores = errores;
});
test.afterEach(async ({ page }) => {
// @ts-expect-error la lista se guardó en beforeEach
expect(page.errores).toEqual([]);
});
test('una ruta protegida lleva al login y vuelve al destino', async ({ page }) => {
await page.goto('/pedidos?estado=ENVIADO');
await expect(page).toHaveURL(/\/login\?volver=/);
await entrar(page, 'ana.garcia@empresa.es', 'incorrecta');
await expect(page.getByRole('alert')).toHaveText('Email o contraseña incorrectos');
await entrar(page);
await expect(page).toHaveURL(/\/pedidos\?estado=ENVIADO$/);
await expect(page.getByRole('heading', { name: 'Pedidos' })).toBeVisible();
// La sesión sobrevive a una recarga, y Salir la cierra.
await page.reload();
await expect(page.getByText('Ana García')).toBeVisible();
await page.getByRole('button', { name: 'Salir' }).click();
await expect(page).toHaveURL(/\/login/);
});
test('el panel muestra las estadísticas de la API', async ({ page, request }) => {
const datos = await (await request.get(`${API}/estadisticas`)).json();
await page.goto('/login');
await entrar(page);
await expect(page).toHaveTitle('Panel · EMPRESA');
await expect(page.getByRole('main').getByText('Empleados', { exact: true }).locator('..')).toContainText(String(datos.empleados));
await expect(page.getByRole('figure').first()).toContainText('Masa salarial mensual por departamento');
await expect(page.getByRole('row', { name: /PENDIENTE/ })).toContainText(String(datos.porEstado.PENDIENTE));
});
test('CRUD de empleados, con 422 y 409', async ({ page }) => {
await page.goto('/login?volver=/empleados');
await entrar(page);
// Búsqueda y filtro en la URL
await page.getByLabel('Buscar empleados').fill('garcia');
await expect(page).toHaveURL(/q=garcia/);
await expect(page.getByRole('cell', { name: /ana\.garcia@empresa\.es/ })).toBeVisible();
await page.getByLabel('Buscar empleados').fill('');
// La búsqueda llega a la URL 300 ms después de la última tecla
await expect(page).toHaveURL(/\/empleados$/);
// Alta con validación en cliente
await page.getByRole('link', { name: 'Nuevo empleado' }).click();
await page.getByRole('button', { name: 'Guardar' }).click();
await expect(page.getByText('El nombre es obligatorio')).toBeVisible();
// 422: el email ya existe, y solo el servidor puede saberlo
await page.getByLabel('Nombre').fill('Eva');
await page.getByLabel('Apellidos').fill(`Prueba ${unico}`);
await page.getByLabel('Email').fill('ana.garcia@empresa.es');
await page.getByLabel('Oficio').fill('ANALISTA');
await page.getByLabel('Salario (€)').fill('2500');
await page.getByLabel('Departamento').selectOption({ label: 'VENTAS' });
await page.getByRole('button', { name: 'Guardar' }).click();
await expect(page.getByText('Ese email ya está en uso')).toBeVisible();
await expect(page.getByLabel('Email')).toHaveAttribute('aria-invalid', 'true');
await page.getByLabel('Email').fill(`eva.${unico}@empresa.es`);
await page.getByRole('button', { name: 'Guardar' }).click();
await expect(page).toHaveURL(/\/empleados$/);
await expect(page.getByText(`Eva Prueba ${unico} guardado`)).toBeVisible();
// Modificación: el departamento se conserva al abrir el formulario
await page.getByLabel('Buscar empleados').fill(String(unico));
const nombre = `Eva Prueba ${unico}`;
await page.getByRole('link', { name: `Editar a ${nombre}` }).click();
await expect(page.getByLabel('Departamento').locator('option:checked')).toHaveText('VENTAS');
await page.getByLabel('Salario (€)').fill('2750');
await page.getByRole('button', { name: 'Guardar' }).click();
// Volvemos al listado con la búsqueda que teníamos
await expect(page).toHaveURL(new RegExp(`q=${unico}`));
await expect(page.getByRole('row', { name: nombre })).toContainText('2750,00');
// Baja
await page.getByRole('button', { name: `Borrar a ${nombre}` }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Borrar' }).click();
await expect(page.getByText('No hay empleados con esos filtros.')).toBeVisible();
// 409: Ana tiene subordinados
await page.getByLabel('Buscar empleados').fill('ana.garcia');
await page.getByRole('button', { name: 'Borrar a Ana García López' }).click();
const dialogo = page.getByRole('dialog');
await dialogo.getByRole('button', { name: 'Borrar' }).click();
await expect(dialogo.getByRole('alert')).toHaveText('No se puede borrar a un empleado que tiene subordinados');
await dialogo.getByRole('button', { name: 'Cancelar' }).click();
await expect(dialogo).toBeHidden();
});
test('CRUD de departamentos, con 422 y 409', async ({ page }) => {
await page.goto('/login?volver=/departamentos');
await entrar(page);
// 422: nombre repetido
await page.getByRole('link', { name: 'Nuevo departamento' }).click();
await page.getByLabel('Nombre').fill('ventas');
await page.getByRole('button', { name: 'Guardar' }).click();
await expect(page.getByText('Ya existe un departamento con ese nombre')).toBeVisible();
const nombre = `PRUEBAS ${unico}`;
await page.getByLabel('Nombre').fill(nombre);
await page.getByLabel('Localidad').fill('Cuenca');
await page.getByLabel('Presupuesto (€)').fill('1000');
await page.getByRole('button', { name: 'Guardar' }).click();
await expect(page.getByRole('row', { name: new RegExp(nombre) })).toContainText('Cuenca');
// Modificación
await page.getByRole('link', { name: `Editar ${nombre}` }).click();
await expect(page.getByLabel('Localidad')).toHaveValue('Cuenca');
await page.getByLabel('Localidad').fill('Teruel');
await page.getByRole('button', { name: 'Guardar' }).click();
await expect(page.getByRole('row', { name: new RegExp(nombre) })).toContainText('Teruel');
// Baja
await page.getByRole('button', { name: `Borrar ${nombre}` }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Borrar' }).click();
await expect(page.getByRole('cell', { name: nombre, exact: true })).toBeHidden();
// 409: VENTAS tiene empleados
await page.getByRole('button', { name: 'Borrar VENTAS' }).click();
const dialogo = page.getByRole('dialog');
await dialogo.getByRole('button', { name: 'Borrar' }).click();
await expect(dialogo.getByRole('alert')).toHaveText('No se puede borrar un departamento con empleados');
});
test('pedidos: 409 de stock, alta y cambio de estado', async ({ page }) => {
await page.goto('/login?volver=/pedidos/nuevo');
await entrar(page);
// Validación en cliente: dos líneas del mismo producto
await page.getByLabel('Cliente').selectOption({ index: 1 });
const producto = page.getByLabel('Producto');
await producto.selectOption({ index: 1 });
await page.getByRole('button', { name: 'Añadir línea' }).click();
await producto.nth(1).selectOption({ index: 1 });
await page.getByRole('button', { name: 'Crear pedido' }).click();
await expect(page.getByText('No repitas un producto: suma las cantidades')).toBeVisible();
await page.getByRole('button', { name: 'Quitar línea 2' }).click();
// 409: más cantidad que stock
await page.getByLabel('Cantidad').fill('99999');
await page.getByRole('button', { name: 'Crear pedido' }).click();
await expect(page.getByRole('alert')).toContainText('No hay stock suficiente de');
// Alta correcta
await page.getByLabel('Cantidad').fill('1');
await page.getByRole('button', { name: 'Crear pedido' }).click();
await expect(page).toHaveURL(/\/pedidos\/\d+$/);
const estado = page.getByTestId('estado-pedido');
await expect(estado).toHaveText('PENDIENTE');
// Cambios de estado
await page.getByRole('button', { name: 'Marcar como enviado' }).click();
await expect(estado).toHaveText('ENVIADO');
await page.reload();
await expect(estado).toHaveText('ENVIADO');
await page.getByRole('button', { name: 'Marcar como entregado' }).click();
await expect(estado).toHaveText('ENTREGADO');
await expect(page.getByRole('button', { name: /Marcar/ })).toHaveCount(0);
// El listado filtrado y el panel se han actualizado
const numero = page.url().split('/').pop()!;
await page.getByRole('link', { name: 'Volver a pedidos' }).click();
await page.getByLabel('Filtrar por estado').selectOption('ENTREGADO');
await expect(page.getByRole('link', { name: numero, exact: true })).toBeVisible();
});
test('los datos con campos nulos se muestran y se guardan', async ({ page, request }) => {
const { contenido } = await (await request.get(`${API}/empleados?tamano=100`)).json();
const sinDatos = contenido.find((e: { deptId: number | null; telefono: string | null }) => e.deptId === null || e.telefono === null);
await page.goto('/login?volver=/departamentos');
await entrar(page);
// INVESTIGACIÓN tiene presupuesto null: se ve un guion y se guarda sin convertirlo en 0
await expect(page.getByRole('row', { name: /INVESTIGACIÓN/ })).toContainText('—');
await page.getByRole('link', { name: 'Editar INVESTIGACIÓN' }).click();
await expect(page.getByLabel('Presupuesto (€)')).toHaveValue('');
await page.getByRole('button', { name: 'Guardar' }).click();
await expect(page.getByText('Departamento INVESTIGACIÓN guardado')).toBeVisible();
expect((await (await request.get(`${API}/departamentos/70`)).json()).presupuesto).toBeNull();
// Un empleado con teléfono o departamento null se edita y los conserva
await page.goto(`/empleados/${sinDatos.empId}/editar`);
await expect(page.getByLabel('Nombre')).toHaveValue(sinDatos.nombre);
await page.getByRole('button', { name: 'Guardar' }).click();
await expect(page).toHaveURL(/\/empleados$/);
const guardado = await (await request.get(`${API}/empleados/${sinDatos.empId}`)).json();
expect([guardado.deptId, guardado.telefono]).toEqual([sinDatos.deptId, sinDatos.telefono]);
});
test('el rol USER no puede borrar y la sesión rechazada lleva al login', async ({ page }) => {
await page.goto('/login?volver=/departamentos');
await entrar(page, 'luis.martinez@empresa.es');
await expect(page.getByText('(USER)')).toBeVisible();
await expect(page.getByRole('row', { name: /VENTAS/ })).toBeVisible();
await expect(page.getByRole('button', { name: /^Borrar/ })).toHaveCount(0);
// Simulamos un token caducado y escribimos: la API responde 401
await page.getByRole('link', { name: 'Nuevo departamento' }).click();
await page.evaluate(() => {
const clave = 'empresa-sesion';
const guardado = JSON.parse(localStorage.getItem(clave)!);
guardado.state.token = 'token.no.valido';
localStorage.setItem(clave, JSON.stringify(guardado));
});
await page.reload();
await page.getByLabel('Nombre').fill(`CADUCADO ${unico}`);
await page.getByRole('button', { name: 'Guardar' }).click();
await expect(page).toHaveURL(/\/login\?volver=%2Fdepartamentos%2Fnuevo/);
await expect(page.getByText('Tu sesión ha caducado. Vuelve a entrar.')).toBeVisible();
});
Algunas decisiones de estas pruebas:
beforeAllhace una petición real a la API antes de nada. Si la API no está arrancada, la prueba falla al momento con “La API no responde en …”, y no con untoBeVisibleagotado 5 segundos después.reuseExistingServer: false: si ya tienesnpm run devabierto en ese puerto, Playwright se niega a arrancar. Reutilizarlo sería cómodo, pero ese servidor podría apuntar a otra API.- Cualquier error en consola hace fallar la prueba. Así se detectan avisos de React como una
keyrepetida o una prop mal escrita. Se ignoran los “Failed to load resource”, que son los 4xx esperados. unico = Date.now()hace únicos los nombres creados. Puedes repetir las pruebas sin reiniciar la API.workers: 1: las pruebas comparten la API y algunas cuentan registros. En paralelo se pisarían.
Con la API arrancada y npm run dev cerrado:
npm run test:e2e
Running 7 tests using 1 worker
ok 1 [chromium] › e2e\flujo.spec.ts:36:1 › una ruta protegida lleva al login y vuelve al destino (5.4s)
ok 2 [chromium] › e2e\flujo.spec.ts:54:1 › el panel muestra las estadísticas de la API (3.0s)
ok 3 [chromium] › e2e\flujo.spec.ts:64:1 › CRUD de empleados, con 422 y 409 (9.0s)
ok 4 [chromium] › e2e\flujo.spec.ts:123:1 › CRUD de departamentos, con 422 y 409 (5.1s)
ok 5 [chromium] › e2e\flujo.spec.ts:159:1 › pedidos: 409 de stock, alta y cambio de estado (6.1s)
ok 6 [chromium] › e2e\flujo.spec.ts:201:1 › los datos con campos nulos se muestran y se guardan (5.4s)
ok 7 [chromium] › e2e\flujo.spec.ts:224:1 › el rol USER no puede borrar y la sesión rechazada lleva al login (4.9s)
7 passed (54.3s)
Si tu API está en otro puerto, indícalo: E2E_API_URL=http://localhost:4000/api npm run test:e2e (en PowerShell, $env:E2E_API_URL='http://localhost:4000/api'; npm run test:e2e).
💡 Consejo: Mientras escribíamos estas pruebas, una falló de una forma instructiva. La prueba vaciaba el buscador y pulsaba “Nuevo empleado” en el mismo instante. Como el buscador espera 300 ms antes de escribir en la URL, la página se desmontaba antes, el temporizador se cancelaba y la URL conservaba
q=garcia. No era un fallo de la aplicación: es lo que pasaría con un usuario igual de rápido. La prueba ahora espera a que la URL cambie:await expect(page).toHaveURL(/\/empleados$/).
15. Build de producción
npm run lint
npm run build
npm run preview
npm run build comprueba primero los tipos (tsc -b) y, si no hay errores, genera dist. Esta es la salida real, recortada:
dist/index.html 0.45 kB │ gzip: 0.30 kB
dist/assets/index-Iv4aRVfK.css 15.75 kB │ gzip: 4.34 kB
dist/assets/PaginaLogin-DNdx8oSJ.js 1.70 kB │ gzip: 0.94 kB
dist/assets/PaginaDepartamentos-Bclda3We.js 2.11 kB │ gzip: 0.93 kB
dist/assets/PaginaPanel-Dz9dpmcJ.js 2.42 kB │ gzip: 0.99 kB
dist/assets/PaginaPedidos-XJ-VNP8C.js 2.47 kB │ gzip: 0.97 kB
dist/assets/PaginaPedido-Cl5---_t.js 2.74 kB │ gzip: 1.01 kB
dist/assets/PaginaEmpleados-BTa3T-vB.js 3.31 kB │ gzip: 1.39 kB
dist/assets/PaginaNuevoPedido-BDHV5HAH.js 3.46 kB │ gzip: 1.56 kB
dist/assets/PaginaFormEmpleado-CJSgUMyJ.js 3.54 kB │ gzip: 1.54 kB
dist/assets/schemas-BJyX8LS1.js 128.32 kB │ gzip: 38.86 kB
dist/assets/index-Djy6ze3s.js 353.20 kB │ gzip: 111.68 kB
✓ built in 3.44s
Así se lee:
- Cada página tiene su fichero. Es el efecto de
lazyen el router: quien solo mira el panel no descarga el formulario de pedidos. index(353 kB, 112 kB comprimido) contiene React, React Router, TanStack Query y Zustand: lo que necesita cualquier pantalla.schemas(128 kB) es Zod, y solo lo descargan las páginas con formulario. Vite y Rolldown lo han separado solos porque varias páginas perezosas lo comparten.
npm run preview sirve dist en http://localhost:4173. Las capturas de este módulo se han hecho así, sobre el build de producción. Para publicarlo en un servidor real, recuerda dos cosas del módulo 25: el servidor debe devolver index.html para cualquier ruta (si no, recargar /empleados da un 404), y VITE_API_URL se fija al compilar.
16. Errores frecuentes y depuración
| Síntoma | Causa | Solución |
|---|---|---|
| “No se puede conectar con la API” en todas las pantallas | La API no está arrancada, o está en otro puerto | Comprueba con curl http://localhost:3000/api; si usas otro puerto, crea .env.local con VITE_API_URL y reinicia npm run dev |
Cambias .env.local y no pasa nada | Vite lee las variables al arrancar | Reinicia npm run dev; para producción, repite el build |
| Tras guardar, el listado muestra datos viejos | Falta invalidar una clave que depende de lo guardado | Revisa qué otras pantallas muestran ese dato e invalida su raíz |
| El desplegable de edición muestra “Sin departamento” | El formulario se montó antes de llegar las opciones | Monta el formulario cuando todas las consultas tengan datos |
| El formulario se envía vacío | Campo no pasa la ref al <input> (o estás en React 18 sin forwardRef) | Comprueba que {...resto} llega al <input>; en React 18, forwardRef |
| Al recargar, me echa al login | La sesión guardada había caducado, o borraste localStorage | Es lo esperado: el token dura una hora |
| Un 401 al guardar me lleva al login | El token ya no es válido (caducado, o reiniciaste la API más de una hora después) | Vuelve a entrar; el aviso “Tu sesión ha caducado” lo explica |
| “Solo un administrador puede hacer esto” | Has entrado como luis.martinez, rol USER, y has llamado a la API a mano | Los borrados exigen ADMIN; la interfaz oculta el botón, la API lo impide |
npm run test:e2e dice “La API no responde” | La API no está arrancada o está en otro puerto | Arráncala, o usa E2E_API_URL |
npm run test:e2e dice que el puerto 5173 está en uso | Tienes npm run dev abierto | Ciérralo, o usa E2E_PUERTO=5180 |
npm install falla con ERESOLVE y react-router | Tu React es anterior a 19.2.7 | Sección 17 |
Para depurar, tres herramientas que ya conoces:
- La pestaña Red del navegador. Mira la petición, su cabecera
Authorizationy la respuesta exacta. Casi todos los errores de datos se ven ahí. - React DevTools. En la pestaña Components, busca
RutaProtegidao cualquier página y mira sus hooks. - Las DevTools de TanStack Query (
@tanstack/react-query-devtools, módulo 19). No se incluyen en el proyecto para mantenerlo pequeño, pero añadir<ReactQueryDevtools />dentro deQueryClientProviderte muestra cada clave, su estado y cuándo caduca.
17. Si tu proyecto usa React 19.1 o anterior 🔄 19.2
🔄 Cambia en React 19.2. React Router 8 exige React 19.2.7 o superior y Node 22.22 o superior. Si usas una versión anterior: cambia
react-routerpor la versión 7.18.5; con React 19.0 o 19.1 no hay que tocar nada más, y con React 18.3 hay que añadirforwardRefa los dos campos de formulario y sustituir<title>pordocument.title.
React no tiene versiones LTS, como viste en el módulo 00: cada equipo se queda en la versión que le conviene. Si tu proyecto sigue en React 19.1 o en 18.3, puedes construir esta misma aplicación. Lo hemos comprobado descomprimiendo empresa-react.zip, cambiando las versiones en package.json y ejecutando npm install, el build, las 18 pruebas de Vitest y las 7 de Playwright contra la API real.
| Versión de React | Qué cambiar | Resultado comprobado |
|---|---|---|
| 19.3, o 19.2.7 y posteriores | Nada | Todo pasa |
| 19.2.0 a 19.2.6 | Sube a React 19.2.9, o usa react-router@7 | No comprobado aparte: misma regla de dependencias que 19.1 |
| 19.1 y 19.0 | react-router 7.18.5 | 19.1.10: build correcto, 18 de 18 en Vitest y 7 de 7 en Playwright |
| 18.3 | react-router 7.18.5, forwardRef y document.title | Sin los cambios fallan 4 pruebas de Vitest y todas las de Playwright; con ellos, todo pasa |
Paso 1: las versiones en package.json
Cambia estas líneas y ejecuta npm install. Con React 19.1:
"react": "19.1.10",
"react-dom": "19.1.10",
"react-router": "^7.18.5",
"@types/react": "~19.1.0",
"@types/react-dom": "~19.1.0",
Con React 18.3, "react": "18.3.1", "react-dom": "18.3.1" y los tipos "^18.3.0". Cambia la versión de React y la de React Router a la vez. Si solo bajas React, npm encuentra que react-router 8 exige react >= 19.2.7 y se niega a instalar con ERESOLVE, como viste en el módulo 18. Las demás dependencias (TanStack Query, Zustand, React Hook Form, Testing Library) admiten React 18 o superior y no cambian.
React Router 7 admite las mismas importaciones que usa este proyecto, incluido react-router/dom, y el modo Datos con lazy funciona igual. Puedes borrar el campo engines de package.json o bajarlo a Node 20.19, que es lo que exigen Vite 8 y React Router 7. Vitest 5 sigue pidiendo Node 22.12 o superior.
Con React 19.1.10 y React Router 7.18.5 no hay que tocar ni una línea de código: ref como prop y <title> existen desde React 19.0.
Paso 2, solo para React 18.3: forwardRef en los campos
En React 18, ref no es una prop: React la aparta antes de llamar a tu componente. register('email') devuelve una ref, pero esa ref nunca llega al <input>, y React Hook Form no puede leer lo que escribes. El proyecto compila sin errores, porque los tipos de React 18 también aceptan ref en ComponentProps<'input'>, pero en ejecución el formulario no ve lo que escribes y la validación no lo deja enviar. Al ejecutar las pruebas en React 18.3.1 sin cambios, el inicio de sesión nunca llegó a la API: fallaron las cuatro pruebas de Vitest que envían un formulario (login correcto, contraseña incorrecta, 422 de empleado y 409 de pedido) y las siete de Playwright.
La solución es envolver los dos componentes en forwardRef:
import { forwardRef, useId, type ComponentPropsWithoutRef, type ReactNode } from 'react';
export const Campo = forwardRef<HTMLInputElement, ComponentPropsWithoutRef<'input'> & Comunes>(
function Campo({ etiqueta, error, ...resto }, ref) {
const id = useId();
return (
<Envoltura id={id} etiqueta={etiqueta} error={error}>
<input
ref={ref}
id={id}
className={CLASES_CONTROL}
aria-invalid={error ? true : undefined}
aria-describedby={error ? `${id}-error` : undefined}
{...resto}
/>
</Envoltura>
);
},
);
Desplegable cambia igual, con HTMLSelectElement y ComponentPropsWithoutRef<'select'>. Se usa ComponentPropsWithoutRef porque la ref ya llega como segundo argumento. El resto del proyecto no cambia: quien usa <Campo {...register('email')} /> no nota la diferencia.
Paso 3, solo para React 18.3: el título del documento
React 18 no sube <title> al <head>. Lo pinta donde está, dentro del <body>, y el título de la pestaña sigue siendo “EMPRESA”. No da error, pero la prueba E2E del panel falla al comprobar toHaveTitle('Panel · EMPRESA'). Crea un hook:
import { useEffect } from 'react';
export function useTitulo(titulo: string) {
useEffect(() => {
document.title = `${titulo} · EMPRESA`;
}, [titulo]);
}
Y en Encabezado, PaginaLogin y PaginaError, quita la etiqueta <title> y llama a useTitulo(titulo) al principio del componente (useTitulo('Iniciar sesión') en el login).
Con estos dos cambios, el proyecto sobre React 18.3.1 y React Router 7.18.5 compila, pasa las 18 pruebas de Vitest y las 7 de Playwright.
💡 Consejo: Si vas a mantener una aplicación en React 18 durante un tiempo, escribe ya los componentes con
forwardRefyuseTitulo: también funcionan en React 19. Cuando subas de versión, volver arefcomo prop y a<title>es opcional y puedes hacerlo poco a poco.
18. Ejercicios
Ejercicio 1: Cerrar la sesión a la hora exacta
Ahora, una sesión caducada solo se detecta cuando la API rechaza una petición, o al recargar. Escribe un hook useCaducidad() que cierre la sesión con el motivo 'caducada' justo cuando llegue expiraEl, y úsalo en RutaProtegida. Escribe una prueba con temporizadores falsos.
Ver solución
import { useEffect } from 'react';
import { useSesion } from '../../compartido/estado/sesion';
export function useCaducidad() {
const expiraEl = useSesion((s) => s.expiraEl);
const cerrarSesion = useSesion((s) => s.cerrarSesion);
useEffect(() => {
if (!expiraEl) return;
const temporizador = setTimeout(() => cerrarSesion('caducada'), Math.max(0, expiraEl - Date.now()));
return () => clearTimeout(temporizador);
}, [expiraEl, cerrarSesion]);
}
En RutaProtegida, llama a useCaducidad() en la primera línea, antes del if (!token): los hooks no pueden ir después de un return condicional.
it('cierra la sesión cuando llega la hora de caducidad', () => {
vi.useFakeTimers();
useSesion.setState({ token: 't', usuario: { email: 'a@b.es', nombre: 'A', rol: 'USER' }, expiraEl: Date.now() + 5000 });
renderHook(() => useCaducidad());
act(() => vi.advanceTimersByTime(4999));
expect(useSesion.getState().token).toBe('t');
act(() => vi.advanceTimersByTime(1));
expect(useSesion.getState()).toMatchObject({ token: null, motivoCierre: 'caducada' });
vi.useRealTimers();
});
- El efecto depende de
expiraEl: si inicias otra sesión, el temporizador anterior se limpia y se crea otro. Math.max(0, ...)cubre una sesión que ya ha caducado: el temporizador se dispara en el siguiente ciclo.vi.useFakeTimers()congela tambiénDate.now(), así que la cuenta es exacta. Al cerrarse la sesión,RutaProtegidaredirige sola, como con el 401.
Ejercicio 2: Filtrar departamentos sin pedirlos de nuevo
Añade un campo de búsqueda al listado de departamentos. Como son pocos y ya están en caché, filtra en el cliente, sin distinguir mayúsculas ni tildes, por nombre o localidad. Escribe la función de filtrado como función pura y pruébala.
Ver solución
const normalizar = (texto: string) => texto.normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase();
export function filtrarDepartamentos(departamentos: Departamento[], filtro: string) {
const buscado = normalizar(filtro.trim());
if (!buscado) return departamentos;
return departamentos.filter((d) => normalizar(`${d.nombre} ${d.localidad ?? ''}`).includes(buscado));
}
En la página, un estado local y el valor derivado en el render, sin efectos ni estado duplicado:
const [filtro, setFiltro] = useState('');
const visibles = data ? filtrarDepartamentos(data, filtro) : [];
// ...
<input type="search" aria-label="Buscar departamentos" value={filtro} onChange={(e) => setFiltro(e.target.value)} />
Y en la tabla, visibles.map(...) en lugar de data.map(...).
it('filtra sin distinguir tildes ni mayúsculas', () => {
const lista = [
{ deptId: 30, nombre: 'INFORMÁTICA', localidad: 'Valencia', presupuesto: 1 },
{ deptId: 70, nombre: 'INVESTIGACIÓN', localidad: 'Bilbao', presupuesto: null },
];
expect(filtrarDepartamentos(lista, 'informatica').map((d) => d.deptId)).toEqual([30]);
expect(filtrarDepartamentos(lista, 'bilbao').map((d) => d.deptId)).toEqual([70]);
expect(filtrarDepartamentos(lista, ' ')).toHaveLength(2);
});
normalize('NFD') separa cada letra de su tilde (Á pasa a A + tilde) y la expresión \p{Diacritic} borra las tildes. Es lo mismo que hace la API con su parámetro q. localidad ?? '' evita que un departamento sin localidad se convierta en el texto "null" y coincida al buscar “nu”.
Ejercicio 3: Precargar la página siguiente
Cuando estás en la página 1 de empleados, lo más probable es que pulses Siguiente. Escribe un hook usePrecargaSiguiente(filtros, paginas) que pida la página siguiente en segundo plano, para que aparezca al instante. Comprueba con MSW que se piden las páginas 1 y 2.
Ver solución
export function usePrecargaSiguiente(filtros: FiltrosEmpleados, paginas: number | undefined) {
const queryClient = useQueryClient();
useEffect(() => {
if (paginas === undefined || filtros.pagina >= paginas) return;
void queryClient.prefetchQuery(empleadosQueries.lista({ ...filtros, pagina: filtros.pagina + 1 }));
}, [queryClient, filtros, paginas]);
}
En PaginaEmpleados, junto al useQuery:
const filtros = useMemo(() => ({ q, orden, pagina, deptId }), [q, orden, pagina, deptId]);
const { data, ... } = useQuery(empleadosQueries.lista(filtros));
usePrecargaSiguiente(filtros, data?.paginas);
useMemo hace falta porque filtros es una dependencia del efecto. Un objeto nuevo en cada render lanzaría el efecto en cada render; prefetchQuery no repetiría la petición si los datos están frescos, pero es trabajo inútil. Con React Compiler, del módulo 14, te lo ahorrarías.
La prueba cuenta qué páginas se piden:
it('precarga la página siguiente', async () => {
const pedidas: string[] = [];
servidor.use(
http.get(`${API}/empleados`, ({ request }) => {
const pagina = new URL(request.url).searchParams.get('pagina')!;
pedidas.push(pagina);
return HttpResponse.json({ ...paginaDe([]), pagina: Number(pagina), paginas: 3 });
}),
);
const filtros = { q: '', orden: 'apellidos', pagina: 1, deptId: '' };
function Prueba() {
const { data } = useQuery(empleadosQueries.lista(filtros));
usePrecargaSiguiente(filtros, data?.paginas);
return <p>{data ? 'listo' : '...'}</p>;
}
render(
<QueryClientProvider client={new QueryClient()}>
<Prueba />
</QueryClientProvider>,
);
await screen.findByText('listo');
await vi.waitFor(() => expect(pedidas).toEqual(['1', '2']));
});
prefetchQuery guarda el resultado en la caché con la misma clave que usará useQuery al pulsar Siguiente. Como esos datos están frescos durante 30 segundos (staleTime), la página 2 se pinta sin petición.
Ejercicio 4: Confirmar antes de cancelar un pedido
Cancelar un pedido no se puede deshacer en la interfaz. Haz que el botón “Cancelar pedido” abra DialogoConfirmar antes de enviar el cambio. Adapta el diálogo para que el texto del botón de confirmar sea configurable (“Borrar” no tiene sentido aquí).
Ver solución
En DialogoConfirmar, una prop opcional con su valor por defecto, para no romper los usos actuales:
interface Props {
// ...
textoConfirmar?: string;
}
export function DialogoConfirmar({ titulo, children, pendiente, error, onConfirmar, onCancelar, textoConfirmar = 'Borrar' }: Props) {
// ...
<Boton variante="peligro" disabled={pendiente} onClick={onConfirmar}>
{pendiente ? 'Enviando...' : textoConfirmar}
</Boton>
En PaginaPedido, un estado que dice si el diálogo está abierto:
const [confirmarCancelacion, setConfirmarCancelacion] = useState(false);
// en el map de SIGUIENTES:
<Boton
key={estado}
disabled={cambiar.isPending}
onClick={() => (estado === 'CANCELADO' ? setConfirmarCancelacion(true) : cambiarA(estado))}
>
{texto}
</Boton>
// al final del componente:
{confirmarCancelacion && (
<DialogoConfirmar
titulo="Cancelar pedido"
textoConfirmar="Cancelar pedido"
pendiente={cambiar.isPending}
error={null}
onConfirmar={() => {
setConfirmarCancelacion(false);
cambiarA('CANCELADO');
}}
onCancelar={() => setConfirmarCancelacion(false)}
>
El stock de sus productos no se repone. ¿Seguro que quieres cancelar el pedido {id}?
</DialogoConfirmar>
)}
El diálogo se cierra antes de enviar porque el cambio es optimista: el estado ya aparece como CANCELADO, y si la API falla, el aviso de error y la vuelta atrás lo explican. Pasa error={null} porque aquí el error se comunica con el aviso, no dentro del diálogo.
El texto del diálogo cuenta algo importante: la API no devuelve el stock al cancelar. Es una regla del negocio que el usuario debe conocer antes de pulsar.
Ejercicio 5: Probar que el optimismo se deshace
Escribe una prueba de Vitest para PaginaPedido: el pedido 5001 está PENDIENTE, pulsas “Marcar como enviado”, la pantalla muestra ENVIADO al instante, la API responde 500 tras 100 ms y la pantalla vuelve a PENDIENTE con un aviso de error.
Ver solución
it('la actualización optimista se deshace si la API falla', async () => {
entrarComo('USER');
const pedido = { pedidoId: 5001, clienteId: 1001, cliente: 'Talleres', fechaPedido: '2026-01-10', fechaEnvio: null, estado: 'PENDIENTE', lineas: [], total: 0 };
servidor.use(
http.get(`${API}/pedidos/5001`, () => HttpResponse.json(pedido)),
http.patch(`${API}/pedidos/5001`, async () => {
await new Promise((r) => setTimeout(r, 100));
return HttpResponse.json({ status: 500, mensaje: 'Error interno del servidor' }, { status: 500 });
}),
);
renderizarRutas([{ path: '/pedidos/:id', element: <PaginaPedido /> }], '/pedidos/5001');
await userEvent.click(await screen.findByRole('button', { name: 'Marcar como enviado' }));
expect(screen.getByTestId('estado-pedido')).toHaveTextContent('ENVIADO');
await vi.waitFor(() => expect(screen.getByTestId('estado-pedido')).toHaveTextContent('PENDIENTE'));
expect(useAvisos.getState().avisos.at(-1)).toMatchObject({ tipo: 'error', texto: 'Error interno del servidor' });
});
- La primera comprobación es síncrona, sin
findBy: justo después del clic ya debe verse ENVIADO. Si el optimismo no funcionara, fallaría aquí. - Los 100 ms de espera en el manejador de MSW dan tiempo a ver el estado intermedio. Sin la espera, la respuesta podría llegar antes de la comprobación.
- El aviso se comprueba en el store y no en pantalla, porque la prueba no monta
ZonaAvisos. Leer el store directamente congetState()es legítimo para un efecto que no es visual en esta pantalla.
Ejercicio 6 (propuesto): CRUD de productos
Añade la sección Productos con listado (filtro por categoría con /api/categorias, y casilla “solo disponibles”), alta, edición y borrado. La API responde 409 si el producto aparece en algún pedido, con el mensaje “descatalógalo en lugar de borrarlo”: en ese caso, ofrece en el propio diálogo un botón para marcarlo como descatalogado con un PUT. Recuerda invalidar ['productos'], porque el formulario de pedidos usa esa clave.
Ejercicio 7 (propuesto): Ficha de cliente
Crea /clientes/:id con los datos del cliente y su historial de pedidos (/api/clientes/:id/pedidos), y enlázala desde el listado y el detalle de pedidos. Muestra el total facturado al cliente sin contar los pedidos cancelados, y compáralo con su limiteCredito.
Ejercicio 8 (propuesto): Validación con avisos tempranos de stock
En el nuevo pedido, muestra un aviso bajo la cantidad si supera el stock que dice el desplegable, pero sin impedir el envío: el stock puede haber cambiado, y quien decide es la API. Explica en un comentario por qué no es una regla de Zod.
Ejercicio 9 (propuesto): Publicar en GitHub Pages
Publica la aplicación en GitHub Pages siguiendo el módulo 25. Necesitarás base en vite.config.ts (el router ya usa BASE_URL), una copia de index.html como 404.html para que funcionen las rutas al recargar, y una API accesible desde Internet. Comprueba qué pasa con CORS y con VITE_API_URL.
Ejercicio 10 (propuesto): React Compiler
Activa React Compiler 1.0 como en el módulo 14, pasa todas las pruebas y busca con React DevTools qué componentes aparecen marcados como optimizados. ¿Sobra algún useCallback del proyecto? ¿Y el useMemo del ejercicio 3?
Resumen
| Concepto | API | Idea clave |
|---|---|---|
| Organización por funcionalidades | funcionalidades/, compartido/ | Cada funcionalidad tiene su api.ts; lo compartido no conoce las funcionalidades |
| Estado de servidor | useQuery, queryOptions, useMutation | Todo dato de la API vive en TanStack Query, nunca en un store |
| Estado de cliente | create, persist, getState | Sesión, tema y avisos en Zustand; getState() lo lee fuera de React |
| Cliente HTTP único | api(), ErrorApi | Todas las respuestas se convierten en datos o en un error con status |
| Sesión caducada | 401 con token → cerrarSesion | El store cambia y la ruta protegida redirige sola |
| Ruta protegida | <Navigate>, useLocation | Un componente suscrito al store reacciona en cualquier momento |
| Destino tras el login | ?volver=, destinoSeguro | Solo rutas internas: evita redirecciones abiertas |
| Carga perezosa | lazy, export { X as Component } | Un fichero JavaScript por página |
| Filtros en la URL | useSearchParams, replace | Se comparten, sobreviven a una recarga y vuelven a la página 1 |
| Paginación sin saltos | placeholderData: keepPreviousData | La tabla anterior se atenúa hasta que llega la nueva |
| Invalidación | invalidateQueries({ queryKey }) | Invalida también lo que muestra el dato cambiado |
| Optimismo | onMutate, onError, onSettled | Copia, cambia, deshace si falla, sincroniza siempre |
| Esquemas con conversión | z.input, z.output, z.coerce | El formulario maneja cadenas; tu código recibe números o null |
| Errores 422 | setError, aplicarErrores | Cada error del servidor bajo su campo, con foco |
| Conflictos 409 | mutation.error en el diálogo | El usuario ve el motivo donde actuó y no pierde lo escrito |
| Listas dinámicas | useFieldArray, useWatch | campo.id como key; errores de la lista en .root |
ref como prop | ComponentProps<'input'> 🆕 19.0 | register() funciona sobre componentes propios sin forwardRef |
| Título de documento | <title> 🆕 19.0 | React lo sube al <head> |
| Modo oscuro | @theme inline, @custom-variant | Variables CSS: ningún componente necesita dark: |
| Pruebas | Vitest, MSW 3, Testing Library | Errores de la API simulados en segundos y sin datos de prueba |
| E2E | Playwright, webServer | Comprueba antes la API y falla con cualquier error de consola |
| React anterior a 19.2.7 | react-router@7 | El proyecto funciona igual; en 18.3, forwardRef y document.title |
| Navegación | |
|---|---|
| ← Módulo anterior: Arquitectura y despliegue |