🎯 Meta: dominar React de verdad: componentes, estado, hooks, formularios, datos remotos, rutas y tests. Al terminar sabrás construir una SPA completa contra cualquiera de tus APIs (caps. 03-08) y entenderás las novedades de React 19 (Actions,
use, compilador).Versiones: React 19.2.8 · Vite 8 · React Router 7 · TanStack Query 5 · Vitest 3 + Testing Library · React Compiler 1.0 estable.
📘 Requisitos: cap. 15 (DOM, fetch, ESM) y apéndice A (TypeScript). Todo el capítulo usa TypeScript — en 2026 nadie escribe React profesional sin tipos.
17.1 · Qué es React y cómo piensa
React es una librería para construir UIs declarativas por componentes: en vez de manipular el DOM a mano ("busca el <li>, cámbiale el texto…"), describes cómo se ve la UI para un estado dado y React actualiza el DOM cuando el estado cambia.
Imperativo (cap. 15) Declarativo (React)
┌─────────────────────────┐ ┌──────────────────────────────┐
│ el.textContent = "3" │ │ UI = f(estado) │
│ el.classList.add(...) │ │ cambias el estado ──▶ │
│ lista.append(li) │ │ React recalcula la UI │
│ (tú sincronizas todo) │ │ y actualiza SOLO lo mínimo │
└─────────────────────────┘ └──────────────────────────────┘🧠 La regla de oro de React: la UI es una función del estado. Nunca toques el DOM directamente; cambia el estado y deja que React haga su trabajo. Cuando algo "no se actualiza", el 95% de las veces es porque mutaste el estado en vez de reemplazarlo.
¿Y el Virtual DOM / el Compiler? React re-ejecuta tus componentes y calcula el mínimo de cambios reales al DOM. Desde 2026, el React Compiler 1.0 memoiza automáticamente en build: ya no necesitas espolvorear useMemo/useCallback a mano en la mayoría de casos.
17.2 · Proyecto nuevo con Vite
npm create vite@latest tienda-front -- --template react-ts
cd tienda-front
npm install
npm run dev # http://localhost:5173tienda-front/
├── index.html ← contiene <div id="root">
├── src/
│ ├── main.tsx ← monta React en #root
│ ├── App.tsx ← componente raíz
│ ├── components/ ← tus componentes
│ ├── hooks/ ← custom hooks
│ └── api/ ← funciones fetch tipadas
└── vite.config.ts ← añade aquí el proxy /api (cap. 15.5)// src/main.tsx — el único sitio donde se toca el DOM real
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
createRoot(document.getElementById('root')!).render(
<StrictMode> {/* en dev, detecta efectos mal escritos: déjalo */}
<App />
</StrictMode>,
);17.3 · Componentes, JSX y props
Un componente es una función que devuelve JSX (HTML dentro de JS, con reglas):
// src/components/TarjetaProducto.tsx
interface Props {
nombre: string;
precio: number;
agotado?: boolean; // opcional
onComprar: (nombre: string) => void; // los eventos suben como callbacks
}
export function TarjetaProducto({ nombre, precio, agotado = false, onComprar }: Props) {
return (
<article className="card"> {/* className, no class */}
<h3>{nombre}</h3> {/* {expresión JS} */}
<p>{precio.toFixed(2)} €</p>
{agotado && <span className="badge">Agotado</span>} {/* condicional */}
<button onClick={() => onComprar(nombre)} disabled={agotado}>
Comprar
</button>
</article>
);
}Reglas de JSX que debes memorizar:
- Un solo elemento raíz por return (usa fragmento
<>…</>si necesitas varios). className,htmlFor(noclass,for); atributos en camelCase (onClick,tabIndex).{}evalúa JavaScript;{condicion && <X/>}y{cond ? <A/> : <B/>}para condicionales.- Los componentes se nombran en PascalCase (
<TarjetaProducto />, no<tarjeta/>). - Las props son de solo lectura. Los datos bajan por props; los eventos suben por callbacks.
Listas y key
<ul>
{productos.map((p) => (
<TarjetaProducto key={p.id} nombre={p.nombre} precio={p.precio} onComprar={comprar} />
))}
</ul>⚠️
keydebe ser un id estable y único. Usar el índice del array (key={i}) rompe el estado interno de los items al reordenar/borrar. Es EL bug clásico de listas en React.
17.4 · Estado: useState
El estado es la memoria del componente. Cambiarlo re-renderiza.
import { useState } from 'react';
function Contador() {
const [cuenta, setCuenta] = useState(0); // [valor, setter]
return (
<button onClick={() => setCuenta((c) => c + 1)}>
Clicks: {cuenta}
</button>
);
}Las 3 reglas que evitan el 90% de los bugs de estado:
// 1. NUNCA mutes: crea siempre un objeto/array nuevo
setItems([...items, nuevo]); // ✅ añadir
setItems(items.filter((i) => i.id !== id)); // ✅ borrar
setItems(items.map((i) => i.id === id ? { ...i, precio: 99 } : i)); // ✅ editar
items.push(nuevo); setItems(items); // ❌ mutación: React no ve el cambio
// 2. Si el nuevo valor depende del anterior, usa función
setCuenta((c) => c + 1); // ✅ seguro aunque se llame 2 veces
// 3. El set es asíncrono: no leas el estado justo después de setearlo
setCuenta(5); console.log(cuenta); // ⚠️ imprime el valor VIEJO¿Dónde vive el estado? En el ancestro común más cercano de quien lo necesita ("lift state up"). Los hijos lo reciben por props y avisan de cambios por callbacks.
17.5 · Formularios
Controlados (el patrón clásico)
function FormProducto({ onCrear }: { onCrear: (n: string, p: number) => void }) {
const [nombre, setNombre] = useState('');
const [precio, setPrecio] = useState('');
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
onCrear(nombre.trim(), Number(precio));
setNombre(''); setPrecio('');
}
return (
<form onSubmit={handleSubmit}>
<input value={nombre} onChange={(e) => setNombre(e.target.value)}
placeholder="Nombre" required />
<input value={precio} onChange={(e) => setPrecio(e.target.value)}
type="number" step="0.01" required />
<button disabled={!nombre.trim()}>Crear</button>
</form>
);
}React 19: Actions y useActionState (formularios con async integrado)
React 19 trae Actions: pasas una función async a <form action={...}> y React gestiona pending, errores y reseteo:
import { useActionState } from 'react';
function FormProducto() {
const [error, enviar, enviando] = useActionState(
async (_prev: string | null, formData: FormData) => {
try {
await crearProducto({
nombre: String(formData.get('nombre')),
precio: Number(formData.get('precio')),
});
return null; // sin error → el form se resetea
} catch (e) {
return (e as Error).message; // el error queda en `error`
}
},
null,
);
return (
<form action={enviar}>
<input name="nombre" required />
<input name="precio" type="number" step="0.01" required />
<button disabled={enviando}>{enviando ? 'Creando…' : 'Crear'}</button>
{error && <p className="error">{error}</p>}
</form>
);
}Complementos de la misma familia:
useOptimistic— muestra el resultado antes de que el servidor confirme (y revierte si falla). Ideal para likes, toggles y añadir a listas.useFormStatus— un hijo del form (p. ej.<BotonEnviar/>) leependingsin prop drilling.
const [likesOptimistas, addLikeOptimista] = useOptimistic(likes, (n) => n + 1);
// en la action: addLikeOptimista(); await api.like(id);17.6 · Efectos: useEffect (y cuándo NO usarlo)
Un efecto sincroniza tu componente con algo externo a React: una suscripción, un timer, la API del navegador. Se ejecuta después del render.
useEffect(() => {
const id = setInterval(() => setAhora(new Date()), 1000);
return () => clearInterval(id); // cleanup: al desmontar o antes de re-ejecutar
}, []); // [] = solo al montarEl array de dependencias:
| Dependencias | Cuándo corre |
|---|---|
| (omitido) | Tras cada render (casi nunca lo quieres) |
[] | Solo al montar (+ cleanup al desmontar) |
[a, b] | Al montar y cuando a o b cambien |
⚠️ El error nº 1 de React: abusar de
useEffect. NO lo uses para: transformar datos para render (calcúlalo en el cuerpo), responder a clicks (va en el handler), "reaccionar" a un cambio de estado con otrosetEstado(replantéalo). Referencia obligada: la página You Might Not Need an Effect de la doc oficial. Y para datos remotos, usa TanStack Query (17.9), nouseEffect+fetchartesanal.
React 19.2: useEffectEvent
Extrae de un efecto la parte que lee valores frescos pero no debe relanzarlo:
import { useEffect, useEffectEvent } from 'react';
function Sala({ salaId, tema }: { salaId: string; tema: string }) {
const onConectado = useEffectEvent(() => {
mostrarToast(`Conectado con tema ${tema}`); // lee `tema` fresco…
});
useEffect(() => {
const con = conectar(salaId);
con.on('conectado', onConectado);
return () => con.desconectar();
}, [salaId]); // …sin que `tema` reconecte la sala ✅
}17.7 · El resto de hooks esenciales
// useRef — referencia mutable que NO re-renderiza; también para nodos DOM
const inputRef = useRef<HTMLInputElement>(null);
<input ref={inputRef} />;
inputRef.current?.focus();
// useContext — evita pasar props por 5 niveles (tema, usuario, idioma)
const TemaContext = createContext<'claro' | 'oscuro'>('claro');
<TemaContext value="oscuro"><App /></TemaContext> // React 19: sin .Provider
const tema = useContext(TemaContext);
// useReducer — estado complejo con transiciones definidas (mini-Redux local)
const [estado, dispatch] = useReducer(reducer, estadoInicial);
dispatch({ type: 'añadir', producto });
// use() — React 19: lee una promesa o un contexto; se puede llamar condicionalmente
const datos = use(promesaDeDatos); // suspende hasta resolverse (con <Suspense>)¿Y useMemo / useCallback / memo? Con React Compiler 1.0 activado (babel-plugin-react-compiler, integrado en las plantillas nuevas), la memoización es automática. Escríbelos solo si mides un problema real sin el compilador.
Custom hooks: reutilizar lógica
Cualquier función useAlgo que llame hooks es un hook. Así se comparte lógica en React:
// src/hooks/useLocalStorage.ts
export function useLocalStorage<T>(clave: string, inicial: T) {
const [valor, setValor] = useState<T>(() => {
const raw = localStorage.getItem(clave);
return raw ? (JSON.parse(raw) as T) : inicial;
});
useEffect(() => {
localStorage.setItem(clave, JSON.stringify(valor));
}, [clave, valor]);
return [valor, setValor] as const;
}
// Uso: const [carrito, setCarrito] = useLocalStorage<Item[]>('carrito', []);🧠 Reglas de los hooks: solo en el nivel superior del componente (no dentro de
if/bucles — excepción:use()), y solo dentro de componentes u otros hooks. El ESLinteslint-plugin-react-hooks(v6, con reglas del compilador) las vigila: instálalo siempre.
17.8 · Suspense, <Activity> y transiciones
// Suspense: UI de espera declarativa mientras algo carga (lazy, use(), queries)
const PaginaAdmin = lazy(() => import('./pages/Admin')); // code-splitting
<Suspense fallback={<Spinner />}>
<PaginaAdmin />
</Suspense>
// React 19.2 — <Activity>: oculta manteniendo el estado (tabs, wizards)
import { Activity } from 'react';
<Activity mode={tab === 'ventas' ? 'visible' : 'hidden'}>
<PanelVentas /> {/* al volver a esta tab, conserva scroll, inputs, estado */}
</Activity>
// useTransition: marca actualizaciones pesadas como no urgentes (la UI no se congela)
const [pendiente, startTransition] = useTransition();
startTransition(() => setFiltro(texto));Error Boundaries: contener errores de render
Un error lanzado dentro del render de un componente (no en un handler ni en una promesa) tumba todo el árbol de React si nadie lo atrapa. Un Error Boundary es el único mecanismo que lo contiene — y solo puede escribirse como clase (no existe hook equivalente):
// components/ErrorBoundary.tsx
import { Component, type ReactNode } from 'react';
interface Props { children: ReactNode; fallback: ReactNode; }
interface State { tieneError: boolean; }
export class ErrorBoundary extends Component<Props, State> {
state: State = { tieneError: false };
static getDerivedStateFromError() {
return { tieneError: true };
}
componentDidCatch(error: Error, info: { componentStack: string }) {
console.error('Error atrapado:', error, info.componentStack); // envía a tu observabilidad (apéndice F)
}
render() {
return this.state.tieneError ? this.props.fallback : this.props.children;
}
}// Envuelve secciones independientes: un error en el carrito no debe tumbar toda la página
<ErrorBoundary fallback={<p>Algo falló en el carrito. Recarga la página.</p>}>
<Carrito />
</ErrorBoundary>🧠 Qué SÍ y qué NO atrapa un Error Boundary: atrapa errores de render, lifecycle y constructores de sus hijos. No atrapa errores en event handlers (
onClick, usa try/catch ahí), en código async (promesas rechazadas) ni errores del propio Error Boundary. Para errores async de una query, TanStack Query (17.9) ya te daerrorsin necesitar un boundary.
17.9 · Datos remotos bien hechos: TanStack Query
Fetch manual con useEffect implica gestionar tú: caché, loading, errores, reintentos, revalidación, deduplicación… TanStack Query 5 lo resuelve y es el estándar de facto:
npm install @tanstack/react-query// main.tsx
const queryClient = new QueryClient();
<QueryClientProvider client={queryClient}><App /></QueryClientProvider>
// api/productos.ts — funciones fetch tipadas (cap. 15.5)
export interface Producto { id: number; nombre: string; precio: number; }
export async function listarProductos(): Promise<Producto[]> {
const res = await fetch('/api/productos');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
// components/ListaProductos.tsx
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
function ListaProductos() {
const qc = useQueryClient();
const { data, isPending, error } = useQuery({
queryKey: ['productos'], // clave de caché
queryFn: listarProductos,
});
const crear = useMutation({
mutationFn: crearProducto,
onSuccess: () => qc.invalidateQueries({ queryKey: ['productos'] }), // refresca
});
if (isPending) return <Spinner />;
if (error) return <p className="error">Error: {error.message}</p>;
if (data.length === 0) return <p>No hay productos todavía.</p>;
return (
<>
<FormProducto onCrear={(n, p) => crear.mutate({ nombre: n, precio: p })} />
<ul>{data.map((p) => <li key={p.id}>{p.nombre} — {p.precio} €</li>)}</ul>
</>
);
}💡 Los 4 estados de UI del cap. 15 (cargando/error/vacío/éxito) salen gratis. Y la caché por
queryKeysignifica: navegar de vuelta a una página es instantáneo, con revalidación en segundo plano ("stale-while-revalidate").
17.10 · Rutas: React Router 7
npm install react-router// main.tsx
import { createBrowserRouter, RouterProvider } from 'react-router';
const router = createBrowserRouter([
{
path: '/',
element: <Layout />, // layout común con <Outlet />
children: [
{ index: true, element: <Inicio /> },
{ path: 'productos', element: <ListaProductos /> },
{ path: 'productos/:id', element: <DetalleProducto /> }, // parámetro
{ path: '*', element: <NoEncontrado /> },
],
},
]);
createRoot(root).render(<RouterProvider router={router} />);// Layout.tsx
import { Outlet, Link, NavLink } from 'react-router';
function Layout() {
return (
<>
<nav>
<NavLink to="/productos">Productos</NavLink> {/* .active automático */}
</nav>
<main><Outlet /></main> {/* aquí se pinta el hijo */}
</>
);
}
// DetalleProducto.tsx
import { useParams, useNavigate } from 'react-router';
const { id } = useParams(); // string | undefined
const navigate = useNavigate(); // navigate('/productos') tras borrar🧠 React Router 7 fusionó Remix: además de este modo declarativo tiene modo framework con loaders/actions y SSR. Si quieres SSR completo, el camino recomendado es Next.js (cap. 18).
17.11 · Tests: Vitest + Testing Library
Filosofía (cap. 09 aplica): testea lo que ve el usuario, no la implementación.
npm install -D vitest jsdom @testing-library/react @testing-library/user-event @testing-library/jest-dom// src/components/FormProducto.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { FormProducto } from './FormProducto';
describe('FormProducto', () => {
it('envía nombre y precio al pulsar Crear', async () => {
const onCrear = vi.fn();
render(<FormProducto onCrear={onCrear} />);
await userEvent.type(screen.getByPlaceholderText('Nombre'), 'Teclado');
await userEvent.type(screen.getByRole('spinbutton'), '89.90');
await userEvent.click(screen.getByRole('button', { name: /crear/i }));
expect(onCrear).toHaveBeenCalledWith('Teclado', 89.9);
});
it('deshabilita el botón si el nombre está vacío', () => {
render(<FormProducto onCrear={vi.fn()} />);
expect(screen.getByRole('button', { name: /crear/i })).toBeDisabled();
});
});Para componentes con red, intercepta con MSW (Mock Service Worker): mockeas el HTTP, no tus funciones — los tests cubren también tu capa api/.
npm run test # vitest en modo watch17.12 · Buenas prácticas React
- Componentes pequeños y puros: mismo props+estado → mismo JSX. Sin side-effects en render.
- Estado mínimo: lo derivable se calcula, no se guarda (
totalse deriva deitems). - Nunca mutar estado ni props. Spread (
...),map,filter. keyestable en listas; jamás el índice si la lista cambia.- Datos remotos = TanStack Query.
useEffect+fetchsolo para aprender cómo funciona. - Pocas dependencias globales de estado: contexto para tema/usuario; para estado complejo compartido, Zustand o el propio Query. Redux solo si ya está en el proyecto.
- ESLint con
react-hooksv6 + React Compiler activados desde el día 1. - Accesibilidad: botones reales, labels, roles — Testing Library te empuja a ello (si no puedes seleccionar por rol, tu HTML probablemente está mal).
- Estructura por feature (
features/productos/{componentes,hooks,api}), no por tipo, cuando el proyecto crece — mismo criterio que en NestJS (cap. 06).
✅ Ejercicio del capítulo
SPA completa de la tienda contra tu API del libro:
1. Proyecto Vite react-ts + proxy /api + ESLint react-hooks + React Compiler.
2. Rutas: / (inicio), /productos (lista), /productos/:id (detalle), 404.
3. Lista con TanStack Query: estados cargando/error/vacío, búsqueda con
useTransition para filtrar sin congelar la UI.
4. Alta con useActionState (pending + errores del servidor) y borrado con
useMutation + useOptimistic (la fila desaparece al instante y revierte si falla).
5. Carrito con useReducer + persistencia con tu hook useLocalStorage.
6. Tabs "Lista / Carrito" con <Activity> (el scroll y filtros se conservan).
7. Modo oscuro con Context (sin prop drilling).
8. Tests: FormProducto (user-event), reducer del carrito, y lista con MSW.
9. Envuelve el Carrito en un Error Boundary con un fallback propio.React manda en el cliente, pero le falta lo que el backend hacía gratis: render en servidor, SEO, rutas con datos… Eso es Next.js.
💡 Pistas de la solución (abre solo si te atascas)
- El punto 3:
useTransitionenvuelve elsetFiltro, no eluseQueryen sí — la key de la query cambia con el filtro y React Query se encarga de refetchear;startTransitionsolo evita que la UI se congele mientras React reconcilia la lista filtrada. - Para el punto 4,
useOptimisticnecesita un valor base (datade la query) y una función reductora; eluseMutation.onErrores quien revierte si el borrado falla en el servidor. - El error típico del punto 5: mutar el array del carrito con
.push()en vez de crear uno nuevo — revisa que cada acción del reducer devuelva un array/objeto nuevo (17.4). - Si
<Activity>no conserva el scroll de la lista, revisa que el componente NO se desmonte condicionalmente por fuera ({tab === 'lista' && <Lista/>}) —<Activity mode="hidden">debe envolver el componente, no sustituir el condicional.
🧠 Autoevaluación
React decide si debe re-renderizar comparando la referencia del estado entre renders. Si mutas el array original, la referencia no cambia y React asume que nada cambió, aunque el contenido sea distinto. Por eso siempre se crea una copia nueva ([...items, x]).
React usa la key para saber qué instancia del componente corresponde a qué elemento entre renders. Con el índice como key, al borrar el elemento 2, el elemento 3 pasa a tener key 2 — React lo trata como "el mismo" componente que antes ocupaba esa posición y reutiliza su estado interno, que ya no corresponde a los datos correctos.
useEffect + fetch a mano solo tiene sentido para entender qué hace React Query por debajo: gestionar caché, reintentos, deduplicación de peticiones, revalidación en segundo plano y los 4 estados de UI. En cualquier proyecto real, TanStack Query evita reimplementar todo eso mal.
El compilador analiza el código en build time y memoiza automáticamente los cálculos y funciones que no cambian entre renders, aplicando la misma optimización que antes había que escribir a mano. Siguen existiendo para casos que el compilador no puede inferir, pero dejan de ser el reflejo por defecto en cada componente.
useActionState guarda el último valor devuelto por la action (incluido el error) como parte del estado del componente, no como una variable local que se resetea. Mientras no se envíe una nueva acción con éxito, el error persiste en pantalla aunque el componente se re-renderice por otros motivos.
Porque los Error Boundaries solo capturan errores que ocurren durante el render, los métodos de ciclo de vida y los constructores de sus componentes hijos — un handler de evento corre fuera de ese flujo, así que un error ahí debe manejarse con un try/catch normal dentro del propio handler.
Siguiente: 18-nextjs.md — React fullstack en producción.