Módulo 05: Eventos y ciclo de renderizado
Objetivos del módulo
- Conectar manejadores de eventos a los elementos y pasarlos como props sin cometer el error de llamarlos
- Entender qué es un evento sintético, cómo se relaciona con el evento nativo y por qué React escucha desde la raíz
- Controlar la propagación: captura, burbuja,
targetfrente acurrentTargetystopPropagation - Cancelar el comportamiento del navegador con
preventDefaulty saber cuándo no funciona - Tipar los eventos en TypeScript sin confundirlos con los tipos nativos del DOM
- Describir las tres fases de una actualización: trigger, render y commit
- Razonar con el estado como una instantánea y predecir el resultado de la cola de actualizaciones
- Saber cuándo React conserva el estado de un componente y cuándo lo destruye
- Usar
keypara reiniciar un componente a propósito - Interpretar lo que hace Strict Mode y aprovecharlo para encontrar errores
1. Responder a lo que hace el usuario
En el módulo 04 usaste onClick para cambiar el estado sin detenerte en cómo funciona. Este módulo abre esa caja: primero veremos qué pasa desde que el usuario pulsa hasta que llega a tu función, y después qué pasa desde que cambias el estado hasta que la pantalla se actualiza. Casi todas las sorpresas que da React a quien empieza salen de una de esas dos mitades.
Prepara el proyecto
Crea un proyecto como en el módulo 01:
npm create vite@latest modulo-05 -- --template react-ts
cd modulo-05
npm install
npm run dev
Las secciones 9 y 13 piden datos a la API de ejemplo. Descarga recursos/empresa-api.zip, descomprímelo y arráncalo en otra terminal con node server.mjs. Quedará en http://localhost:3000/api.
Cada ejemplo completo de este módulo sustituye a src/App.tsx. Borra src/App.css y sustituye src/index.css por estas reglas, que usan todos los ejemplos:
:root {
font-family: 'Segoe UI', system-ui, sans-serif;
color: #263238;
background: #f5f6fa;
}
body {
margin: 0;
padding: 1.25rem;
}
.tarjeta {
background: #ffffff;
border: 1px solid #c5cae9;
border-radius: 10px;
padding: 1rem 1.25rem;
margin-bottom: 1rem;
max-width: 40rem;
}
.tarjeta h2 {
margin-top: 0;
color: #1a237e;
font-size: 1.15rem;
}
button {
font: inherit;
padding: 0.35rem 0.8rem;
margin: 0.15rem 0.3rem 0.15rem 0;
border: 1px solid #3f51b5;
border-radius: 6px;
background: #e8eaf6;
color: #1a237e;
cursor: pointer;
}
button:hover {
background: #c5cae9;
}
input,
select {
font: inherit;
padding: 0.3rem 0.5rem;
border: 1px solid #90a4ae;
border-radius: 6px;
}
label {
display: block;
margin-bottom: 0.5rem;
}
.fila {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.4rem 0.6rem;
border-radius: 6px;
cursor: pointer;
}
.fila:hover {
background: #eceff1;
}
.fila.activa {
background: #c5cae9;
}
.registro {
font-family: Consolas, 'Courier New', monospace;
font-size: 0.9rem;
background: #263238;
color: #eceff1;
padding: 0.75rem;
border-radius: 8px;
min-height: 1.5rem;
white-space: pre-wrap;
}
.error {
color: #c62828;
}
⚠️ Importante: La plantilla de Vite envuelve la aplicación en
<StrictMode>, y eso hace que en desarrollo cada componente se renderice dos veces. La sección 12 lo explica. Hasta entonces, las trazas que cuentan renders se han tomado sin<StrictMode>para que se lean mejor, y el texto lo avisa en cada caso. Para ver exactamente lo mismo, quítalo un momento desrc/main.tsx.
Tu primer manejador
Un manejador de eventos (event handler) es una función que React ejecuta cuando ocurre algo en un elemento. Se pasa como una prop cuyo nombre empieza por on:
import { useState } from 'react';
export default function App() {
const [guardados, setGuardados] = useState(0);
function guardar() {
setGuardados(guardados + 1);
console.log('Ficha guardada');
}
return (
<div className="tarjeta">
<h2>Ficha de empleado</h2>
<p>Has guardado {guardados} veces.</p>
<button onClick={guardar}>Guardar</button>
</div>
);
}
Los manejadores suelen declararse dentro del componente, porque así ven sus props y su estado. A diferencia del render, que debe ser puro (lo viste en el módulo 03), un manejador puede tener efectos secundarios: cambiar el estado, llamar a la API o escribir en localStorage. Es el lugar natural para todo lo que ocurre porque el usuario ha hecho algo.
La convención en inglés es handleClick para la función y onClick para la prop. En este curso, como el resto de identificadores van en español, llamaremos a las funciones guardar o alPulsar, y a las props de nuestros componentes onGuardar u onBorrar.
Pasar la función, no llamarla
Este es el error más frecuente al empezar. Fíjate en la diferencia entre estas tres líneas:
| Código | Qué recibe React | Qué ocurre |
|---|---|---|
onClick={guardar} | La función | ✅ React la llama cuando el usuario pulse |
onClick={guardar()} | Lo que devuelve guardar | ❌ Se ejecuta durante el render, en cada render, sin que nadie pulse |
onClick={() => guardar()} | Una función nueva que llama a guardar | ✅ Igual que la primera; útil para pasar argumentos |
JSX evalúa todo lo que hay entre llaves mientras se calcula la interfaz. Por eso los paréntesis cambian por completo el significado: guardar es la función, y guardar() es ejecutarla ya.
Con una función que cambia el estado, el error es además fatal:
import { useState } from 'react';
export default function App() {
const [n, setN] = useState(0);
// ❌ MAL: cambia el estado mientras renderiza
return <button onClick={setN(n + 1)}>Sumar {n}</button>;
}
TypeScript te avisa antes de ejecutar, porque setN devuelve void y onClick espera una función:
src/App.tsx(7,18): error TS2322: Type 'void' is not assignable to type 'MouseEventHandler<HTMLButtonElement> | undefined'.
Si aun así lo ejecutas, cada render cambia el estado, y cada cambio pide otro render. React corta el bucle y la pantalla queda en blanco con este error en la consola:
Too many re-renders. React limits the number of renders to prevent an infinite loop.
Cuando necesites pasar un argumento, envuelve la llamada en una función flecha:
import { useState } from 'react';
const departamentos = [
{ deptId: 10, nombre: 'DIRECCIÓN' },
{ deptId: 20, nombre: 'VENTAS' },
{ deptId: 30, nombre: 'MARKETING' },
];
export default function App() {
const [elegido, setElegido] = useState<number | null>(null);
return (
<div className="tarjeta">
<h2>Departamento: {elegido ?? 'ninguno'}</h2>
{departamentos.map((d) => (
<button key={d.deptId} onClick={() => setElegido(d.deptId)}>
{d.nombre}
</button>
))}
</div>
);
}
Crear una función en cada render no es un problema de rendimiento en la inmensa mayoría de los casos. Volveremos sobre esto en el módulo 14.
Pasar manejadores como props
Un elemento HTML como <button> da un significado especial a onClick. En cambio, en tus componentes onGuardar es una prop como cualquier otra: una función que el padre entrega al hijo. Así el hijo avisa de que ha pasado algo sin saber qué hará el padre con ello:
import { useState } from 'react';
import type { ReactNode } from 'react';
interface BotonAccionProps {
children: ReactNode;
onAccion: () => void;
}
function BotonAccion({ children, onAccion }: BotonAccionProps) {
return <button onClick={onAccion}>{children}</button>;
}
interface FilaEmpleadoProps {
id: number;
nombre: string;
onBorrar: (id: number) => void;
}
function FilaEmpleado({ id, nombre, onBorrar }: FilaEmpleadoProps) {
return (
<div className="fila">
<span>{nombre}</span>
<BotonAccion onAccion={() => onBorrar(id)}>Borrar</BotonAccion>
</div>
);
}
export default function App() {
const [empleados, setEmpleados] = useState([
{ id: 100, nombre: 'Ana García' },
{ id: 101, nombre: 'Luis Martínez' },
{ id: 102, nombre: 'Marta Sánchez' },
]);
function borrar(id: number) {
setEmpleados(empleados.filter((e) => e.id !== id));
}
return (
<div className="tarjeta">
<h2>Plantilla ({empleados.length})</h2>
{empleados.map((e) => (
<FilaEmpleado key={e.id} id={e.id} nombre={e.nombre} onBorrar={borrar} />
))}
</div>
);
}
El estado vive en App, que es quien sabe borrar. FilaEmpleado solo sabe que debe avisar con un identificador, y BotonAccion ni siquiera sabe qué botón es. Cada uno añade lo que conoce: es el mismo flujo de “los datos bajan y los avisos suben” del módulo 04.
💡 Consejo: Da a las props de tus componentes nombres que digan qué ha pasado en el lenguaje de la aplicación (
onBorrar,onCambiarDepartamento), no cómo se ha hecho (onClickBotonRojo). Si mañana el botón se convierte en un atajo de teclado, la prop sigue teniendo sentido.
Nombres de los eventos
Los elementos del DOM admiten onClick, onChange, onKeyDown y demás, siempre en camelCase. Si escribes onclick, como en HTML, TypeScript te frena, y si llega a ejecutarse React no lo conecta:
Invalid event handler property `onclick`. Did you mean `onClick`?
Date cuenta de que onClick en JSX no tiene nada que ver con el atributo onclick de HTML. Aquí no pasas una cadena con código, sino una función, y React no la escribe en el HTML: inspecciona el botón en el navegador y verás que no tiene ningún atributo onclick.
2. Eventos sintéticos
Tu manejador recibe un argumento con la información de lo ocurrido. Parece un evento del navegador, pero no lo es del todo:
import type { MouseEvent } from 'react';
export default function App() {
function alPulsar(e: MouseEvent<HTMLButtonElement>) {
console.log('tipo:', e.type);
console.log('botón:', e.currentTarget.textContent);
console.log('coordenadas:', e.clientX > 0, e.clientY > 0);
console.log('Ctrl pulsada:', e.ctrlKey);
console.log('¿es un evento nativo?', e instanceof Event);
console.log('e.nativeEvent es nativo:', e.nativeEvent instanceof Event);
console.log('clase del nativo:', e.nativeEvent.constructor.name);
}
return (
<div className="tarjeta">
<button onClick={alPulsar}>Inspeccionar el evento</button>
</div>
);
}
tipo: click
botón: Inspeccionar el evento
coordenadas: true true
Ctrl pulsada: false
¿es un evento nativo? false
e.nativeEvent es nativo: true
clase del nativo: PointerEvent
📘 Concepto: Un evento sintético (SyntheticEvent) es un objeto que crea React y que envuelve al evento nativo del navegador. Ofrece la misma interfaz que el evento estándar (
type,target,preventDefault(),clientX…) y da acceso al original mediantee.nativeEvent.
¿Para qué envolverlo? Por dos motivos:
- Un único modelo de eventos, igual aunque el evento se origine en un elemento normal, en un portal (módulo 10) o en un árbol de React dentro de otra página.
- Un punto de control. Como React recibe el evento antes que tu código, puede agrupar los cambios de estado de ese evento (sección 8) y decidir con qué prioridad renderizarlos.
React escucha en la raíz
Quizá pienses que React hace un addEventListener en cada botón. No es así. Hemos contado, en una página con el ejemplo anterior, las llamadas reales a addEventListener:
#root: 142 oyentes (87 de captura y 55 de burbuja)
botón: 0 oyentes
El botón no tiene ninguno. Al llamar a createRoot, React registra de golpe oyentes para todos los eventos que conoce en el contenedor raíz, tanto en fase de captura como de burbuja. Es lo que se llama delegación de eventos, una técnica clásica de JavaScript que repasarás en el Curso de JavaScript.
Cuando pulsas, el evento nativo sube por el DOM hasta #root. Allí React lo recoge, averigua sobre qué componente ha caído, recorre su propio árbol buscando quién tiene un onClick y ejecuta esos manejadores.
Figura 1. La delegación: un solo punto de escucha en la raíz. Tu onClick no es un oyente del DOM, sino una entrada que React consulta.
Esto tiene consecuencias prácticas que veremos enseguida: añadir y quitar filas de una tabla no cuesta ni un oyente, y el orden respecto a los oyentes que pongas tú a mano puede sorprenderte (sección 3).
📘 Concepto: Hasta React 16 los oyentes se registraban en
document. Desde React 17 se registran en el contenedor decreateRoot, lo que permite convivir con otras aplicaciones en la misma página. React 17 eliminó además el reciclaje de eventos (event pooling): si lees tutoriales antiguos cone.persist(), sobra. El método sigue existiendo, pero no hace nada.
currentTarget desaparece al terminar
El objeto del evento sigue siendo legible después del manejador, con una excepción que muerde en el código asíncrono: currentTarget vuelve a null en cuanto React termina de despachar:
import type { MouseEvent } from 'react';
export default function App() {
async function alPulsar(e: MouseEvent<HTMLButtonElement>) {
const boton = e.currentTarget; // ✅ Guárdalo antes del primer await
console.log('antes del await:', e.currentTarget?.tagName);
await Promise.resolve();
console.log('después del await:', e.currentTarget);
console.log('tipo, sigue disponible:', e.type);
console.log('copia guardada:', boton.tagName);
}
return (
<div className="tarjeta">
<button onClick={alPulsar}>Probar</button>
</div>
);
}
antes del await: BUTTON
después del await: null
tipo, sigue disponible: click
copia guardada: BUTTON
En qué se distinguen de los eventos nativos
La mayoría se comportan igual que en el navegador, salvo estas diferencias que hemos comprobado una a una en Chromium:
| Prop | Qué cambia respecto al evento nativo |
|---|---|
onChange | Se dispara en cada pulsación, como el evento nativo input. El change nativo solo salta al salir del campo |
onFocus, onBlur | Sí se propagan hacia arriba (React usa focusin y focusout). Los nativos focus y blur no suben |
onScroll | No se propaga en React, aunque desde dentro sí puedes verlo con onScrollCapture en un antecesor |
onMouseEnter, onMouseLeave | No se propagan: saltan al entrar o salir del elemento, sin importar sus hijos |
onMouseOver, onMouseOut | Se propagan: saltan también al moverte entre hijos |
La primera fila es la que más se nota. Si vienes de JavaScript puro, recuerda que en React onChange equivale a oninput.
3. Propagación: captura y burbuja
Cuando pulsas un botón, también estás pulsando la fila que lo contiene, la tarjeta que contiene la fila y el resto de la página. Por eso el navegador no entrega el evento solo al botón: lo hace viajar en tres fases, que React imita en su árbol.
- Captura: baja desde la raíz hasta el elemento pulsado.
- Destino: llega al elemento.
- Burbuja (bubbling): sube de vuelta hasta la raíz.
Un onClick normal escucha en la burbuja. Si añades Capture al nombre (onClickCapture), escuchas en la bajada.
Figura 2. Recorrido de un clic. Los manejadores onClickCapture se ejecutan bajando, y los onClick, subiendo.
Este ejemplo lo hace visible:
import { useState } from 'react';
export default function App() {
const [traza, setTraza] = useState<string[]>([]);
function anotar(texto: string) {
setTraza((anterior) => [...anterior, texto]);
}
// Devuelve las dos props para no repetirlas en cada elemento
function espiar(nombre: string) {
return {
onClickCapture: () => anotar(`captura ${nombre}`),
onClick: () => anotar(`burbuja ${nombre}`),
};
}
return (
<>
<section className="tarjeta" {...espiar('tarjeta')}>
<h2>Tarjeta</h2>
<div className="fila" {...espiar('fila')}>
<span>Fila de Ana García</span>
<button {...espiar('botón')}>Borrar</button>
</div>
</section>
<button onClick={() => setTraza([])}>Limpiar</button>
<pre className="registro">{traza.join('\n')}</pre>
</>
);
}
Al pulsar Borrar obtenemos:
captura tarjeta
captura fila
captura botón
burbuja botón
burbuja fila
burbuja tarjeta

Figura 3. Un solo clic ejecuta seis manejadores.
Si pulsas en el texto de la fila, en lugar del botón, solo aparecen cuatro líneas: el botón ya no está en el camino.
Fíjate en que anotar usa la función actualizadora (anterior => ...). Los seis manejadores se ejecutan dentro del mismo evento, con la misma instantánea del estado, así que si escribieras setTraza([...traza, texto]) solo sobreviviría la última línea. La sección 7 explica el porqué.
target frente a currentTarget
Durante el recorrido hay dos elementos distintos en juego:
e.targetes el elemento donde ocurrió el evento. No cambia durante el viaje.e.currentTargetes el elemento cuyo manejador se está ejecutando. Cambia en cada paso.
import type { MouseEvent } from 'react';
export default function App() {
function alPulsar(e: MouseEvent<HTMLDivElement>) {
const origen = e.target as HTMLElement;
console.log('target:', origen.tagName, '| currentTarget:', e.currentTarget.tagName);
}
return (
<div className="tarjeta" onClick={alPulsar}>
<h2>Pulsa en cualquier parte</h2>
<p>
Un párrafo con <strong>texto destacado</strong>.
</p>
</div>
);
}
target: STRONG | currentTarget: DIV
target: H2 | currentTarget: DIV
El manejador siempre es el del div, pero target dice qué parte se pulsó. Por eso TypeScript da a currentTarget el tipo que indicas (HTMLDivElement) y deja target como un EventTarget genérico: el evento puede venir de cualquier descendiente, y solo tú sabes qué esperas ahí.
Esto permite, por ejemplo, reaccionar solo si se pulsó el fondo y no un hijo, comparando ambos: if (e.target === e.currentTarget) { ... }. Es útil para cerrar un diálogo al pulsar fuera de su contenido.
Detener la propagación con stopPropagation
La propagación es lo que quieres casi siempre, pero a veces estorba. Un caso muy habitual en una aplicación de gestión es una fila que se selecciona al pulsarla y que contiene un botón:
import { useState } from 'react';
import type { MouseEvent } from 'react';
export default function App() {
const [plantilla, setPlantilla] = useState(['Ana García', 'Luis Martínez']);
const [seleccionado, setSeleccionado] = useState<string | null>(null);
function borrar(e: MouseEvent<HTMLButtonElement>, nombre: string) {
e.stopPropagation(); // Sin esto, el clic llegaría también a la fila
setPlantilla(plantilla.filter((p) => p !== nombre));
}
return (
<div className="tarjeta">
<h2>Seleccionado: {seleccionado ?? 'nadie'}</h2>
{plantilla.map((nombre) => (
<div
key={nombre}
className={nombre === seleccionado ? 'fila activa' : 'fila'}
onClick={() => setSeleccionado(nombre)}
>
<span>{nombre}</span>
<button onClick={(e) => borrar(e, nombre)}>Borrar</button>
</div>
))}
</div>
);
}
Sin e.stopPropagation(), borrar a Ana la dejaría seleccionada aunque ya no exista. stopPropagation impide que el evento siga viajando hacia arriba. Solo afecta al viaje: no cancela nada del navegador, que es tarea de preventDefault (sección 4).
Por qué no conviene abusar de él
Muchas piezas de una interfaz escuchan clics en document para enterarse de lo que pasa en cualquier parte. El ejemplo típico es un menú desplegable que se cierra al pulsar fuera. Si un componente corta la propagación, esas piezas se quedan ciegas. Hemos montado un banco de pruebas para ver el orden real, y de paso usa useEffect, que aún no has estudiado: basta con saber que ejecuta código tras mostrar el componente, y que lo estudiarás en el módulo 09.
import { useEffect } from 'react';
export default function App() {
useEffect(() => {
const enDocumento = () => console.log('nativo en document (burbuja)');
const enCaptura = () => console.log('nativo en document (captura)');
const enBoton = () => console.log('nativo en el propio botón');
const boton = document.getElementById('con-nativo');
document.addEventListener('click', enDocumento);
document.addEventListener('click', enCaptura, { capture: true });
boton?.addEventListener('click', enBoton);
return () => {
document.removeEventListener('click', enDocumento);
document.removeEventListener('click', enCaptura, { capture: true });
boton?.removeEventListener('click', enBoton);
};
}, []);
return (
<div className="tarjeta">
<button id="con-nativo" onClick={() => console.log('React: onClick')}>
Normal
</button>
<button
id="parando"
onClick={(e) => {
e.stopPropagation();
console.log('React: onClick con stopPropagation');
}}
>
Parando la propagación
</button>
</div>
);
}
Botón Normal:
nativo en document (captura)
nativo en el propio botón
React: onClick
nativo en document (burbuja)
Botón Parando la propagación:
nativo en document (captura)
React: onClick con stopPropagation
Tres lecciones:
- Un oyente nativo puesto directamente en el botón se ejecuta antes que tu
onClick, aunque esté en el mismo botón, porque React no se entera hasta que el evento llega a#root. stopPropagationen React detiene también el evento nativo, así que el oyente de burbuja dedocumentno se ejecuta. Un menú que se cierre con ese oyente seguiría abierto.- El oyente de captura en
documentse ejecuta siempre el primero. Por eso los componentes de “clic fuera” bien hechos lo usan:{ capture: true }.
⚠️ Importante: Antes de escribir
stopPropagation, pregúntate si puedes resolverlo de otra forma: comparare.target, reorganizar quién tiene el manejador o no anidar un elemento pulsable dentro de otro. Úsalo para casos concretos como la fila con botón, no como costumbre.
Escuchar antes que nadie con onClickCapture
La captura se ejecuta aunque un descendiente detenga la burbuja, porque ocurre antes. Sirve para registrar o auditar lo que pasa en una zona entera sin depender de lo que hagan los hijos:
<main onClickCapture={() => registrarActividad()}>{/* ... */}</main>
Lo usarás en el ejercicio 8. Para el código normal, onClick es lo correcto.
Los portales propagan por el árbol de React
El viaje sigue el árbol de componentes, no el del DOM. Un modal creado con createPortal, que estudiarás en el módulo 10, puede estar colgado directamente de <body>, y aun así sus clics llegan a los onClick del componente que lo renderiza. Lo hemos comprobado: un <div onClick> recibe el clic de un botón que, en el DOM, está fuera de él.
4. Cancelar lo que hace el navegador: preventDefault
Algunos eventos traen un comportamiento por defecto. Enviar un formulario recarga la página, un enlace navega y Ctrl+S abre el diálogo de guardar del navegador. e.preventDefault() lo cancela:
import { useState } from 'react';
import type { SubmitEvent } from 'react';
export default function App() {
const [texto, setTexto] = useState('');
const [busqueda, setBusqueda] = useState('');
function alEnviar(e: SubmitEvent<HTMLFormElement>) {
e.preventDefault(); // Sin esto, el navegador recarga la página
setBusqueda(texto.trim());
}
return (
<form className="tarjeta" onSubmit={alEnviar}>
<h2>Buscar empleados</h2>
<input value={texto} onChange={(e) => setTexto(e.target.value)} placeholder="Apellido" />
<button type="submit">Buscar</button>
<p>Última búsqueda: {busqueda || 'ninguna'}</p>
</form>
);
}
Dos detalles de este ejemplo:
- El manejador va en
onSubmitdel formulario, no enonClickdel botón. Así funciona también al pulsar Enter dentro del campo, que el navegador convierte en un envío. - El estado guarda
texto(lo que se escribe) ybusqueda(lo que se ha enviado), que son dos cosas distintas.
Los formularios tienen un módulo propio, el 08. Allí verás también <form action>, de React 19, que cancela el envío por ti.
No confundas los dos métodos
e.stopPropagation() | e.preventDefault() | |
|---|---|---|
| Qué detiene | El viaje del evento por el árbol | La acción predeterminada del navegador |
| ¿Siguen los manejadores de arriba? | No | Sí |
| ¿Sigue la acción del navegador? | Sí | No |
| Ejemplo | La fila no se entera del clic en el botón | El formulario no recarga la página |
Son independientes: puedes usar uno, otro o los dos. Y no te fíes de lo que recuerdes de los antiguos manejadores onclick de HTML: en React, devolver false no cancela nada. Lo hemos probado con un onSubmit={() => false} y la página se recarga igualmente. Hay que llamar a preventDefault.
Teclado: cancelar solo lo que te interesa
Un atajo de teclado debe cancelar únicamente su combinación. Si llamas a preventDefault sin condiciones, el campo deja de admitir texto:
import { useState } from 'react';
import type { KeyboardEvent } from 'react';
export default function App() {
const [nota, setNota] = useState('');
const [guardada, setGuardada] = useState('');
function alPulsarTecla(e: KeyboardEvent<HTMLInputElement>) {
if (e.key === 's' && e.ctrlKey) {
e.preventDefault(); // Evita el diálogo de guardar del navegador
setGuardada(nota);
} else if (e.key === 'Escape') {
setNota(guardada);
}
}
return (
<div className="tarjeta">
<h2>Nota del empleado</h2>
<input value={nota} onChange={(e) => setNota(e.target.value)} onKeyDown={alPulsarTecla} />
<p>Guardado: {guardada || '(nada)'}. Ctrl+S guarda y Escape descarta.</p>
</div>
);
}
Eventos en los que preventDefault no funciona
Para que el scroll vaya fluido, el navegador no espera a que JavaScript decida si lo cancela. React registra en la raíz wheel, touchstart y touchmove como oyentes pasivos, y en un oyente pasivo preventDefault se ignora. Si lo intentas en onWheel, el contenido se desplaza igualmente y la consola te avisa:
import { useEffect, useRef, useState } from 'react';
const lineas = Array.from({ length: 30 }, (_, i) => `Línea ${i + 1}`);
export default function App() {
const [nivel, setNivel] = useState(0);
const caja = useRef<HTMLDivElement>(null);
// ✅ Un oyente nativo no pasivo sí puede cancelar la rueda
useEffect(() => {
const nodo = caja.current;
if (!nodo) return;
function alMoverRueda(e: WheelEvent) {
e.preventDefault();
setNivel((n) => n + Math.sign(e.deltaY));
}
nodo.addEventListener('wheel', alMoverRueda, { passive: false });
return () => nodo.removeEventListener('wheel', alMoverRueda);
}, []);
const estilo = { height: '6rem', overflow: 'auto', border: '1px solid #90a4ae' };
return (
<div className="tarjeta">
<h2>Zoom con la rueda: {nivel}</h2>
<div id="con-react" style={estilo} onWheel={(e) => e.preventDefault()}>
{/* ❌ Se sigue desplazando */}
{lineas.map((l) => (
<p key={l}>{l}</p>
))}
</div>
<div id="con-nativo" ref={caja} style={estilo}>
{lineas.map((l) => (
<p key={l}>{l}</p>
))}
</div>
</div>
);
}
Unable to preventDefault inside passive event listener invocation.
Tras mover la rueda 100 px sobre cada caja:
caja con onWheel: scrollTop = 100
caja con oyente nativo: scrollTop = 0, y el nivel sube a 1
Aquí WheelEvent no se importa de React: es el tipo global del DOM, porque el oyente es nativo. useRef y useEffect se estudian en los módulos 10 y 09. De momento quédate con la idea: si necesitas cancelar la rueda o un gesto táctil, onWheel no te vale.
5. Tipos de eventos con TypeScript
Cuando TypeScript lo deduce solo
Si escribes la función en línea, no hace falta anotar nada. TypeScript sabe que onChange de un <input> recibe un evento de HTMLInputElement:
<input onChange={(e) => setNombre(e.target.value)} />
Si pasas el ratón por e en VS Code, verás el tipo exacto. Es un truco muy útil cuando vas a extraer la función: copias el tipo que te muestra el editor.
Cuando extraes la función
Fuera de la línea no hay contexto del que deducir, y debes anotar el parámetro. Los tipos se importan de react y reciben el elemento como parámetro genérico:
import type { ChangeEvent, MouseEvent, MouseEventHandler } from 'react';
function alCambiar(e: ChangeEvent<HTMLInputElement>) {
console.log(e.target.value);
}
function alPulsar(e: MouseEvent<HTMLButtonElement>) {
console.log(e.currentTarget.disabled);
}
// Alternativa: tipar la función entera, y así e se deduce
const alSalir: MouseEventHandler<HTMLButtonElement> = (e) => {
console.log(e.altKey);
};
⚠️ Importante: Si olvidas importar
MouseEventdereact, TypeScript no se queja de que falte: usa elMouseEventglobal del DOM, que es otro tipo. El error aparece en el JSX y confunde bastante:error TS2322: Type '(e: MouseEvent) => void' is not assignable to type 'MouseEventHandler<HTMLButtonElement>'. Types of parameters 'e' and 'event' are incompatible. Type 'MouseEvent<HTMLButtonElement, MouseEvent>' is missing the following properties from type 'MouseEvent': layerX, layerY, offsetX, offsetY, and 16 more.Lo mismo pasa con
KeyboardEvent,SubmitEvent,DragEvent,FocusEvent… Si ves un error con esa forma, revisa losimport.
Tabla de tipos
Tipo (de react) | Props habituales | Datos útiles |
|---|---|---|
MouseEvent<T> | onClick, onDoubleClick, onContextMenu, onMouseEnter | clientX, clientY, button, ctrlKey, shiftKey |
PointerEvent<T> | onPointerDown, onPointerMove, onPointerUp | Los de ratón, más pointerId, pointerType, pressure |
KeyboardEvent<T> | onKeyDown, onKeyUp | key, code, ctrlKey, repeat |
ChangeEvent<T> | onChange | target.value, target.checked, target.name |
InputEvent<T> | onBeforeInput, onInput | data |
SubmitEvent<T> | onSubmit | submitter (sección siguiente) |
FocusEvent<T> | onFocus, onBlur | relatedTarget |
ClipboardEvent<T> | onCopy, onCut, onPaste | clipboardData |
DragEvent<T> | onDragStart, onDragOver, onDrop | dataTransfer |
WheelEvent<T> | onWheel | deltaX, deltaY |
TouchEvent<T> | onTouchStart, onTouchMove, onTouchEnd | touches |
AnimationEvent<T>, TransitionEvent<T> | onAnimationEnd, onTransitionEnd | animationName, propertyName |
ToggleEvent<T> | onToggle, onBeforeToggle | oldState, newState |
SyntheticEvent<T> | onReset, onLoad, onError | Solo lo común |
⚠️ Importante: En
@types/react19.3.0,FormEventyFormEventHandlerestán marcados como obsoletos, porque ese evento no existe en el navegador. Se recomiendaChangeEvent,InputEvent,SubmitEventoSyntheticEvent. Si en tutoriales vesonSubmit={(e: FormEvent<HTMLFormElement>) => ...}, funciona, peroSubmitEventda además el tipo correcto desubmitter. Si tus tipos son más antiguos y no declaranSubmitEvent, usaFormEvent.
Este ejemplo reúne varios tipos en una sola tarjeta. Prueba a teclear, a pulsar con el botón derecho o a salir del campo:
import { useState } from 'react';
import type { ClipboardEvent, FocusEvent, KeyboardEvent, MouseEvent } from 'react';
export default function App() {
const [ultimo, setUltimo] = useState('Aún no ha pasado nada');
function alPulsarTecla(e: KeyboardEvent<HTMLInputElement>) {
const modificador = e.ctrlKey ? 'Ctrl + ' : '';
setUltimo(`keydown: ${modificador}${e.key} (código ${e.code})`);
}
function alSalir(e: FocusEvent<HTMLInputElement>) {
const destino = e.relatedTarget?.tagName ?? 'fuera de la página';
setUltimo(`blur: el foco va a ${destino}`);
}
function alPegar(e: ClipboardEvent<HTMLInputElement>) {
setUltimo(`paste: "${e.clipboardData.getData('text')}"`);
}
function alPulsarRaton(e: MouseEvent<HTMLDivElement>) {
e.preventDefault(); // Evita el menú contextual del navegador
const zona = e.currentTarget.getBoundingClientRect();
const x = Math.round(e.clientX - zona.left);
const y = Math.round(e.clientY - zona.top);
setUltimo(`contextmenu en (${x}, ${y}) con el botón ${e.button}`);
}
return (
<div className="tarjeta">
<h2>Tablero de eventos</h2>
<label>
Campo:{' '}
<input onKeyDown={alPulsarTecla} onBlur={alSalir} onPaste={alPegar} />
</label>
<button>Un botón para recibir el foco</button>
<div
onContextMenu={alPulsarRaton}
style={{ margin: '0.75rem 0', padding: '1rem', background: '#e8eaf6', borderRadius: 6 }}
>
Pulsa aquí con el botón derecho
</div>
<pre className="registro">{ultimo}</pre>
</div>
);
}

Figura 4. El tablero tras pulsar con el botón derecho en la zona azul.
Un manejador para varios campos
Si un formulario tiene muchos campos, no hace falta una función por campo. El atributo name dice cuál ha cambiado, y la clave calculada [name] actualiza solo esa propiedad:
import { useState } from 'react';
import type { ChangeEvent } from 'react';
export default function App() {
const [ficha, setFicha] = useState({ nombre: 'Ana', oficio: 'DIRECTOR GENERAL', deptId: '10' });
function alCambiar(e: ChangeEvent<HTMLInputElement | HTMLSelectElement>) {
const { name, value } = e.target;
setFicha({ ...ficha, [name]: value });
}
return (
<div className="tarjeta">
<h2>Ficha</h2>
<label>
Nombre <input name="nombre" value={ficha.nombre} onChange={alCambiar} />
</label>
<label>
Oficio <input name="oficio" value={ficha.oficio} onChange={alCambiar} />
</label>
<label>
Departamento{' '}
<select name="deptId" value={ficha.deptId} onChange={alCambiar}>
<option value="10">DIRECCIÓN</option>
<option value="20">VENTAS</option>
</select>
</label>
<pre className="registro">{JSON.stringify(ficha)}</pre>
</div>
);
}
Eventos de tus propios componentes
Cuando creas un componente, tú decides qué datos lleva cada aviso. Lo normal es no reenviar el evento de React, sino los datos que le interesan al padre:
interface SelectorDepartamentoProps {
valor: number;
onCambiar: (deptId: number) => void; // ✅ El padre recibe un número, no un evento
}
Así el padre no depende de que por dentro haya un <select> o una lista de botones. Y si tu componente envuelve un elemento nativo y quieres aceptar todas sus props, ComponentProps<'button'> las incluye con sus tipos, lo que verás en el módulo 13.
Saber qué botón envió el formulario 🆕 19.3
🆕 Desde React 19.3. En React 19.2 y anteriores el evento sintético de
submitno tienesubmitter: valeundefined. Si usas una versión anterior: leee.nativeEvent.submitter, que existe en todas las versiones, y con tipos antiguos convierte antes:(e.nativeEvent as globalThis.SubmitEvent).submitter.
Un formulario puede tener varios botones de envío. e.submitter dice cuál se pulsó, y es null si se envió de otra forma, por ejemplo llamando a requestSubmit() sin argumentos. Pulsar Enter en un campo cuenta como pulsar el primer botón de envío:
import { useState } from 'react';
import type { SubmitEvent } from 'react';
export default function App() {
const [accion, setAccion] = useState('ninguna');
function alEnviar(e: SubmitEvent<HTMLFormElement>) {
e.preventDefault();
const boton = e.submitter as HTMLButtonElement | null;
setAccion(boton?.value ?? 'desconocida');
}
return (
<form className="tarjeta" onSubmit={alEnviar}>
<h2>Pedido 5001</h2>
<input defaultValue="Nota interna" />
<button type="submit" name="accion" value="guardar">
Guardar
</button>
<button type="submit" name="accion" value="enviar">
Guardar y enviar
</button>
<p>Última acción: {accion}</p>
</form>
);
}
Eventos de pantalla completa 🆕 19.3
🆕 Desde React 19.3. No existen en React 19.2 ni anteriores. Si usas una versión anterior: escucha
fullscreenchangeyfullscreenerrorconaddEventListeneren un efecto (módulo 09), que hace lo mismo con unas líneas más.
onFullscreenChange y onFullscreenError avisan cuando un elemento entra o sale de pantalla completa, o cuando falla el intento. Hay un detalle que el anuncio no cuenta: @types/react 19.3.0 aún no los declara, así que React los admite, pero TypeScript da error si los escribes directamente en el JSX. Hasta que se actualicen los tipos, pásalos con un objeto y {...}:
import { useState } from 'react';
import type { MouseEvent } from 'react';
export default function App() {
const [completa, setCompleta] = useState(false);
function alternar(e: MouseEvent<HTMLButtonElement>) {
if (document.fullscreenElement) {
void document.exitFullscreen();
} else {
void e.currentTarget.parentElement?.requestFullscreen();
}
}
// @types/react 19.3.0 no declara estas props: el spread evita el error de tipos
const eventosPantalla = {
onFullscreenChange: () => setCompleta(document.fullscreenElement !== null),
onFullscreenError: () => console.log('El navegador lo ha rechazado'),
};
return (
<div className="tarjeta" {...eventosPantalla}>
<h2>Panel {completa ? 'en pantalla completa' : 'normal'}</h2>
<button onClick={alternar}>Alternar</button>
</div>
);
}
Eventos de fin de scroll y de popover 🆕 19.0
🆕 Desde React 19.0. En React 18.3 no existen
onScrollEndnionBeforeToggle, ni se reconoce la APIpopover. Si usas una versión anterior: escuchascrollend,beforetoggleytoggleconaddEventListeneren un efecto, y controla el atributopopoverdesde JavaScript.
onScrollEnd salta cuando acaba un desplazamiento, sin tener que vigilar cada evento onScroll. onBeforeToggle y onToggle avisan cuando se abre o se cierra un elemento con el atributo popover, y su evento indica oldState y newState.
<div popover="auto" id="ayuda" onToggle={(e) => console.log(e.newState)}>
Ayuda sobre este campo
</div>
<button popoverTarget="ayuda">Ayuda</button>
6. Del clic a la pantalla: trigger, render y commit
Ya sabes cómo llega el evento a tu función. Ahora veamos la segunda mitad: qué ocurre después de llamar a setSaldo. Una actualización pasa siempre por tres fases, y la palabra “renderizar” solo nombra una de ellas.
- Disparo (trigger): algo pide una actualización.
- Render: React llama a tus componentes para saber qué debe mostrarse.
- Confirmación (commit): React modifica el DOM para que coincida.
Después, y ya fuera de React, el navegador pinta.
Figura 5. Las tres fases de React y el pintado. Render calcula y no toca nada. Commit toca el DOM.
📘 Concepto: Renderizar es llamar a tu función, no pintar en la pantalla. Un render puede terminar sin ningún cambio en el DOM. Cuando alguien te dice que un componente “se renderiza demasiado”, habla de cuántas veces se llama a la función.
Fase 1: disparo
Solo hay dos motivos para que un componente se renderice:
- El render inicial: lo pide
createRoot(...).render(<App />), la línea desrc/main.tsx. - Un cambio de estado: tuyo o de un antecesor. Cuando un componente se renderiza, también se renderizan sus hijos.
Observa que “cambian las props” no está en la lista. Las props de un hijo solo cambian porque su padre se ha renderizado, y eso ya lo arrastra a él.
Fase 2: render
React llama al componente que tiene el cambio pendiente, mira qué devuelve y, si incluye otros componentes, los llama también, hasta llegar a etiquetas HTML. Con el resultado, compara el árbol nuevo con el anterior para averiguar qué ha cambiado. Esta comparación se llama reconciliación (reconciliation).
Este ejemplo enseña el orden real. Leer el DOM dentro del render solo se hace aquí para mostrar el momento en que cambia; en código normal sería una impureza. useEffect se ejecuta después del commit, como verás en el módulo 09.
import { useEffect, useState } from 'react';
function leerSaldo() {
return document.getElementById('saldo')?.textContent ?? '(no existe)';
}
function Cabecera() {
console.log(' render de Cabecera');
return <h2>Caja de la tienda</h2>;
}
function Saldo({ valor }: { valor: number }) {
console.log(' render de Saldo');
return <p id="saldo">Saldo: {valor} €</p>;
}
export default function App() {
const [saldo, setSaldo] = useState(0);
console.log(` render de App con saldo=${saldo}. DOM: ${leerSaldo()}`);
useEffect(() => {
console.log(` commit terminado. DOM: ${leerSaldo()}`);
});
function ingresar() {
console.log('clic: pido el cambio');
setSaldo(saldo + 10);
console.log(`fin del manejador, saldo sigue siendo ${saldo}`);
}
return (
<div className="tarjeta">
<Cabecera />
<Saldo valor={saldo} />
<button onClick={ingresar}>Ingresar 10 €</button>
</div>
);
}
Traza sin <StrictMode>, primero al cargar y después con un clic:
render de App con saldo=0. DOM: (no existe)
render de Cabecera
render de Saldo
commit terminado. DOM: Saldo: 0 €
clic: pido el cambio
fin del manejador, saldo sigue siendo 0
render de App con saldo=10. DOM: Saldo: 0 €
render de Cabecera
render de Saldo
commit terminado. DOM: Saldo: 10 €
Fíjate en cuatro cosas:
- El manejador termina por completo antes de que empiece el render.
setSaldono renderiza: deja el cambio apuntado. - Durante el render el DOM aún dice 0. React calcula primero y toca después.
- Primero se renderiza el padre y después los hijos, en orden.
Cabecerase renderiza aunque no reciba props ni haya cambiado nada. Es lo normal y no es un problema: ese render es barato. El módulo 14 explica cuándo y cómo evitarlo.
Fase 3: commit
En el render inicial, React crea todos los nodos con appendChild. En las actualizaciones aplica lo mínimo para que el DOM coincida con el último render. Si dos renders producen el mismo resultado, el DOM no se toca.
import { useState } from 'react';
export default function App() {
const [pulsaciones, setPulsaciones] = useState(0);
return (
<div className="tarjeta">
<h2>Comentario sobre el pedido</h2>
<input placeholder="Escribe sin que se pierda" />
<p>Pulsaciones: {pulsaciones}</p>
<button onClick={() => setPulsaciones(pulsaciones + 1)}>Pulsar</button>
</div>
);
}
Observa que el <input> no está controlado por React: el texto vive en el DOM. Si React rehiciera la página en cada actualización, lo perderías. Escribimos en el campo y vigilamos el DOM con un MutationObserver durante un clic:
attributes en INPUT: name
attributes en INPUT: name
characterData en #text: "0" -> "1"
El campo sigue conteniendo: Texto del usuario
El único cambio efectivo es el nodo de texto del número: ni el <div>, ni el <p>, ni el valor del campo. Las dos primeras líneas son un detalle interno de <input>: al actualizarlo, React pone y quita su atributo name, y al final queda como estaba. Esta precisión es lo que hace que el modelo declarativo del módulo 00 sea rápido: tú describes la pantalla entera, y React escribe en el DOM solo la diferencia.
Tras el commit el navegador recalcula y pinta. Si quieres ejecutar código justo en ese momento, necesitas un efecto. Hasta entonces, recuerda que todo tu manejador se ejecuta antes del pintado: si tarda dos segundos en un bucle, la pantalla se queda congelada esos dos segundos.
Cuándo React se ahorra el render
Si llamas a set con el valor que ya tiene, React compara con Object.is y normalmente no renderiza:
import { useState } from 'react';
const inicial = { nombre: 'Ana' };
export default function App() {
const [n, setN] = useState(0);
const [ficha, setFicha] = useState(inicial);
console.log(`render n=${n} nombre=${ficha.nombre}`);
function mutar() {
ficha.nombre = 'Ana María'; // ❌ Misma referencia: React no ve el cambio
setFicha(ficha);
}
return (
<div className="tarjeta">
<h2>
n = {n}, nombre = {ficha.nombre}
</h2>
<button onClick={() => setN(1)}>Poner n a 1</button>
<button onClick={mutar}>Mutar la ficha</button>
</div>
);
}
Traza sin <StrictMode>, pulsando cuatro veces Poner n a 1 y luego Mutar la ficha:
render n=0 nombre=Ana (al cargar)
render n=1 nombre=Ana (clic 1)
render n=1 nombre=Ana (clic 2)
(clic 3: sin render)
(clic 4: sin render)
(mutar: sin render)
Dos enseñanzas:
- El segundo clic puede producir un render más aunque el valor no cambie. React a veces necesita llamar otra vez al componente antes de poder descartar el trabajo, pero no continúa con los hijos ni toca el DOM. Es normal, y a partir de ahí no se repite.
- Mutar el objeto no provoca render, porque
Object.iscompara la referencia, que es la misma. La pantalla sigue diciendo “Ana” aunque el dato ya diga “Ana María”. Por eso el módulo 04 insiste en crear siempre objetos nuevos.
7. El estado es una instantánea
Mira este código con calma y decide qué muestra tras un clic:
import { useState } from 'react';
export default function App() {
const [puntos, setPuntos] = useState(0);
function sumarTres() {
setPuntos(puntos + 1);
setPuntos(puntos + 1);
setPuntos(puntos + 1);
console.log('dentro del manejador:', puntos);
}
return (
<div className="tarjeta">
<h2>Puntos: {puntos}</h2>
<button onClick={sumarTres}>Sumar tres</button>
</div>
);
}
dentro del manejador: 0
La pantalla muestra 1 y la consola dice 0. Si piensas en puntos como una variable que se modifica, no tiene sentido. Pero puntos no es una variable que cambie. Es una constante de ese render.
📘 Concepto: Cada render es como una fotografía: una llamada a tu función que recibe sus propios valores de estado, y cuyo JSX y manejadores se crean con esos valores. Cambiar el estado no retoca la foto actual. Pide a React que haga una foto nueva.
Con puntos valiendo 0, lo que realmente se ejecuta es esto:
function sumarTres() {
setPuntos(0 + 1);
setPuntos(0 + 1);
setPuntos(0 + 1);
console.log('dentro del manejador:', 0);
}
Tres veces “pon el estado a 1”. Tiene su lógica: puntos se declara con const, y un const no cambia dentro de una ejecución de la función. La siguiente ejecución, el siguiente render, tendrá otro const puntos distinto.
La instantánea sobrevive al tiempo
La foto no solo vale para el código síncrono. Un manejador recuerda los valores de su render aunque se ejecute segundos después, porque JavaScript mantiene la clausura. Repasa las clausuras en el Curso de JavaScript si te hace falta.
import { useState } from 'react';
export default function App() {
const [departamento, setDepartamento] = useState('VENTAS');
const [avisos, setAvisos] = useState<string[]>([]);
function asignar() {
setTimeout(() => {
setAvisos((a) => [...a, `Asignado a ${departamento}`]);
}, 3000);
}
return (
<div className="tarjeta">
<h2>Asignar empleado</h2>
<select value={departamento} onChange={(e) => setDepartamento(e.target.value)}>
<option>VENTAS</option>
<option>MARKETING</option>
<option>I+D</option>
</select>
<button onClick={asignar}>Asignar en 3 s</button>
<pre className="registro">{avisos.join('\n')}</pre>
</div>
);
}
Pulsa Asignar en 3 s con VENTAS seleccionado y cambia enseguida a MARKETING. Este es el resultado:
Seleccionado: MARKETING
Aviso: Asignado a VENTAS
¿Es esto un fallo? Al contrario. El usuario ordenó asignar a VENTAS, y eso es lo que se hace, cambie lo que cambie después. Las instantáneas hacen que un manejador sea predecible: trabaja con los valores del momento en que el usuario actuó.
Cuando de verdad necesites el valor más reciente, por ejemplo en un temporizador de larga duración, tienes dos caminos: la función actualizadora de la sección 8, o una ref, que estudiarás en el módulo 10.
Leer el valor justo después de cambiarlo
Un error directo de esta idea es intentar usar el valor nuevo en la línea siguiente:
function agregar() {
setCantidad(cantidad + 1);
enviarPedido(cantidad); // ❌ Envía el valor anterior
}
La solución es calcular primero y usar el resultado en los dos sitios:
function agregar() {
const nueva = cantidad + 1;
setCantidad(nueva);
enviarPedido(nueva); // ✅
}
8. La cola de actualizaciones y los lotes
La función actualizadora
Para que varias actualizaciones se encadenen, pasa a set una función en lugar de un valor. Es una instrucción distinta: no dice “pon 1”, sino “coge lo que haya y súmale uno”.
import { useState } from 'react';
export default function App() {
const [puntos, setPuntos] = useState(0);
function sumarTres() {
setPuntos((p) => p + 1);
setPuntos((p) => p + 1);
setPuntos((p) => p + 1);
}
return (
<div className="tarjeta">
<h2>Puntos: {puntos}</h2>
<button onClick={sumarTres}>Sumar tres</button>
</div>
);
}
Ahora sí se suman tres. React guarda las actualizaciones en una cola y, durante el siguiente render, las procesa en orden, pasando a cada función el resultado de la anterior.
Mezclar valores y funciones
Un valor y una función conviven en la cola con una regla simple: un valor sustituye lo que hubiera, y una función transforma lo que hubiera.
import { useState } from 'react';
export default function App() {
const [n, setN] = useState(0);
function ejecutar() {
setN(n + 5);
setN((x) => x + 1);
setN(42);
setN((x) => x * 2);
}
return (
<div className="tarjeta">
<h2>n = {n}</h2>
<button onClick={ejecutar}>Ejecutar la cola</button>
</div>
);
}
Desde n = 0, un clic da n = 84. Un segundo clic da otra vez n = 84, porque el 42 descarta lo que hubiera antes:
| Línea | Instrucción en la cola | Parte de | Resultado |
|---|---|---|---|
setN(n + 5) | Sustituir por 5 | 0 | 5 |
setN((x) => x + 1) | Sumar 1 | 5 | 6 |
setN(42) | Sustituir por 42 | 6 | 42 |
setN((x) => x * 2) | Multiplicar por 2 | 42 | 84 |
Fíjate en que n vale 0 en las cuatro líneas: solo x avanza.
Figura 6. Durante el render, React recorre la cola. Lo que entra en la cola se decide en el manejador, pero las funciones se ejecutan después.
⚠️ Importante: Las funciones actualizadoras se ejecutan durante el render, así que deben ser puras: solo calculan y devuelven. No hagas peticiones ni cambies otra cosa dentro. Strict Mode las llama dos veces para ayudarte a detectarlo (sección 12). El nombre del parámetro suele ser la inicial de la variable (
p => p + 1) oanterior.
¿Valor o función?
| Situación | Usa |
|---|---|
El valor nuevo no depende del anterior: setNombre('Ana') | Valor |
| Depende del anterior, en un manejador sencillo | Cualquiera; el valor suele ser más legible |
| Hay varias actualizaciones seguidas del mismo estado | Función |
Se ejecuta más tarde, en un temporizador o tras un await | Función, porque la instantánea puede estar anticuada |
| Varias fuentes añaden a la misma lista | Función |
Actualización por lotes
Ya viste en el módulo 04 que React agrupa los cambios. Este agrupamiento se llama actualización por lotes (batching): React espera a que termine el código que se está ejecutando y hace un solo render con todo lo pendiente. Como un camarero que toma la comanda completa antes de ir a la cocina.
import { useState } from 'react';
export default function App() {
const [a, setA] = useState(0);
const [b, setB] = useState(0);
console.log(`render a=${a} b=${b}`);
function enManejador() {
setA(a + 1);
setB(b + 1);
}
function enTemporizador() {
setTimeout(() => {
setA((x) => x + 1);
setB((x) => x + 1);
}, 0);
}
async function juntosTrasAwait() {
await Promise.resolve();
setA((x) => x + 1);
setB((x) => x + 1);
}
async function separadosPorAwait() {
setA((x) => x + 1);
await new Promise((r) => setTimeout(r, 0));
setB((x) => x + 1);
}
return (
<div className="tarjeta">
<h2>
a = {a}, b = {b}
</h2>
<button onClick={enManejador}>En el manejador</button>
<button onClick={enTemporizador}>En un temporizador</button>
<button onClick={juntosTrasAwait}>Juntos tras await</button>
<button onClick={separadosPorAwait}>Separados por await</button>
</div>
);
}
Pulsando los cuatro botones en orden, sin <StrictMode>:
render a=0 b=0 (al cargar)
render a=1 b=1 (en el manejador)
render a=2 b=2 (en un temporizador)
render a=3 b=3 (juntos tras await)
render a=4 b=3 (separados por await)
render a=4 b=4
Cada uno de los tres primeros botones cambia dos estados y produce un solo render. El cuarto produce dos, porque el await detiene la función y React renderiza lo que tenía antes de seguir.
Además, cada evento discreto del usuario (un clic, una tecla) se renderiza por separado antes del siguiente. Un doble clic son dos renders: React no los mezcla, y eso es importante para el ejemplo de la próxima sección.
📘 Concepto: Hasta React 17, los cambios solo se agrupaban dentro de los manejadores de React, y en un
setTimeoutcadasetproducía un render. Desde React 18 el agrupamiento es automático en todas partes. Si alguna vez necesitas forzar que un cambio llegue al DOM en el acto, existeflushSync, que verás en el módulo 10.
9. Manejadores asíncronos con la API
Un evento es el mejor lugar para una petición que lanza el usuario: guardar, borrar o pulsar “Cargar”. Aquí se juntan todas las ideas anteriores. Este ejemplo pide empleados a la API de ejemplo:
import { useState } from 'react';
const API = 'http://localhost:3000/api';
interface Empleado {
empId: number;
nombre: string;
apellidos: string;
departamento: string;
}
export default function App() {
const [empleados, setEmpleados] = useState<Empleado[]>([]);
const [cargando, setCargando] = useState(false);
const [error, setError] = useState<string | null>(null);
async function cargar() {
if (cargando) return; // Evita la segunda petición de un doble clic
setCargando(true);
setError(null);
try {
const respuesta = await fetch(`${API}/empleados?tamano=4`);
if (!respuesta.ok) {
throw new Error(`Error ${respuesta.status}`);
}
const datos: { contenido: Empleado[] } = await respuesta.json();
setEmpleados(datos.contenido);
} catch (e) {
setError(e instanceof Error ? e.message : 'Error desconocido');
} finally {
setCargando(false);
}
}
return (
<div className="tarjeta">
<h2>Empleados</h2>
<button onClick={cargar} disabled={cargando}>
{cargando ? 'Cargando...' : 'Cargar'}
</button>
{error && <p className="error">{error}</p>}
<ul>
{empleados.map((e) => (
<li key={e.empId}>
{e.nombre} {e.apellidos} ({e.departamento})
</li>
))}
</ul>
</div>
);
}
Sigamos un clic paso a paso:
setCargando(true)ysetError(null)entran en la cola. La función llega al primerawaity se detiene.- React aprovecha para renderizar y hacer el commit: el botón dice “Cargando…” y queda desactivado. Por esto el usuario lo ve: el
awaitha devuelto el control. - Llega la respuesta y la función continúa. Su
cargandosigue siendofalse, porque es la instantánea del render en que se creó. Da igual: aquí ya no se lee. setEmpleadosysetCargando(false)se agrupan en un solo render.
¿Por qué funciona if (cargando) return si la instantánea no cambia? Porque el segundo clic llama a otro manejador, creado por el render posterior al primer clic, y ese ve cargando = true. Lo hemos comprobado con un doble clic real y una API con 300 ms de retardo: una sola petición.
💡 Consejo: Este patrón es correcto cuando la carga la pide el usuario. Si los datos deben aparecer al abrir la pantalla, no hay ningún evento que los pida: necesitas un efecto (módulo 09) o, mejor todavía, TanStack Query (módulo 19).
10. Identidad: dónde vive el estado
Hasta ahora parecía que el estado estaba dentro de la función del componente. No es así: la función se ejecuta y desaparece. El estado lo guarda React, y lo asocia a una posición en el árbol.
📘 Concepto: React conserva el estado de un componente mientras se renderice el mismo tipo de componente en la misma posición del árbol. Si el tipo cambia, o si el componente deja de renderizarse, destruye su estado, y el de todo lo que tenga debajo.
Este componente servirá de banco de pruebas en toda la sección. Tiene un estado interno que veremos conservarse o perderse:
import { useState } from 'react';
function Contador({ etiqueta }: { etiqueta: string }) {
const [n, setN] = useState(0);
return (
<p>
{etiqueta}: {n} <button onClick={() => setN(n + 1)}>+1</button>
</p>
);
}
export default function App() {
const [visible, setVisible] = useState(true);
const [esJefe, setEsJefe] = useState(false);
return (
<div className="tarjeta">
<h2>Identidad</h2>
{/* A: se destruye al ocultarlo */}
{visible && <Contador etiqueta="Ocultable" />}
{/* B: mismo tipo en la misma posición, solo cambia una prop */}
{esJefe ? <Contador etiqueta="Jefe" /> : <Contador etiqueta="Empleado" />}
<button onClick={() => setVisible(!visible)}>Mostrar u ocultar</button>
<button onClick={() => setEsJefe(!esJefe)}>Cambiar de rol</button>
</div>
);
}
Sube los dos contadores a 3 y prueba:
- Con Cambiar de rol, el segundo pasa de “Empleado: 3” a “Jefe: 3”. Son dos líneas de JSX distintas, pero en el árbol resultante hay un
Contadoren la misma posición, así que para React es el mismo y solo cambia su prop. - Con Mostrar u ocultar, el primero desaparece, y al volver empieza en 0. Su estado se destruyó al salir del árbol.
Ten en cuenta que el segundo contador no se ha movido al ocultar el primero, aunque esté “más arriba” en la pantalla. En el JSX, {visible && ...} ocupa un hueco fijo. Cuando es false, ese hueco contiene false, y el siguiente hermano sigue siendo el hijo número 2.
Figura 7. El estado va ligado al hueco del árbol. Cambiar una prop lo conserva; sacar el componente del árbol lo destruye.
Las reglas, una por una
Hemos comprobado cada fila con un contador a 3 antes del cambio:
| Cambio | ¿Se conserva el estado? | Por qué |
|---|---|---|
| Cambia una prop | ✅ Sí | Mismo tipo y misma posición |
cond ? <A x={1} /> : <A x={2} /> | ✅ Sí | Las dos ramas dan A en el mismo hueco |
cond ? <A /> : <B /> | ❌ No | Cambia el tipo |
{cond && <A />} pasa a false y vuelve | ❌ No | Sale del árbol |
El hermano {cond && <X />} aparece antes que <A /> | ✅ Sí | A mantiene su hueco |
cond ? <div><A /></div> : <section><A /></section> | ❌ No | Cambia un antecesor, y se destruye todo lo que tiene debajo |
A envuelta en otro componente con la misma salida | ❌ No | La identidad se decide por el tipo, no por el HTML que genera |
Ocultarlo con CSS (hidden, display: none) | ✅ Sí | Sigue en el árbol |
La fila más traicionera es la que sigue, porque parece idéntica a la del hermano con &&. Mira la diferencia:
import { useState } from 'react';
function Nota() {
const [texto, setTexto] = useState('');
return <input value={texto} onChange={(e) => setTexto(e.target.value)} placeholder="Nota" />;
}
function ConHueco({ aviso }: { aviso: boolean }) {
return (
<div>
{aviso && <p>Tienes cambios sin guardar</p>}
<Nota />
</div>
);
}
function ConRamas({ aviso }: { aviso: boolean }) {
if (aviso) {
return (
<div>
<p>Tienes cambios sin guardar</p>
<Nota />
</div>
);
}
return (
<div>
<Nota />
</div>
);
}
export default function App() {
const [aviso, setAviso] = useState(false);
return (
<div className="tarjeta">
<h2>Mismo aspecto, distinto árbol</h2>
<ConHueco aviso={aviso} />
<ConRamas aviso={aviso} />
<button onClick={() => setAviso(!aviso)}>Alternar aviso</button>
</div>
);
}
Escribe en los dos campos y pulsa Alternar aviso. El primero conserva el texto y el segundo lo pierde. En ConHueco, Nota siempre es el segundo hijo. En ConRamas, pasa de ser el primer hijo a ser el segundo, y en la posición del primero el tipo cambia de Nota a p.
El error del componente definido dentro de otro
Lo anterior explica uno de los fallos más desconcertantes de React: un campo de texto que pierde el foco tras cada letra.
import { useState } from 'react';
export default function App() {
const [filtro, setFiltro] = useState('');
// ❌ MAL: se crea una función nueva en cada render de App
function Buscador() {
return <input value={filtro} onChange={(e) => setFiltro(e.target.value)} />;
}
return (
<div className="tarjeta">
<h2>Filtro: {filtro}</h2>
<Buscador />
</div>
);
}
Tecleamos hola carácter a carácter, y este es el resultado:
Filtro: h | valor del campo: h | foco en: BODY
Solo llega la primera letra. Cada render de App crea una función Buscador nueva. Se llama igual, pero es otro valor, y React compara los tipos por identidad, como compara === dos funciones. Tipo distinto significa destruir el <input> y crear otro, que nace sin foco.
La solución es definir siempre los componentes en el nivel superior del módulo, y pasarles por props lo que necesiten:
import { useState } from 'react';
interface BuscadorProps {
filtro: string;
onCambiar: (valor: string) => void;
}
// ✅ BIEN: siempre es la misma función
function Buscador({ filtro, onCambiar }: BuscadorProps) {
return <input value={filtro} onChange={(e) => onCambiar(e.target.value)} />;
}
export default function App() {
const [filtro, setFiltro] = useState('');
return (
<div className="tarjeta">
<h2>Filtro: {filtro}</h2>
<Buscador filtro={filtro} onCambiar={setFiltro} />
</div>
);
}
💡 Consejo: El linter te lo señala: oxlint, el de la plantilla, y
eslint-plugin-react-hooks7 tienen la reglastatic-components, que detecta exactamente este error.
Conservar el estado de lo que no se ve
Si quieres ocultar algo sin perder lo que el usuario estaba haciendo, tienes tres opciones:
- Ocultarlo con CSS, dejándolo en el árbol. Es lo más simple en una pantalla pequeña.
- Elevar el estado al padre o a un almacén (módulos 04 y 20). El componente se puede destruir, porque el dato vive fuera.
- Usar
<Activity>(sección siguiente), que además desactiva lo oculto.
{/* Opción CSS: el componente sigue montado, con su estado */}
<div hidden={!visible}>
<Contador etiqueta="Oculto, pero vivo" />
</div>
Ocultar con <Activity> 🆕 19.2
🆕 Desde React 19.2. No existe en React 19.1 ni anteriores. Si usas una versión anterior: oculta el componente con CSS (
hidden) para conservar su estado, sabiendo que sus efectos seguirán activos, o eleva el estado y deja que el componente se destruya.
<Activity mode="hidden"> conserva el estado de lo que envuelve, pero lo oculta y detiene sus efectos. Lo estudiarás a fondo en el módulo 17.
11. key para reiniciar un componente
A veces quieres justo lo contrario: dos renders con el mismo tipo en la misma posición que React trate como componentes distintos. Este es un fallo real de muchas aplicaciones. Elegimos un empleado, modificamos su salario y cambiamos a otro:
import { useState } from 'react';
const plantilla = [
{ empId: 100, nombre: 'Ana García', salario: 6500 },
{ empId: 101, nombre: 'Luis Martínez', salario: 4800 },
{ empId: 102, nombre: 'Marta Sánchez', salario: 4500 },
];
interface Empleado {
empId: number;
nombre: string;
salario: number;
}
function FichaSalario({ empleado }: { empleado: Empleado }) {
const [salario, setSalario] = useState(empleado.salario);
return (
<div className="tarjeta">
<h2>{empleado.nombre}</h2>
<label>
Salario:{' '}
<input
type="number"
value={salario}
onChange={(e) => setSalario(Number(e.target.value))}
/>
</label>
</div>
);
}
export default function App() {
const [id, setId] = useState(100);
const empleado = plantilla.find((e) => e.empId === id) ?? plantilla[0];
return (
<>
<div className="tarjeta">
{plantilla.map((e) => (
<button key={e.empId} onClick={() => setId(e.empId)}>
{e.nombre}
</button>
))}
</div>
<FichaSalario empleado={empleado} />
</>
);
}
Ponemos 7000 en la ficha de Ana y pulsamos en Luis:

Figura 8. Sin key: la ficha dice Luis, pero el campo conserva los 7000 de Ana.
Luis cobra 4800, pero el campo dice 7000, y si esto se enviara a la API le subiríamos el sueldo por error. Es la regla de la sección anterior: FichaSalario sigue en la misma posición, así que React conserva su estado. El argumento de useState solo se usa la primera vez y aquí se ignora.
La solución tiene una sola palabra. Cambia la última línea del JSX:
<FichaSalario key={empleado.empId} empleado={empleado} />

Figura 9. Con key: la ficha de Luis empieza de cero con su salario.
📘 Concepto:
keyforma parte de la identidad de un componente. React considera que dos elementos son el mismo solo si coinciden su posición, su tipo y sukey. Si lakeycambia, destruye el componente antiguo, con todo su estado y su DOM, y crea uno nuevo.
Figura 10. Cambiar la key significa decirle a React: “esto es otra cosa”.
Esto no tiene nada que ver con las listas, aunque sea allí donde la verás más (módulo 06). En una lista, key identifica a cada hermano. Aquí se usa fuera de una lista con el mismo mecanismo.
Por qué no reiniciar con un efecto
Es tentador sincronizar el estado cuando cambia la prop:
// ❌ Evítalo: un render con datos viejos y lógica de más
useEffect(() => {
setSalario(empleado.salario);
}, [empleado]);
Funciona a medias. React muestra primero un render con el valor antiguo y luego lo corrige. Además, si el componente tiene varios estados o hijos con estado, hay que acordarse de reiniciarlos todos. Con key se reinicia todo de golpe, sin ningún render intermedio. El módulo 09 lo trata como uno de los efectos que sobran.
Un botón de “Descartar cambios”
La key no tiene por qué ser un identificador de datos. Un número que incrementas basta para obtener un reinicio a voluntad:
import { useState } from 'react';
function Formulario() {
const [nombre, setNombre] = useState('');
const [oficio, setOficio] = useState('');
return (
<>
<label>
Nombre <input value={nombre} onChange={(e) => setNombre(e.target.value)} />
</label>
<label>
Oficio <input value={oficio} onChange={(e) => setOficio(e.target.value)} />
</label>
</>
);
}
export default function App() {
const [version, setVersion] = useState(0);
return (
<div className="tarjeta">
<h2>Nuevo empleado</h2>
<Formulario key={version} />
<button onClick={() => setVersion(version + 1)}>Descartar cambios</button>
</div>
);
}
Formulario no sabe nada: no tiene una función de reinicio ni hay que limpiar cada campo a mano.
Conservar borradores
Y si queremos justo lo contrario, que cada empleado recuerde su borrador al volver, reiniciar no basta, porque el estado destruido se pierde. Hay que elevarlo: guardar un borrador por empleado en el padre. La key ya no hace falta, porque el componente no tiene estado propio:
import { useState } from 'react';
const plantilla = [
{ empId: 100, nombre: 'Ana García', salario: 6500 },
{ empId: 101, nombre: 'Luis Martínez', salario: 4800 },
];
export default function App() {
const [id, setId] = useState(100);
const [borradores, setBorradores] = useState<Record<number, number>>({});
const empleado = plantilla.find((e) => e.empId === id) ?? plantilla[0];
const salario = borradores[id] ?? empleado.salario;
return (
<div className="tarjeta">
{plantilla.map((e) => (
<button key={e.empId} onClick={() => setId(e.empId)}>
{e.nombre}
</button>
))}
<h2>{empleado.nombre}</h2>
<input
type="number"
value={salario}
onChange={(e) => setBorradores({ ...borradores, [id]: Number(e.target.value) })}
/>
</div>
);
}
Observa que salario es estado derivado, como en el módulo 04: si hay borrador se usa, y si no, el salario real.
Otras cosas que debes saber de key
keyno es una prop. React se la queda y tu componente no la recibe. Si la necesitas también dentro, pásala con otro nombre:<Ficha key={id} empId={id} />. Si intentas leerprops.key, obtienesundefinedy un aviso en la consola.- Solo tiene que ser única entre hermanos, no en toda la aplicación.
- No uses valores aleatorios.
key={Math.random()}cambia en cada render, y el componente se destruirá siempre, perdiendo foco y estado. Es el error de la sección 10 con otra forma. - Reiniciar tiene un coste: se recrea todo el DOM de ese subárbol. No importa en una ficha; pero en una tabla enorme, no lo hagas en cada pulsación.
- Destruye también a los hijos: los campos no controlados y los hijos con estado se reinician, y se limpian sus efectos.
12. Strict Mode
<StrictMode> es un componente que no muestra nada. Activa comprobaciones adicionales para sus descendientes, solo en desarrollo. Ya lo tienes en src/main.tsx, porque lo incluye la plantilla de Vite:
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
Se puede poner en una parte del árbol, pero lo normal es dejarlo en la raíz. No afecta en absoluto a la compilación de producción.
📘 Concepto: Strict Mode provoca a propósito las situaciones que React puede producir en cualquier momento, para que un error escondido salga a la luz en tu máquina y no en la del usuario. No detecta errores por sí mismo: los hace visibles repitiendo el trabajo.
| Qué repite | Dónde se ejecuta | Qué delata |
|---|---|---|
| Renderiza dos veces cada componente | Función del componente, inicializadores de useState, funciones actualizadoras, reductores | Render impuro |
| Ejecuta dos veces los efectos al montar | useEffect, con limpieza entre medias | Limpieza que falta (módulo 09) |
| Llama dos veces a los ref callbacks al montar | Refs con función, con limpieza entre medias | Limpieza de refs que falta (módulo 10) |
Por qué se renderiza dos veces
Recuerda la regla: un componente debe ser puro. Con los mismos datos, el mismo resultado. Si lo es, llamarlo dos veces no cambia nada, y la segunda llamada es inocua. Si tienes un error, dos llamadas dan algo distinto. Y la razón por la que esto importa fuera del desarrollo es que el render concurrente (módulo 15) puede empezar un render, descartarlo y repetirlo.
Veamos un caso real. Asignamos un número de ticket a cada fila con un contador externo:
let siguienteTicket = 1;
function Ticket({ cliente }: { cliente: string }) {
// ❌ MAL: cambia una variable externa durante el render
const numero = siguienteTicket;
siguienteTicket = siguienteTicket + 1;
return (
<p>
Ticket #{numero}: {cliente}
</p>
);
}
export default function App() {
return (
<div className="tarjeta">
<h2>Tickets del día</h2>
<Ticket cliente="Distribuciones Norte" />
<Ticket cliente="Papelería Central" />
<Ticket cliente="Hotel Mediterráneo" />
</div>
);
}
Mismo código, dos resultados:

Figura 11. En desarrollo con Strict Mode: los números saltan de dos en dos. Es la pista del error.

Figura 12. En producción parece correcto, hasta que el componente vuelva a renderizarse y los números cambien.
En producción sale 1, 2, 3 y parece que todo funciona, pero el fallo sigue ahí. Pulsar cualquier botón que renderice de nuevo la lista daría 4, 5, 6. El linter también lo señala:
src/App.tsx:6:3: warning react(globals): Cannot reassign variables declared outside of the component/hook
La solución es que el número salga de los datos, no de un contador que cambia al renderizar. Si los tickets existen, ya tienen número. Y si se crean, se numeran en el manejador que los crea, no al pintarlos.
Las actualizadoras también se repiten
Otro error que Strict Mode pone en evidencia es mutar el estado dentro de una función actualizadora:
import { useState } from 'react';
export default function App() {
const [lineas, setLineas] = useState<string[]>([]);
function agregarMal() {
setLineas((l) => {
l.push('Línea'); // ❌ MAL: muta el array anterior
return [...l];
});
}
function agregarBien() {
setLineas((l) => [...l, 'Línea']); // ✅
}
return (
<div className="tarjeta">
<h2>Líneas del pedido: {lineas.length}</h2>
<button onClick={agregarMal}>Añadir mal</button>
<button onClick={agregarBien}>Añadir bien</button>
</div>
);
}
Pulsaciones de "Añadir mal": 1 2 3 4 5
Líneas en desarrollo: 1 3 5 7 9
A partir de la segunda pulsación, cada una suma dos líneas, porque React llama dos veces a la función y las dos llamadas empujan al mismo array. La primera suma una porque, si el componente no tiene trabajo pendiente, React calcula la actualización por adelantado, una sola vez: es el ahorro que viste en la sección 6. En producción, dos pulsaciones dan dos líneas y el error queda oculto. Hazla pura y todo cuadra.
Lo que verás en la consola
Si haces console.log en el cuerpo de un componente en desarrollo, cada render imprime dos líneas. Lo hemos medido:
En desarrollo con <StrictMode>, al cargar:
render n=0 nombre=Ana
render n=0 nombre=Ana
En producción con <StrictMode>, al cargar:
render n=0 nombre=Ana
Con React DevTools instalado, la segunda línea aparece atenuada, para que sepas que procede de la repetición. Por eso, si cuentas renders, hazlo sin <StrictMode> o en una compilación de producción.
⚠️ Importante: No quites
<StrictMode>para que “dejen de salir dos veces” los mensajes. Si un error solo aparece con él, el error está en tu código, y sin él seguiría ahí, solo que escondido. Si un efecto se comporta mal al repetirse, la solución es escribir bien la limpieza.
Doble llamada a los ref callbacks 🔄 19.0
🔄 Cambia en React 19.0. En React 18.3, Strict Mode llama una sola vez a un ref callback al montar; desde 19.0 lo llama, lo limpia y lo vuelve a llamar. Si usas una versión anterior: no verás esa repetición, así que comprueba a mano que la limpieza de tus refs funciona.
Medido en las tres versiones con un componente que tiene un ref callback dentro de <StrictMode>:
| Versión | Llamadas con el nodo | Llamadas con null |
|---|---|---|
| 18.3.1 | 1 | 0 |
| 19.0.9 | 2 | 1 |
| 19.3.1 | 2 | 1 |
Doble efecto al hidratar 🔄 19.3
🔄 Cambia en React 19.3. En React 19.0, 19.1 y 19.2, Strict Mode no repite los efectos al hidratar con
hydrateRoot. Si usas una versión anterior: comprueba la limpieza también en un árbol creado concreateRoot, donde la repetición sí ocurre.
Solo te afecta si usas renderizado en servidor (módulo 23). El módulo 09 muestra las mediciones por versión.
13. Caso práctico: panel de empleados
Reunimos el módulo en una pantalla de la aplicación EMPRESA. Cada decisión del código procede de una sección:
- Buscar con un formulario y
preventDefault, de modo que Enter funcione (sección 4). - Cargar en el manejador, con su
cargando(sección 9). - Filas que se seleccionan y botones que no deben seleccionar:
stopPropagation(sección 3). - Ficha con
key, para no arrastrar el borrador de otro empleado (sección 11). - Escape para cerrar la ficha, gracias a que
onKeyDownen el formulario recibe las teclas de sus campos por propagación (secciones 3 y 5). - El empleado seleccionado se deriva del identificador, así no hay dos copias del dato (módulo 04).
Los cambios se quedan en el cliente, porque escribir en la API exige sesión. Eso llega en el módulo 16.
import { useState } from 'react';
import type { KeyboardEvent, MouseEvent, SubmitEvent } from 'react';
const API = 'http://localhost:3000/api';
interface Empleado {
empId: number;
nombre: string;
apellidos: string;
oficio: string;
salario: number;
departamento: string;
}
interface FichaSalarioProps {
empleado: Empleado;
onGuardar: (empId: number, salario: number) => void;
onCerrar: () => void;
}
function FichaSalario({ empleado, onGuardar, onCerrar }: FichaSalarioProps) {
const [salario, setSalario] = useState(String(empleado.salario));
const importe = Number(salario);
const valido = salario !== '' && importe > 0;
function alEnviar(e: SubmitEvent<HTMLFormElement>) {
e.preventDefault();
if (valido) {
onGuardar(empleado.empId, importe);
}
}
function alPulsarTecla(e: KeyboardEvent<HTMLFormElement>) {
if (e.key === 'Escape') {
onCerrar();
}
}
return (
<form className="tarjeta" onSubmit={alEnviar} onKeyDown={alPulsarTecla}>
<h2>
{empleado.nombre} {empleado.apellidos}
</h2>
<p>
{empleado.oficio}, {empleado.departamento}
</p>
<label>
Salario:{' '}
<input
type="number"
aria-label="Salario"
value={salario}
onChange={(e) => setSalario(e.target.value)}
/>
</label>
{!valido && <p className="error">El salario debe ser mayor que 0</p>}
<button type="submit" disabled={!valido}>
Guardar
</button>
<button type="button" onClick={onCerrar}>
Cerrar
</button>
</form>
);
}
export default function App() {
const [texto, setTexto] = useState('');
const [empleados, setEmpleados] = useState<Empleado[]>([]);
const [seleccionadoId, setSeleccionadoId] = useState<number | null>(null);
const [cargando, setCargando] = useState(false);
const [error, setError] = useState<string | null>(null);
const seleccionado = empleados.find((e) => e.empId === seleccionadoId);
async function buscar(e: SubmitEvent<HTMLFormElement>) {
e.preventDefault();
if (cargando) return;
setCargando(true);
setError(null);
setSeleccionadoId(null);
try {
const consulta = encodeURIComponent(texto.trim());
const respuesta = await fetch(`${API}/empleados?q=${consulta}&tamano=5`);
if (!respuesta.ok) {
throw new Error(`Error ${respuesta.status}`);
}
const datos: { contenido: Empleado[] } = await respuesta.json();
setEmpleados(datos.contenido);
} catch (e) {
setError(e instanceof Error ? e.message : 'Error desconocido');
} finally {
setCargando(false);
}
}
function quitar(e: MouseEvent<HTMLButtonElement>, empId: number) {
e.stopPropagation();
setEmpleados(empleados.filter((emp) => emp.empId !== empId));
if (empId === seleccionadoId) {
setSeleccionadoId(null);
}
}
function guardar(empId: number, salario: number) {
setEmpleados(empleados.map((emp) => (emp.empId === empId ? { ...emp, salario } : emp)));
setSeleccionadoId(null);
}
return (
<>
<form className="tarjeta" onSubmit={buscar}>
<h2>Empleados</h2>
<input
value={texto}
onChange={(e) => setTexto(e.target.value)}
placeholder="Nombre u oficio"
/>
<button type="submit" disabled={cargando}>
{cargando ? 'Buscando...' : 'Buscar'}
</button>
{error && <p className="error">{error}</p>}
</form>
{empleados.length > 0 && (
<div className="tarjeta">
{empleados.map((emp) => (
<div
key={emp.empId}
className={emp.empId === seleccionadoId ? 'fila activa' : 'fila'}
onClick={() => setSeleccionadoId(emp.empId)}
>
<span>
{emp.nombre} {emp.apellidos}: {emp.salario.toLocaleString('es-ES')} €
</span>
<button onClick={(e) => quitar(e, emp.empId)}>Quitar</button>
</div>
))}
</div>
)}
{seleccionado && (
<FichaSalario
key={seleccionado.empId}
empleado={seleccionado}
onGuardar={guardar}
onCerrar={() => setSeleccionadoId(null)}
/>
)}
</>
);
}

Figura 13. El panel con un empleado seleccionado. La fila activa se resalta y la ficha muestra su salario.
Lo hemos ejecutado en Chromium con Strict Mode activo, contra la API real, y este es el resultado de cada comprobación:
Enter en el buscador: 5 filas
Seleccionar a Ana: ficha con salario 6500
Escribir 7000 y pasar a Luis: ficha con salario 4800
Salario 0: Guardar desactivado y "El salario debe ser mayor que 0"
Guardar 5000: "Luis Martínez Ruiz: 5000 €" y la ficha se cierra
Escape en la ficha de Marta: se cierra
Quitar a Marta: 4 filas y ninguna ficha abierta
Buscar garcia: 1 fila
Observa un detalle de accesibilidad: una fila con onClick no se alcanza con el teclado. En una aplicación real, el nombre de la fila sería un <button> o un enlace. Lo verás en el módulo 24. Las listas y su key se tratan en el módulo 06.
14. Errores frecuentes y cómo depurarlos
| Síntoma | Causa probable | Solución |
|---|---|---|
| La función se ejecuta al cargar, sin pulsar | onClick={guardar()} | Pasa la función: onClick={guardar} o () => guardar() |
Too many re-renders | set llamado durante el render | Muévelo a un manejador |
| Al pulsar el botón, también actúa la fila | La propagación | stopPropagation, o compara e.target |
| El formulario recarga la página | Falta preventDefault | Ponlo en onSubmit, no en el botón |
| Un botón dentro de un formulario lo envía | Un <button> es type="submit" por defecto | Añade type="button" |
e.currentTarget es null tras un await | React lo limpia al acabar de despachar | Guárdalo en una variable antes |
El error de tipos habla de MouseEvent<...> y EventHandler | Falta import type { MouseEvent } from 'react' | Importa el tipo de React |
console.log muestra el valor antiguo tras el set | La instantánea | Calcula el valor en una variable, o míralo en el siguiente render |
Tres setN(n + 1) suman uno | La instantánea | setN((x) => x + 1) |
| La pantalla no se actualiza tras cambiar un objeto | Mutación, misma referencia | Crea un objeto nuevo |
| El estado no se reinicia al cambiar la prop | Mismo tipo y posición | key |
| Un campo pierde el foco tras cada letra | Componente definido dentro de otro, o key aleatoria | Defínelo en el nivel superior |
| Al ocultar y mostrar, se pierde lo escrito | Salió del árbol | Ocúltalo con CSS o eleva el estado |
| Todo se ejecuta dos veces | Strict Mode en desarrollo | Es intencionado; comprueba que sea inocuo |
| Funciona en desarrollo y falla en producción, o al revés | Render impuro | Haz caso a lo que muestre Strict Mode |
onWheel no cancela el scroll | Oyente pasivo | addEventListener con { passive: false } |
Cómo investigar
- ¿Se ejecuta mi manejador? Un
console.logen la primera línea. Si no sale, mira si un hijo tienestopPropagation, o si el elemento estádisabled. - ¿Qué elemento recibe el evento? Registra
e.targetye.currentTarget. - ¿Se renderiza el componente? Un
console.logen el cuerpo, recordando que con Strict Mode sale doble. React DevTools, en sus opciones, puede resaltar los componentes que se renderizan (Highlight updates). - ¿Sigue vivo el estado? En la pestaña Components de React DevTools, mira si el componente conserva sus hooks o si aparece como nuevo. También puedes añadir un
keytemporal para confirmar que el problema es de identidad.
💡 Consejo: Para entender en qué orden pasan las cosas, añade un
console.logantes y después deset, otro en el cuerpo del componente y otro en unuseEffectsin dependencias. Verás las tres fases igual que en la traza de la sección 6.
15. Ejercicios
Ejercicio 1: Sumar de cinco en cinco
Este contador debería sumar 5 con cada pulsación del botón, pero al abrir la página da un error. Arregla los dos fallos que tiene, y haz que siga funcionando aunque se añadan más sumar():
export default function App() {
const [n, setN] = useState(0);
function sumar() {
setN(n + 1);
setN(n + 1);
setN(n + 1);
setN(n + 1);
setN(n + 1);
}
return <button onClick={sumar()}>Total: {n}</button>;
}
Ver solución
Hay dos errores. onClick={sumar()} ejecuta la función durante el render, lo que provoca Too many re-renders. Y cinco setN(n + 1) con la misma instantánea solo suman uno.
import { useState } from 'react';
export default function App() {
const [n, setN] = useState(0);
function sumar() {
setN((x) => x + 1);
setN((x) => x + 1);
setN((x) => x + 1);
setN((x) => x + 1);
setN((x) => x + 1);
}
return <button onClick={sumar}>Total: {n}</button>;
}
Tras dos pulsaciones marca Total: 10. Si solo quieres sumar 5, también vale setN(n + 5).
Ejercicio 2: Tarjeta con favorito
Crea una lista de productos en tarjetas. Pulsar en la tarjeta abre el detalle y muestra el producto en un título. Dentro de cada tarjeta hay un botón con una estrella que marca el favorito sin abrir el detalle.
Ver solución
import { useState } from 'react';
import type { MouseEvent } from 'react';
const productos = [
{ id: 1, nombre: 'Portátil' },
{ id: 2, nombre: 'Monitor' },
];
export default function App() {
const [abierto, setAbierto] = useState<number | null>(null);
const [favoritos, setFavoritos] = useState<number[]>([]);
function alternarFavorito(e: MouseEvent<HTMLButtonElement>, id: number) {
e.stopPropagation();
setFavoritos(favoritos.includes(id) ? favoritos.filter((f) => f !== id) : [...favoritos, id]);
}
const detalle = productos.find((p) => p.id === abierto);
return (
<div className="tarjeta">
<h2>Detalle: {detalle?.nombre ?? 'ninguno'}</h2>
{productos.map((p) => (
<div key={p.id} className="fila" onClick={() => setAbierto(p.id)}>
<span>{p.nombre}</span>
<button onClick={(e) => alternarFavorito(e, p.id)}>
{favoritos.includes(p.id) ? '★' : '☆'}
</button>
</div>
))}
</div>
);
}
stopPropagation evita que la tarjeta reciba el clic de la estrella. Guardamos solo abierto y derivamos detalle, como en el caso práctico.
Ejercicio 3: Etiquetas con el teclado
Haz un campo para añadir etiquetas a un producto. Enter añade la etiqueta y vacía el campo, sin añadir etiquetas vacías. Escape vacía el campo. Si el campo está vacío, Retroceso borra la última etiqueta.
Ver solución
import { useState } from 'react';
import type { KeyboardEvent } from 'react';
export default function App() {
const [texto, setTexto] = useState('');
const [etiquetas, setEtiquetas] = useState<string[]>([]);
function alPulsarTecla(e: KeyboardEvent<HTMLInputElement>) {
if (e.key === 'Enter') {
e.preventDefault();
const limpia = texto.trim();
if (limpia !== '') {
setEtiquetas([...etiquetas, limpia]);
setTexto('');
}
} else if (e.key === 'Escape') {
setTexto('');
} else if (e.key === 'Backspace' && texto === '') {
setEtiquetas(etiquetas.slice(0, -1));
}
}
return (
<div className="tarjeta">
<h2>Etiquetas: {etiquetas.join(', ') || 'ninguna'}</h2>
<input
value={texto}
onChange={(e) => setTexto(e.target.value)}
onKeyDown={alPulsarTecla}
/>
</div>
);
}
El preventDefault de Enter protege el componente si un día se coloca dentro de un formulario, donde Enter lo enviaría. Retroceso no se cancela: cuando hay texto, debe seguir borrando letras.
Ejercicio 4: Predice la cola
Sin ejecutarlo, escribe qué muestra la pantalla tras un clic, partiendo de n = 3, y qué imprime la consola:
function ejecutar() {
setN(n + 1);
setN((x) => x * 2);
console.log(n);
setN(10);
setN((x) => x + n);
}
Ver solución
import { useState } from 'react';
export default function App() {
const [n, setN] = useState(3);
function ejecutar() {
setN(n + 1);
setN((x) => x * 2);
console.log(n);
setN(10);
setN((x) => x + n);
}
return <button onClick={ejecutar}>n = {n}</button>;
}
La consola imprime 3, porque n es la instantánea. La pantalla muestra n = 13:
| Instrucción | Parte de | Resultado |
|---|---|---|
Sustituir por 4 (3 + 1) | 3 | 4 |
| Multiplicar por 2 | 4 | 8 |
| Sustituir por 10 | 8 | 10 |
Sumar n, que vale 3 en la clausura | 10 | 13 |
La última línea es la trampa: x avanza, pero n sigue siendo 3.
Ejercicio 5: Las notas que se quedan
Esta pantalla muestra las notas de cada departamento. Si escribes una nota en VENTAS y cambias a MARKETING, el texto se queda. Arréglalo de forma que al cambiar de departamento la nota empiece vacía, sin añadir ningún estado ni efecto:
function Notas({ departamento }: { departamento: string }) {
const [nota, setNota] = useState('');
return (
<label>
Nota para {departamento}:{' '}
<input value={nota} onChange={(e) => setNota(e.target.value)} />
</label>
);
}
Ver solución
Notas sigue en la misma posición, así que React conserva su estado. Basta con darle una key que dependa del departamento, sin tocar el componente:
import { useState } from 'react';
function Notas({ departamento }: { departamento: string }) {
const [nota, setNota] = useState('');
return (
<label>
Nota para {departamento}:{' '}
<input value={nota} onChange={(e) => setNota(e.target.value)} />
</label>
);
}
export default function App() {
const [departamento, setDepartamento] = useState('VENTAS');
return (
<div className="tarjeta">
<button onClick={() => setDepartamento('VENTAS')}>VENTAS</button>
<button onClick={() => setDepartamento('MARKETING')}>MARKETING</button>
<Notas key={departamento} departamento={departamento} />
</div>
);
}
Ejercicio 6: El temporizador de la instantánea
Un botón programa un aviso a los 2 segundos con el valor de un contador. Queremos dos variantes en el mismo componente: una que muestre el valor del momento de pulsar y otra que sume 1 al contador pasados los 2 segundos sin perder las pulsaciones ocurridas entretanto. Explica por qué la segunda necesita una función actualizadora.
Ver solución
import { useState } from 'react';
export default function App() {
const [n, setN] = useState(0);
const [aviso, setAviso] = useState('');
function avisar() {
setTimeout(() => setAviso(`El contador valía ${n}`), 2000);
}
function sumarLuego() {
setTimeout(() => setN((x) => x + 1), 2000);
}
return (
<div className="tarjeta">
<h2>n = {n}</h2>
<button onClick={() => setN(n + 1)}>Sumar</button>
<button onClick={avisar}>Avisar en 2 s</button>
<button onClick={sumarLuego}>Sumar en 2 s</button>
<p>{aviso}</p>
</div>
);
}
En avisar nos interesa la instantánea, y la clausura la conserva. En sumarLuego, si escribiéramos setN(n + 1), usaríamos la n del momento del clic y machacaríamos las sumas posteriores. Con x => x + 1 la operación se aplica sobre el valor que haya cuando se ejecuta.
Lo hemos probado así: Sumar en 2 s, tres veces Sumar, Avisar en 2 s y otra vez Sumar. El resultado final es n = 5 y el aviso dice El contador valía 3: la suma diferida no ha machacado ninguna pulsación, y el aviso conserva el valor del momento de pulsar.
Ejercicio 7: Repara el render impuro
Este componente lista los pedidos urgentes primero. En desarrollo el orden parece correcto, pero el padre muestra la lista original alterada. Encuentra el error y corrígelo:
function Urgentes({ pedidos }: { pedidos: Pedido[] }) {
pedidos.sort((a, b) => b.prioridad - a.prioridad);
return (
<ol>
{pedidos.map((p) => (
<li key={p.id}>{p.id}</li>
))}
</ol>
);
}
Ver solución
sort ordena sobre el propio array, de modo que el render está mutando una prop, algo prohibido desde el módulo 03. Usa toSorted, que devuelve una copia:
interface Pedido {
id: number;
prioridad: number;
}
const pedidos: Pedido[] = [
{ id: 5001, prioridad: 1 },
{ id: 5002, prioridad: 3 },
{ id: 5003, prioridad: 2 },
];
function Urgentes({ pedidos }: { pedidos: Pedido[] }) {
const ordenados = pedidos.toSorted((a, b) => b.prioridad - a.prioridad);
return (
<ol>
{ordenados.map((p) => (
<li key={p.id}>{p.id}</li>
))}
</ol>
);
}
export default function App() {
return (
<div className="tarjeta">
<h2>Urgentes</h2>
<Urgentes pedidos={pedidos} />
<h2>Por llegada: {pedidos.map((p) => p.id).join(', ')}</h2>
</div>
);
}
La segunda línea sigue en orden de llegada, 5001, 5002, 5003. Con pedidos.sort mostraría 5002, 5003, 5001.
Ejercicio 8: Auditoría de clics
Una zona contiene botones, y uno de ellos detiene la propagación. Haz que un contador en la zona cuente todos los clics que ocurran dentro, también los de ese botón, sin modificar los botones.
Ver solución
Un onClick en la zona no recibe el clic del botón que para la propagación. La captura sí, porque se ejecuta antes:
import { useState } from 'react';
export default function App() {
const [clics, setClics] = useState(0);
const [burbuja, setBurbuja] = useState(0);
return (
<div
className="tarjeta"
onClickCapture={() => setClics((c) => c + 1)}
onClick={() => setBurbuja((b) => b + 1)}
>
<h2>
Auditoría: {clics} clics (burbuja: {burbuja})
</h2>
<button>Normal</button>
<button onClick={(e) => e.stopPropagation()}>Discreto</button>
</div>
);
}
Con un clic en cada botón, marca Auditoría: 2 clics (burbuja: 1).
Ejercicio 9 (propuesto): Clic fuera
Crea un menú desplegable que se cierre al pulsar fuera de él o al pulsar Escape. Hazlo primero con una capa transparente de pantalla completa detrás del menú, cuyo onClick lo cierre. Después piensa cómo lo harías con un oyente en document, y comprueba qué ocurre si otro componente de la página usa stopPropagation.
Ejercicio 10 (propuesto): Asistente por pasos
Crea un asistente de alta de empleado con tres pasos, en el que cada paso es un componente distinto con su propio estado. Comprueba que al avanzar y retroceder se pierde lo escrito, y explica la causa. Después corrígelo de dos formas: elevando el estado al padre y ocultando los pasos con hidden. Compara las dos versiones.
Ejercicio 11 (propuesto): Banco de pruebas de renders
Crea un árbol de tres niveles con un console.log en cada componente. Añade dos estados en la raíz y actualízalos en un manejador, en un setTimeout y separados por un await. Antes de ejecutar, escribe cuántos renders esperas en cada caso. Compara el resultado con y sin <StrictMode>, y en la compilación de producción con npm run build y npm run preview.
Resumen
| Concepto | API | Idea clave |
|---|---|---|
| Manejador | onClick={guardar} | Se pasa la función, no se llama |
| Evento sintético | SyntheticEvent, e.nativeEvent | Envuelve al nativo con la misma interfaz |
| Delegación | Oyentes en #root | React escucha en la raíz y recorre su árbol |
| Propagación | onClick, onClickCapture | Baja en captura y sube en burbuja |
| Origen del evento | e.target, e.currentTarget | Dónde ocurrió y quién lo atiende |
| Detener el viaje | e.stopPropagation() | Con moderación: deja ciegos a otros |
| Cancelar la acción | e.preventDefault() | No funciona en oyentes pasivos como onWheel |
| Tipos | MouseEvent<T>, ChangeEvent<T>, SubmitEvent<T> | Impórtalos de react, no uses los globales |
| Botón de envío | e.submitter 🆕 19.3 | Antes, e.nativeEvent.submitter |
| Pantalla completa | onFullscreenChange 🆕 19.3 | Aún sin tipos en @types/react 19.3.0 |
| Fases | trigger, render, commit | Render calcula; commit toca el DOM |
| Evitar render | Object.is | El mismo valor no actualiza; mutar tampoco |
| Instantánea | Estado por render | Un const para cada render, también en clausuras |
| Cola | setN(x => x + 1) | Un valor sustituye; una función transforma |
| Lotes | Automático desde React 18 | Un render por tanda, y await separa tandas |
| Identidad | Tipo + posición + key | Mientras coincidan, el estado se conserva |
| Componente anidado | Nivel superior del módulo | Si no, cada render crea un tipo nuevo |
| Reinicio | key | Cambiarla destruye y recrea el componente |
| Ocultar sin perder | hidden, <Activity> 🆕 19.2 | Salir del árbol destruye el estado |
| Strict Mode | <StrictMode> | Repite renders y efectos en desarrollo para delatar errores |
| Ref callbacks | 🔄 19.0 | Strict Mode también los repite |