Módulo 06: Renderizado condicional y listas
Objetivos del módulo
- Elegir la forma adecuada de renderizar algo según una condición:
if,null, ternario,&&u objeto de presentación - Conocer qué valores pinta JSX y evitar la trampa del
0con&& - Distinguir entre quitar un componente del árbol y esconderlo, y saber qué le pasa a su estado en cada caso
- Transformar arrays en elementos con
map - Entender qué hace
keypor dentro y por qué el índice falla cuando la lista cambia - Filtrar y ordenar sin guardar copias en el estado y sin mutar los datos
- Construir tablas válidas, ordenables y accesibles
- Tratar todos los estados de una lista: carga, error, vacía y sin resultados
- Agrupar con
Object.groupByyMap.groupBy - Paginar en el cliente y decidir cuándo esa tarea le corresponde al servidor
- Combinar filtro, orden y paginación en un panel completo
💡 Consejo: En este módulo solo hay una marca:
<Activity>🆕 19.2, en la sección 3. Todo lo demás funciona igual en React 18.3 y en 19.3. Hemos medido el comportamiento dekeyen 18.3.1 y en 19.3.1 con la misma prueba, y los resultados son idénticos.
1. Prepara el proyecto
Crea un proyecto como en el módulo 01, donde también se explica qué cambia si usas PowerShell:
npm create vite@latest modulo-06 -- --template react-ts
cd modulo-06
npm install
npm run dev
Cada ejemplo completo sustituye a src/App.tsx. El nombre de la función exportada da igual, porque main.tsx importa la exportación por defecto. 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: #fff;
border: 1px solid #c5cae9;
border-radius: 10px;
padding: 1rem 1.25rem;
max-width: 760px;
margin: 0 0 1rem;
}
.tarjeta h2 {
margin: 0 0 0.5rem;
font-size: 1.15rem;
color: #1a237e;
}
.tarjeta h3 {
margin: 0.75rem 0 0.25rem;
font-size: 1rem;
color: #283593;
}
.fila {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
margin: 0.5rem 0;
}
.columnas {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
max-width: 860px;
}
.tenue {
color: #607d8b;
font-size: 0.9rem;
}
.aviso {
background: #fff3e0;
border-left: 4px solid #ff7043;
padding: 0.5rem 0.75rem;
border-radius: 4px;
}
button,
input,
select,
textarea {
font: inherit;
}
button {
background: #3f51b5;
color: #fff;
border: none;
border-radius: 6px;
padding: 0.35rem 0.75rem;
cursor: pointer;
}
button:disabled {
background: #b0bec5;
cursor: not-allowed;
}
input,
select,
textarea {
border: 1px solid #9fa8da;
border-radius: 6px;
padding: 0.3rem 0.5rem;
}
.insignia {
display: inline-block;
border-radius: 999px;
padding: 0.05rem 0.6rem;
font-size: 0.8rem;
font-weight: 600;
background: #eceff1;
color: #37474f;
}
.insignia.exito {
background: #c8e6c9;
color: #1b5e20;
}
.insignia.info {
background: #bbdefb;
color: #0d47a1;
}
.insignia.alerta {
background: #ffe0b2;
color: #e65100;
}
.insignia.peligro {
background: #ffcdd2;
color: #b71c1c;
}
.lista {
list-style: none;
margin: 0;
padding: 0;
}
.lista li {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: center;
padding: 0.4rem 0;
border-bottom: 1px solid #eceff1;
}
.tabla {
border-collapse: collapse;
width: 100%;
font-size: 0.92rem;
}
.tabla caption {
text-align: left;
color: #607d8b;
padding-bottom: 0.4rem;
}
.tabla th,
.tabla td {
border-bottom: 1px solid #e0e3f0;
padding: 0.35rem 0.5rem;
text-align: left;
}
.tabla thead th {
background: #e8eaf6;
color: #1a237e;
}
.tabla .numero {
text-align: right;
font-variant-numeric: tabular-nums;
}
.tabla .grupo th {
background: #f1f3fb;
color: #283593;
}
.tabla tfoot td {
font-weight: 600;
background: #f5f6fa;
}
.tabla tr.marcada td {
background: #fff3e0;
}
.cabecera-orden {
all: unset;
cursor: pointer;
font-weight: 700;
}
.cabecera-orden:focus-visible {
outline: 2px solid #ff7043;
outline-offset: 2px;
}
.paginador {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
margin-top: 0.75rem;
}
.paginador button {
background: #e8eaf6;
color: #1a237e;
min-width: 2.2rem;
}
.paginador button[aria-current='page'] {
background: #3f51b5;
color: #fff;
}
.paginador button:disabled {
background: #eceff1;
color: #90a4ae;
}
.chip {
background: #fff;
color: #283593;
border: 1px solid #9fa8da;
border-radius: 999px;
}
.chip[aria-pressed='true'] {
background: #3f51b5;
color: #fff;
}
Las listas necesitan datos con los que merezca la pena trabajar. Crea src/datos.ts con los empleados y departamentos de la aplicación EMPRESA. Son los mismos que devuelve la API de ejemplo, reducidos a los campos que usamos aquí:
export interface Empleado {
empId: number;
nombre: string;
apellidos: string;
oficio: string;
salario: number;
comision: number | null;
deptId: number | null;
fechaAlta: string;
}
export interface Departamento {
deptId: number;
nombre: string;
localidad: string;
}
export const departamentos: Departamento[] = [
{ deptId: 10, nombre: 'DIRECCIÓN', localidad: 'Madrid' },
{ deptId: 20, nombre: 'VENTAS', localidad: 'Madrid' },
{ deptId: 30, nombre: 'INFORMÁTICA', localidad: 'Valencia' },
{ deptId: 40, nombre: 'CONTABILIDAD', localidad: 'Sevilla' },
{ deptId: 50, nombre: 'MARKETING', localidad: 'Barcelona' },
{ deptId: 60, nombre: 'LOGÍSTICA', localidad: 'Zaragoza' },
{ deptId: 70, nombre: 'INVESTIGACIÓN', localidad: 'Bilbao' },
];
export const empleados: Empleado[] = [
{ empId: 100, nombre: 'Ana', apellidos: 'García López', oficio: 'DIRECTOR GENERAL', salario: 6500, comision: null, deptId: 10, fechaAlta: '2010-01-15' },
{ empId: 101, nombre: 'Luis', apellidos: 'Martínez Ruiz', oficio: 'DIRECTOR', salario: 4800, comision: null, deptId: 20, fechaAlta: '2012-03-01' },
{ empId: 102, nombre: 'Marta', apellidos: 'Sánchez Gil', oficio: 'DIRECTOR', salario: 5000, comision: null, deptId: 30, fechaAlta: '2011-06-20' },
{ empId: 103, nombre: 'Pedro', apellidos: 'Gómez Díaz', oficio: 'DIRECTOR', salario: 4200, comision: null, deptId: 40, fechaAlta: '2013-09-10' },
{ empId: 104, nombre: 'Laura', apellidos: 'Fernández Moreno', oficio: 'DIRECTOR', salario: 4300, comision: null, deptId: 50, fechaAlta: '2015-02-02' },
{ empId: 105, nombre: 'Javier', apellidos: 'López Navarro', oficio: 'DIRECTOR', salario: 4000, comision: null, deptId: 60, fechaAlta: '2014-11-11' },
{ empId: 110, nombre: 'Carmen', apellidos: 'Ruiz Ortega', oficio: 'VENDEDOR', salario: 2200, comision: 500, deptId: 20, fechaAlta: '2016-04-12' },
{ empId: 111, nombre: 'David', apellidos: 'Torres Romero', oficio: 'VENDEDOR', salario: 2100, comision: 800, deptId: 20, fechaAlta: '2017-07-01' },
{ empId: 112, nombre: 'Elena', apellidos: 'Navarro Castro', oficio: 'VENDEDOR', salario: 2300, comision: 300, deptId: 20, fechaAlta: '2019-01-15' },
{ empId: 113, nombre: 'Sergio', apellidos: 'Jiménez Vidal', oficio: 'VENDEDOR', salario: 1900, comision: 0, deptId: 20, fechaAlta: '2021-09-01' },
{ empId: 114, nombre: 'Lucía', apellidos: 'Romero Serrano', oficio: 'ADMINISTRATIVO', salario: 1700, comision: null, deptId: 20, fechaAlta: '2020-02-10' },
{ empId: 120, nombre: 'Pablo', apellidos: 'Moreno Herrera', oficio: 'ANALISTA', salario: 3500, comision: null, deptId: 30, fechaAlta: '2014-05-05' },
{ empId: 121, nombre: 'Raquel', apellidos: 'Molina Prieto', oficio: 'PROGRAMADOR', salario: 2800, comision: null, deptId: 30, fechaAlta: '2018-10-01' },
{ empId: 122, nombre: 'Iván', apellidos: 'Castro Méndez', oficio: 'PROGRAMADOR', salario: 2600, comision: null, deptId: 30, fechaAlta: '2020-03-16' },
{ empId: 123, nombre: 'Sara', apellidos: 'Ortiz Delgado', oficio: 'PROGRAMADOR', salario: 2400, comision: null, deptId: 30, fechaAlta: '2022-06-01' },
{ empId: 124, nombre: 'Hugo', apellidos: 'Rubio Marín', oficio: 'TÉCNICO SISTEMAS', salario: 2500, comision: null, deptId: 30, fechaAlta: '2019-11-20' },
{ empId: 130, nombre: 'Isabel', apellidos: 'Domínguez Vega', oficio: 'CONTABLE', salario: 2600, comision: null, deptId: 40, fechaAlta: '2016-08-08' },
{ empId: 131, nombre: 'Jorge', apellidos: 'Vázquez Ramos', oficio: 'ADMINISTRATIVO', salario: 1650, comision: null, deptId: 40, fechaAlta: '2023-01-09' },
{ empId: 140, nombre: 'Nuria', apellidos: 'Gil Soto', oficio: 'DISEÑADOR', salario: 2300, comision: null, deptId: 50, fechaAlta: '2018-05-21' },
{ empId: 141, nombre: 'Álvaro', apellidos: 'Peña Cano', oficio: 'ANALISTA', salario: 2700, comision: null, deptId: 50, fechaAlta: '2021-04-12' },
{ empId: 150, nombre: 'Rosa', apellidos: 'Iglesias Cruz', oficio: 'OPERARIO', salario: 1600, comision: null, deptId: 60, fechaAlta: '2017-03-03' },
{ empId: 151, nombre: 'Manuel', apellidos: 'Herrero Lozano', oficio: 'OPERARIO', salario: 1600, comision: null, deptId: 60, fechaAlta: '2022-10-17' },
{ empId: 152, nombre: 'Cristina', apellidos: 'Medina Blanco', oficio: 'ADMINISTRATIVO', salario: 1750, comision: null, deptId: 60, fechaAlta: '2019-07-22' },
{ empId: 160, nombre: 'Tomás', apellidos: 'Reyes Fuentes', oficio: 'BECARIO', salario: 900, comision: null, deptId: null, fechaAlta: '2025-09-01' },
];
Fíjate en cuatro detalles de estos datos, porque varios ejemplos dependen de ellos:
| Dato | Por qué nos interesa |
|---|---|
Sergio tiene comision: 0 y Lucía comision: null | “Comisión cero” y “sin comisión” significan cosas distintas |
Tomás tiene deptId: null | Hay empleados sin departamento |
| INVESTIGACIÓN no tiene empleados | Hay grupos vacíos |
| Álvaro empieza con tilde; Peña lleva eñe | Ordenar y buscar en español exige algo más que comparar letras |
La sección 9 pide datos a la API. Descarga recursos/empresa-api.zip, descomprímelo y arráncalo en otra terminal con node server.mjs. Quedará en http://localhost:3000/api.
2. Renderizado condicional
Una interfaz real cambia según los datos: un empleado puede no existir, un pedido puede estar cancelado y una lista puede estar vacía. En una plantilla HTML clásica escribirías directivas especiales. En Angular, por ejemplo, @if. React no tiene ninguna, porque JSX es JavaScript: un componente es una función, así que usas las mismas herramientas que en cualquier función. El módulo 02 explicó que JSX se transforma en llamadas a funciones; aquí sacamos partido de ello.
📘 Concepto: Renderizado condicional significa devolver una interfaz u otra según una condición. No es una función de React, sino JavaScript normal aplicado a elementos JSX, que son valores como cualquier otro.
Qué pinta React y qué ignora
Antes de ver las formas de escribir una condición, hay que saber qué hace JSX con cada valor. Esta tabla es el resultado real de renderizar <p>{valor}</p> en React 19.3.1:
| Valor entre llaves | HTML resultante | Comentario |
|---|---|---|
false, true | <p></p> | No se pinta |
null, undefined | <p></p> | No se pinta |
'' | <p></p> | Cadena vacía |
0 | <p>0</p> | Sí se pinta |
NaN | <p>NaN</p> | Sí se pinta, y React avisa en la consola |
[1, 2] | <p>12</p> | Pinta cada elemento del array |
{ a: 1 } | Error | Objects are not valid as a React child |
Las cuatro primeras filas son la base del renderizado condicional: si una expresión produce false o null, no aparece nada. La fila del 0 provoca uno de los errores más conocidos de React, que veremos enseguida.
if y salida temprana
La forma más legible para los casos especiales es salir pronto de la función. Si el empleado no existe, no tiene sentido seguir:
import { departamentos, empleados, type Empleado } from './datos';
interface FichaProps {
empleado: Empleado | undefined;
}
function Ficha({ empleado }: FichaProps) {
// Salida temprana: a partir de aquí, TypeScript sabe que empleado existe
if (!empleado) {
return <p className="aviso">No existe ese empleado.</p>;
}
const departamento = departamentos.find((d) => d.deptId === empleado.deptId);
return (
<article className="tarjeta">
<h2>
{empleado.nombre} {empleado.apellidos}
</h2>
<p>{empleado.oficio}</p>
<p>
{departamento ? (
`${departamento.nombre} (${departamento.localidad})`
) : (
<span className="insignia alerta">Sin departamento</span>
)}
</p>
</article>
);
}
function buscar(empId: number) {
return empleados.find((e) => e.empId === empId);
}
export default function Fichas() {
return (
<>
<Ficha empleado={buscar(100)} />
<Ficha empleado={buscar(160)} />
<Ficha empleado={buscar(999)} />
</>
);
}

Figura 1. El mismo componente produce tres resultados: dato completo, dato parcial y dato inexistente.
La salida temprana tiene una ventaja añadida con TypeScript: después del if, el compilador estrecha el tipo de empleado de Empleado | undefined a Empleado. Por eso empleado.deptId compila sin signos de exclamación. Si lo escribieras todo dentro de un ternario gigante, perderías esa claridad.
⚠️ Importante: Los hooks deben llamarse antes de cualquier
returntemprano. Como viste en el módulo 04, React identifica cadauseStatepor su orden de llamada. UnuseStatedespués de unreturncondicional se ejecutaría unas veces sí y otras no, y eso rompe las reglas de los hooks.
Devolver null
Un componente puede decidir no pintar nada. Esto se usa en piezas que solo aparecen en ciertas circunstancias:
function BannerBecario({ empleado }: { empleado: Empleado }) {
if (empleado.oficio !== 'BECARIO') return null;
return <p className="aviso">Contrato en prácticas</p>;
}
Devolver null no es lo mismo que no existir: la función se ha ejecutado, sus hooks han corrido y su estado sigue guardado. Lo único que falta es el resultado en el DOM. Si la condición es cara o el componente tiene efectos, suele ser mejor que decida el padre.
Ternario: una cosa u otra
El ternario condicion ? a : b es una expresión, así que cabe dentro del JSX. Es la herramienta natural cuando hay exactamente dos alternativas:
<p>{departamento ? departamento.nombre : 'Sin departamento'}</p>
Se vuelve ilegible cuando se anida. Compara estas dos versiones:
// ❌ MAL: hay que leerlo tres veces
{cargando ? <Spinner /> : error ? <Fallo /> : datos.length === 0 ? <Vacia /> : <Tabla datos={datos} />}
// ✅ BIEN: un componente con salidas tempranas
function Contenido({ cargando, error, datos }: ContenidoProps) {
if (cargando) return <Spinner />;
if (error) return <Fallo />;
if (datos.length === 0) return <Vacia />;
return <Tabla datos={datos} />;
}
La regla práctica: un nivel de ternario, sí; a partir de dos niveles, extrae un componente. Los componentes Spinner, Fallo, Vacia y Tabla de este fragmento son figurados, pero en la sección 9 harás esto mismo con código real.
&&: algo o nada
Si no hay alternativa, && es más corto que condicion ? <Algo /> : null. Funciona porque JavaScript evalúa a && b así: si a es falso, devuelve a; si no, devuelve b.
{seleccionado && <p>Has elegido a {seleccionado.nombre}</p>}
La palabra clave es devuelve a. El operador no devuelve false, sino el propio valor de la izquierda. Si la izquierda es null, no pasa nada, porque null no se pinta. Pero si es 0, React pinta un cero:
import { empleados } from './datos';
// Carmen tiene 500, Sergio tiene 0 y Lucía tiene null
const muestra = empleados.filter((e) => [110, 113, 114].includes(e.empId));
export default function ComisionCero() {
return (
<div className="columnas">
<section className="tarjeta">
<h2>❌ Con && sobre un número</h2>
<ul className="lista">
{muestra.map((e) => (
<li key={e.empId}>
<span>{e.nombre}</span>
<span>{e.comision && <b>{e.comision} €</b>}</span>
</li>
))}
</ul>
</section>
<section className="tarjeta">
<h2>✅ Con una condición booleana</h2>
<ul className="lista">
{muestra.map((e) => (
<li key={e.empId}>
<span>{e.nombre}</span>
<span>{e.comision !== null && <b>{e.comision} €</b>}</span>
</li>
))}
</ul>
</section>
</div>
);
}

Figura 2. A la izquierda, el 0 de Sergio se cuela en la pantalla sin su etiqueta. A la derecha, la condición booleana trata bien los tres casos.
Mira la tarjeta izquierda: Carmen sale bien y Lucía también, pero Sergio muestra un 0 suelto. Es el valor de e.comision devuelto por &&. El caso más habitual de este error está en las listas:
// ❌ MAL: con la lista vacía, en pantalla aparece un 0
{pedidos.length && <Lista pedidos={pedidos} />}
// ✅ BIEN: una comparación siempre da true o false
{pedidos.length > 0 && <Lista pedidos={pedidos} />}
⚠️ Importante: A la izquierda de
&&en JSX pon siempre un booleano real: una comparación (> 0,!== null), un método que devuelva booleano (includes,some) o un valor que ya seaboolean. Si es un número o puede serNaN, compáralo. TypeScript no te avisará, porque0es un hijo válido para React.
Tampoco arregla el problema escribir el ternario sin pensar: {e.comision ? <b>...</b> : null} hace desaparecer el 0, pero también oculta que Sergio tiene una comisión de cero euros, que es un dato real. La pregunta correcta no es “¿tiene valor verdadero?”, sino “¿qué distingue a quien tiene dato de quien no lo tiene?”. Aquí, que no sea null.
|| y ??: valores por defecto
Dos operadores parecidos dan valores de reserva, y el 0 vuelve a marcar la diferencia:
| Expresión | comision = 500 | comision = 0 | comision = null |
|---|---|---|---|
comision \|\| 'Sin dato' | 500 | 'Sin dato' ❌ | 'Sin dato' |
comision ?? 'Sin dato' | 500 | 0 ✅ | 'Sin dato' |
|| reacciona a cualquier valor falso (0, '', false, null, undefined), mientras que ?? solo reacciona a null y undefined. Cuando el 0 o la cadena vacía sean valores válidos, usa ??. Si necesitas repasarlo, el Curso de JavaScript explica ambos operadores.
Guardar JSX en una variable
Cuando la decisión es larga, puedes calcularla antes del return. Es útil si no compensa crear un componente:
import type { ReactNode } from 'react';
function Aviso({ errores }: { errores: string[] }) {
let contenido: ReactNode;
if (errores.length === 0) {
contenido = <p>Todo correcto.</p>;
} else if (errores.length === 1) {
contenido = <p>{errores[0]}</p>;
} else {
contenido = (
<ul>
{errores.map((error) => (
<li key={error}>{error}</li>
))}
</ul>
);
}
return <aside className="aviso">{contenido}</aside>;
}
ReactNode es el tipo de “cualquier cosa que React sabe pintar”. El módulo 13 lo estudia a fondo.
Tres o más casos: objeto de presentación
Si la interfaz depende de un valor con varias opciones, una cadena de if repite mucho. Es mejor describir los casos como datos:
type EstadoPedido = 'PENDIENTE' | 'ENVIADO' | 'ENTREGADO' | 'CANCELADO';
interface Pedido {
pedidoId: number;
cliente: string;
estado: EstadoPedido;
}
// Record obliga a cubrir todos los estados: si falta uno, no compila
const PRESENTACION: Record<EstadoPedido, { texto: string; clase: string }> = {
PENDIENTE: { texto: 'Pendiente', clase: 'alerta' },
ENVIADO: { texto: 'En camino', clase: 'info' },
ENTREGADO: { texto: 'Entregado', clase: 'exito' },
CANCELADO: { texto: 'Cancelado', clase: 'peligro' },
};
const pedidos: Pedido[] = [
{ pedidoId: 5001, cliente: 'Talleres Martín S.L.', estado: 'ENTREGADO' },
{ pedidoId: 5002, cliente: 'Construcciones Levante S.A.', estado: 'ENVIADO' },
{ pedidoId: 5003, cliente: 'Papelería Central', estado: 'PENDIENTE' },
{ pedidoId: 5004, cliente: 'Hotel Mediterráneo', estado: 'CANCELADO' },
];
function InsigniaEstado({ estado }: { estado: EstadoPedido }) {
const { texto, clase } = PRESENTACION[estado];
return <span className={`insignia ${clase}`}>{texto}</span>;
}
export default function EstadosPedido() {
return (
<section className="tarjeta">
<h2>Pedidos</h2>
<ul className="lista">
{pedidos.map((pedido) => (
<li key={pedido.pedidoId}>
<span>
#{pedido.pedidoId} · {pedido.cliente}
</span>
<InsigniaEstado estado={pedido.estado} />
</li>
))}
</ul>
</section>
);
}

Figura 3. Cada estado del pedido tiene su texto y su color definidos en un solo objeto.
Este patrón separa la decisión de la presentación, y Record<EstadoPedido, ...> añade una garantía: si mañana aparece el estado DEVUELTO en el tipo, el objeto deja de compilar hasta que lo cubras. Con una cadena de if, el caso nuevo pasaría sin que nadie se enterase.
Cuando cada caso exige lógica distinta, switch es igual de válido. Con el tipo de retorno declarado, TypeScript también comprueba que cubras todos los casos:
function textoEstado(estado: EstadoPedido): string {
switch (estado) {
case 'PENDIENTE':
return 'Pendiente';
case 'ENVIADO':
return 'En camino';
case 'ENTREGADO':
return 'Entregado';
case 'CANCELADO':
return 'Cancelado';
}
}
Figura 4. Cada pregunta lleva a una forma de condicional.
3. Esconder o quitar
Hay dos cosas muy distintas que parecen iguales en pantalla: quitar un componente y esconderlo. La diferencia está en el estado, que el módulo 05 explicó: React guarda el estado asociado a la posición del componente en el árbol. Si el componente sale del árbol, su estado se destruye.
Esta prueba escribe texto en tres campos, oculta los paneles y los vuelve a mostrar:
import { Activity, useState } from 'react';
function Notas({ titulo }: { titulo: string }) {
const [texto, setTexto] = useState('');
return (
<label>
{titulo}
<br />
<input value={texto} onChange={(e) => setTexto(e.target.value)} />
</label>
);
}
export default function OcultarOQuitar() {
const [visible, setVisible] = useState(true);
return (
<section className="tarjeta">
<h2>Tres formas de esconder un panel</h2>
<button onClick={() => setVisible(!visible)}>{visible ? 'Ocultar' : 'Mostrar'} paneles</button>
{/* 1. Se desmonta: React destruye el DOM y el estado */}
<h3>Condicional con &&</h3>
{visible && <Notas titulo="Notas A" />}
{/* 2. Sigue montado: solo lo esconde el navegador */}
<h3>Atributo hidden</h3>
<div hidden={!visible}>
<Notas titulo="Notas B" />
</div>
{/* 3. React lo esconde, conserva el estado y limpia sus efectos */}
<h3>Componente Activity</h3>
<Activity mode={visible ? 'visible' : 'hidden'}>
<Notas titulo="Notas C" />
</Activity>
</section>
);
}

Figura 5. Tras ocultar y volver a mostrar, solo el campo desmontado ha perdido el texto.
| Técnica | Estado | DOM al ocultar | Efectos al ocultar | Cuándo usarla |
|---|---|---|---|---|
{visible && <Panel />} | Se pierde | Se elimina | Se limpian | Opción por defecto: diálogos, paneles que empiezan de cero |
hidden o CSS | Se conserva | Permanece | Siguen activos | Pestañas sencillas sin efectos costosos |
<Activity> 🆕 19.2 | Se conserva | Permanece, oculto | Se limpian | Pestañas o paneles con efectos que no deben seguir activos |
Para los efectos necesitarás el módulo 09. Por ahora, basta con esta idea: un componente con hidden sigue funcionando aunque no se vea, y si tuviera un temporizador o una conexión abierta, seguirían ahí.
⚠️ Importante: El atributo
hiddenfunciona porque el navegador le aplicadisplay: none. Si tu CSS le pone al mismo elemento undisplaypropio, por ejemplodisplay: flex, ese CSS gana y el elemento sigue visible.
Conservar el estado con <Activity> 🆕 19.2
🆕 Desde React 19.2. No existe en React 19.1 ni anteriores. Si usas una versión anterior: usa el atributo
hidden, que también conserva el estado, aunque los efectos del panel oculto seguirán activos. En el ejemplo de esta sección, borra el bloque 3 y su importación.
<Activity mode="hidden"> esconde a sus hijos, conserva su estado y su DOM, y limpia sus efectos hasta que vuelvan a ser visibles. Es un punto medio entre las dos técnicas anteriores. El módulo 17 lo estudia a fondo; aquí basta con saber que existe.
4. Listas con map
En las aplicaciones de gestión casi todo son listas. La idea es la misma que con las condiciones: como JSX es un valor, puedes construir un array de elementos, y ya vimos en la tabla de la sección 2 que React pinta cada elemento de un array.
La herramienta para convertir un array de datos en un array de elementos es map:
const nombres = ['Ana', 'Luis', 'Marta'];
// Devuelve un array con tres elementos <li>
const elementos = nombres.map((nombre) => <li key={nombre}>{nombre}</li>);
return <ul>{elementos}</ul>;
Lo normal es escribirlo directamente en el JSX y extraer cada fila a su propio componente cuando crezca:
import { departamentos, empleados, type Departamento } from './datos';
function FilaDepartamento({ departamento }: { departamento: Departamento }) {
const plantilla = empleados.filter((e) => e.deptId === departamento.deptId).length;
const texto = plantilla === 1 ? '1 persona' : `${plantilla} personas`;
return (
<li>
<span>
<b>{departamento.nombre}</b> <span className="tenue">{departamento.localidad}</span>
</span>
<span className={plantilla === 0 ? 'insignia alerta' : 'insignia'}>
{plantilla === 0 ? 'Sin plantilla' : texto}
</span>
</li>
);
}
export default function ListaDepartamentos() {
return (
<section className="tarjeta">
<h2>Departamentos</h2>
<ul className="lista">
{departamentos.map((departamento) => (
<FilaDepartamento key={departamento.deptId} departamento={departamento} />
))}
</ul>
</section>
);
}

Figura 6. Una lista con una condición en cada fila.
Tres cosas que observar:
keyva en el elemento que devuelvemap, es decir, en<FilaDepartamento>, no en el<li>interior. Veremos por qué en la siguiente sección.-
mapcon llaves necesitareturn. Con paréntesis, la función flecha devuelve la expresión directamente:// ❌ MAL: las llaves abren un bloque sin return, y map produce undefined {lista.map((e) => { <li key={e.empId}>{e.nombre}</li>; })} // ✅ BIEN: paréntesis, devolución implícita {lista.map((e) => (<li key={e.empId}>{e.nombre}</li>))} // ✅ BIEN: llaves con return, útil si hay cálculos antes {lista.map((e) => { const completo = `${e.nombre} ${e.apellidos}`; return <li key={e.empId}>{completo}</li>; })} mapno necesita componente propio si la fila es pequeña. Extrae uno cuando la fila tenga lógica o estado propio, comoFilaDepartamento, que calcula su plantilla y su texto.
Si la fila necesita varias ramas, combina lo aprendido: un map puede contener cualquier condicional. Y si una fila no debe aparecer, no devuelvas null desde map: filtra antes, como en la sección 6.
5. key a fondo
Si quitas key de un map, la consola te avisa. Este es el texto real que muestra React 19.3.1:
Each child in a list should have a unique "key" prop.
Check the render method of `SinKey`. See https://react.dev/link/warning-keys for more information.
Mucha gente “resuelve” el aviso con el índice de map y sigue adelante. Para entender por qué es mala idea, hay que ver cómo trabaja React.
Qué problema resuelve key
Tras cada render, React compara el árbol nuevo con el anterior para decidir qué cambiar en el DOM. A este proceso se le llama reconciliación. El módulo 05 mostró que la identidad de un componente depende de su posición. Pero en una lista dinámica la posición no basta: si un elemento se añade al principio, todos los demás cambian de posición sin haber cambiado de identidad.
key es la forma de decirle a React quién es cada hijo. Dentro de un mismo padre, React empareja el hijo antiguo y el nuevo que tengan la misma key:
- Misma
keyen ambos renders: es el mismo componente. Conserva estado y DOM, y si ha cambiado de sitio, se mueve. keynueva: se monta un componente desde cero.keyque desaparece: se desmonta, y su estado se destruye.
📘 Concepto:
keyes la identidad de un elemento entre sus hermanos. No es un dato para tu componente, sino para el algoritmo de reconciliación. Por eso debe identificar al dato, no a su posición.
Por qué falla el índice: el estado se queda en la posición
Este ejemplo pinta la misma lista dos veces. Cada fila tiene un campo de nota con su propio estado:
import { useState } from 'react';
interface Tarea {
id: number;
texto: string;
}
// Cada fila tiene su propio estado: la nota que escribe el usuario
function FilaTarea({ tarea }: { tarea: Tarea }) {
const [nota, setNota] = useState('');
return (
<li>
<span>{tarea.texto}</span>
<input
aria-label={`Nota de ${tarea.texto}`}
placeholder="Nota..."
value={nota}
onChange={(e) => setNota(e.target.value)}
/>
</li>
);
}
const iniciales: Tarea[] = [
{ id: 1, texto: 'Revisar nóminas' },
{ id: 2, texto: 'Cerrar el trimestre' },
];
export default function IndiceContraId() {
const [tareas, setTareas] = useState(iniciales);
const [siguienteId, setSiguienteId] = useState(3);
function anadirAlPrincipio() {
const nueva = { id: siguienteId, texto: `Tarea urgente ${siguienteId}` };
setTareas([nueva, ...tareas]);
setSiguienteId(siguienteId + 1);
}
return (
<>
<button onClick={anadirAlPrincipio}>Añadir al principio</button>
<div className="columnas" style={{ marginTop: '0.75rem' }}>
<section className="tarjeta" id="con-indice">
<h2>❌ key = índice</h2>
<ul className="lista">
{tareas.map((tarea, indice) => (
<FilaTarea key={indice} tarea={tarea} />
))}
</ul>
</section>
<section className="tarjeta" id="con-id">
<h2>✅ key = id</h2>
<ul className="lista">
{tareas.map((tarea) => (
<FilaTarea key={tarea.id} tarea={tarea} />
))}
</ul>
</section>
</div>
</>
);
}
Escribe “Urgente: hoy” en la primera fila de las dos tarjetas:

Figura 7. Antes de añadir, las dos listas son idénticas.
Ahora pulsa “Añadir al principio”:

Figura 8. Con el índice, la nota se ha quedado en la primera posición y ahora acompaña a otra tarea. Con el id, la nota sigue a su tarea.
La nota escrita para “Revisar nóminas” ha acabado en “Tarea urgente 3”. No es un fallo visual: en una aplicación real, esto significa asignar un dato a la persona equivocada. Mira qué ha pasado por dentro:
Figura 9. Con el índice, la key 0 antigua se empareja con la key 0 nueva, aunque el dato sea otro.
Con el índice, React compara “key 0 antes” con “key 0 ahora”, ve la misma key y concluye que es el mismo componente: le cambia las props y le deja su estado. Nada ha fallado en React. Le has dicho que esa fila era la misma.
Este efecto no se limita a useState. También afecta a cualquier cosa que viva en el DOM o en la instancia: el foco, la posición del cursor, el valor de un campo no controlado, las animaciones o los efectos.
Cuánto trabajo hace el DOM
La identidad no solo afecta a la corrección, sino también al rendimiento. Hemos medido con un MutationObserver las operaciones reales en el DOM sobre una lista de 1000 <li>. Los resultados son idénticos en React 18.3.1 y en 19.3.1:
| Operación | key = índice | key = id |
|---|---|---|
| Añadir al principio | 1 inserción y 1000 cambios de texto | 1 inserción |
| Borrar la primera fila | 1 borrado y 999 cambios de texto | 1 borrado |
| Invertir la lista | 1000 cambios de texto | 999 movimientos de nodo |
Las dos primeras filas son claras: con el índice, React reescribe toda la lista para hacer un cambio mínimo. La tercera muestra que no todo es tan sencillo: al invertir, el id no hace menos operaciones. Pero aun así es lo correcto, porque cada nodo se mueve con su estado, su foco y su contenido, y con el índice se quedarían todos en su sitio.
Qué usar como key
| Fuente | ¿Buena? | Comentario |
|---|---|---|
Id de la base de datos (empId, pedidoId) | ✅ La mejor | Estable y única |
Clave natural única (email, nif) | ✅ | Si realmente no se repite ni cambia |
crypto.randomUUID() al crear el dato | ✅ | Para datos creados en el cliente, guardando el id en el dato |
| Contador incrementado al crear el dato | ✅ | Como en el ejemplo anterior |
Índice de map | ⚠️ | Solo en las condiciones de más abajo |
Math.random() o crypto.randomUUID() en el map | ❌ | Cambia en cada render: destruye y recrea todas las filas |
| Texto mostrado, si puede repetirse | ❌ | Dos empleados pueden llamarse igual |
⚠️ Importante: Genera la
keycuando se crea el dato, no cuando se pinta.key={crypto.randomUUID()}en el JSX equivale a decirle a React que todas las filas son nuevas en cada render, así que perderán el estado y el foco en cada pulsación. Recuerda además quecrypto.randomUUID()solo existe en contextos seguros, es decir, HTTPS olocalhost. Si abres la aplicación porhttp://desde otro equipo de la red local, no estará disponible.
¿Cuándo es aceptable el índice? Solo si se cumplen las tres condiciones a la vez:
- La lista es estática o solo crece por el final.
- Nunca se reordena ni se filtra.
- Las filas no tienen estado propio, ni campos, ni foco.
Por ejemplo, los números del paginador o las opciones fijas de un <select>. Pero piensa en las condiciones con vistas al futuro: lo que hoy es estático, mañana tiene un botón de ordenar. Usar el id no cuesta nada.
Reglas de key
- Va en el elemento más externo que devuelve
map. Si está en el<li>interior deFilaDepartamento, React no la ve donde la necesita, y seguirá avisando. - Solo tiene que ser única entre hermanos. Dos listas distintas pueden repetir valores, como hacían las dos tarjetas del ejemplo.
- No es una prop.
props.keydaundefinedy un aviso. Si el hijo necesita el valor, pásalo con otro nombre. En nuestro ejemplo,FilaTarearecibetareaentera. - Se convierte a cadena.
key={1}ykey="1"son la misma clave, y React avisa si coinciden en un mismo padre. - Si necesitas varios elementos por dato, usa
Fragment. La forma corta<>no admite atributos. Lo verás en la sección 10.
Si dos hermanos tienen la misma key, React avisa así, y lo que pase después no está definido:
Encountered two children with the same key, `x`. Keys should be unique so that components maintain their
identity across updates. Non-unique keys may cause children to be duplicated and/or omitted — the behavior
is unsupported and could change in a future version.
💡 Consejo: Estos avisos solo salen en desarrollo: en la versión de producción, una
keyincorrecta falla en silencio. Por eso conviene que los detecte también el linter. Hemos comprobado queoxlint, el de la plantilla, marca por defecto lakeyausente con la reglareact/jsx-key. El uso del índice, en cambio, no se detecta salvo que lo pidas:npx oxlint --react-plugin -D react/no-array-index-key src.
El módulo 05 mostró el otro uso de key, que parte de la misma idea: como key define la identidad, cambiarla a propósito reinicia un componente.
6. Filtrar
Filtrar es aplicar filter antes de map. Lo importante no es esa llamada, sino dónde vive el resultado.
import { useState } from 'react';
import { departamentos, empleados } from './datos';
// Quita tildes y mayúsculas: "alvaro" encuentra "Álvaro"
function normalizar(texto: string) {
return texto
.normalize('NFD')
.replace(/\p{Diacritic}/gu, '')
.toLowerCase();
}
export default function Buscador() {
// Solo se guarda lo que decide el usuario
const [texto, setTexto] = useState('');
const [deptId, setDeptId] = useState('todos');
const [soloComision, setSoloComision] = useState(false);
// La lista filtrada se calcula en cada render: es estado derivado
const buscado = normalizar(texto.trim());
const resultado = empleados.filter((e) => {
const coincideTexto = normalizar(`${e.nombre} ${e.apellidos} ${e.oficio}`).includes(buscado);
const coincideDepartamento = deptId === 'todos' || e.deptId === Number(deptId);
const coincideComision = !soloComision || e.comision !== null;
return coincideTexto && coincideDepartamento && coincideComision;
});
return (
<section className="tarjeta">
<h2>Buscar empleados</h2>
<div className="fila">
<input
type="search"
aria-label="Buscar por nombre u oficio"
placeholder="Nombre u oficio..."
value={texto}
onChange={(e) => setTexto(e.target.value)}
/>
<select aria-label="Departamento" value={deptId} onChange={(e) => setDeptId(e.target.value)}>
<option value="todos">Todos los departamentos</option>
{departamentos.map((d) => (
<option key={d.deptId} value={d.deptId}>
{d.nombre}
</option>
))}
</select>
<label>
<input
type="checkbox"
checked={soloComision}
onChange={(e) => setSoloComision(e.target.checked)}
/>{' '}
Con comisión
</label>
</div>
<p className="tenue" role="status">
{resultado.length} de {empleados.length} empleados
</p>
{resultado.length === 0 ? (
<p className="aviso">Ningún empleado coincide con la búsqueda.</p>
) : (
<ul className="lista">
{resultado.map((e) => (
<li key={e.empId}>
<span>
{e.nombre} {e.apellidos}
</span>
<span className="insignia">{e.oficio}</span>
</li>
))}
</ul>
)}
</section>
);
}

Figura 10. La búsqueda ignora tildes y mayúsculas.
La lista filtrada no es estado
El error más frecuente aquí es guardar la lista filtrada en otro useState:
// ❌ MAL: dos fuentes de verdad
const [texto, setTexto] = useState('');
const [filtrados, setFiltrados] = useState(empleados);
function cambiarTexto(valor: string) {
setTexto(valor);
setFiltrados(empleados.filter((e) => e.nombre.includes(valor)));
}
Parece razonable, pero filtrados no es información nueva: se puede calcular a partir de texto y empleados. Si guardas el dato dos veces, tienes que mantener ambas copias sincronizadas a mano. Cuando añadas el select de departamento, tendrás que recordar filtrar también desde su manejador, y con los dos filtros combinados. Es el estado derivado del módulo 04: guarda lo mínimo y calcula el resto en cada render.
¿Y el rendimiento? Filtrar unas decenas o miles de elementos en memoria es casi instantáneo, así que no optimices sin haber medido. Si en algún momento hay un problema real, el módulo 14 explica useMemo y la virtualización, y el módulo 15 enseña useDeferredValue para que el campo siga fluido con listas muy grandes.
Buscar en español
includes distingue mayúsculas y tildes, y el usuario teclea “alvaro” esperando encontrar a “Álvaro”. La función normalizar resuelve las dos cosas:
normalize('NFD')descompone cada letra acentuada en la letra base más su marca:Ápasa a serAseguida de una tilde combinable.replace(/\p{Diacritic}/gu, '')elimina esas marcas. La banderauactiva las clases Unicode como\p{...}.toLowerCase()iguala las mayúsculas.
El resultado real es alvaro para Álvaro y pena para Peña. Observa este último caso: la ñ también se descompone en n más una virgulilla. Para un buscador es lo que suele querer el usuario, porque quien teclea “pena” encontrará a “Peña”. Eso sí, en español n y ñ son letras distintas, de modo que esta función sirve para buscar, pero no para ordenar.
Combinar filtros
Cada filtro es una condición independiente que se puede activar o no. El patrón es siempre el mismo: “si el filtro está inactivo, déjalo pasar”.
const coincideDepartamento = deptId === 'todos' || e.deptId === Number(deptId);
const coincideComision = !soloComision || e.comision !== null;
Fíjate en que value de un <select> siempre es una cadena. Por eso deptId se guarda como texto y se convierte con Number al comparar. Con === entre '20' y 20 no coincidiría nunca.
El <p role="status"> anuncia a los lectores de pantalla los cambios en el número de resultados sin mover el foco. El módulo 24 trata estos atributos.
7. Ordenar
sort muta: usa toSorted
Array.prototype.sort ordena sobre el propio array y lo devuelve. En React esto es un problema grave:
// ❌ MAL: reordena el array original durante el render
const filas = empleados.sort(comparar);
// ✅ BIEN: toSorted devuelve una copia ordenada (ES2023)
const filas = empleados.toSorted(comparar);
// ✅ También válido: copiar y luego ordenar
const filas2 = [...empleados].sort(comparar);
Si empleados es estado o una prop, sort muta datos que no te pertenecen, y si es un dato compartido, cambia para todos los que lo usen. Un render debe ser puro, como vimos en el módulo 03. toSorted, toReversed, toSpliced y with son las versiones de ES2023 que no mutan. La plantilla de Vite las admite sin configuración, porque usa lib: ES2023.
Por qué el orden por defecto no sirve
Sin función de comparación, JavaScript convierte todo a cadena y compara códigos Unicode. Esta es la salida real en Node 24:
[100, 20, 3].toSorted() -> [ 100, 20, 3 ]
[100, 20, 3].toSorted((a, b) => a - b) -> [ 3, 20, 100 ]
['Sala 10', 'Sala 2', 'Sala 1'].toSorted() -> [ 'Sala 1', 'Sala 10', 'Sala 2' ]
Con los nombres en español, el resultado es peor todavía:
Por defecto: Nuria, Pablo, Zoe, ana, Álvaro, Ñuño, Óscar
localeCompare: Álvaro, ana, Nuria, Ñuño, Óscar, Pablo, Zoe
Por defecto, las mayúsculas van antes que las minúsculas y las letras con tilde al final de todo. Las herramientas correctas son localeCompare e Intl.Collator:
// Con localeCompare, una llamada para cada comparación
const a = nombres.toSorted((x, y) => x.localeCompare(y, 'es'));
// Con Intl.Collator, la configuración se crea una vez y se reutiliza
const colacion = new Intl.Collator('es');
const b = nombres.toSorted(colacion.compare);
// numeric: true entiende los números que hay dentro del texto
const salas = new Intl.Collator('es', { numeric: true });
['Sala 10', 'Sala 2', 'Sala 1'].toSorted(salas.compare); // Sala 1, Sala 2, Sala 10
Ambas dan el mismo resultado, con la ñ entre la n y la o. En los ejemplos usamos Intl.Collator para declarar la configuración en un solo sitio.
Ordenar con null
Si una columna admite null, hay que decidir dónde van esos valores. Lo normal es dejarlos siempre al final, ordenes como ordenes. Por eso la comprobación va antes de aplicar la dirección: en comparar, los return de los null no se invierten.
Un detalle útil: el ordenamiento de JavaScript es estable. Si dos elementos empatan, conservan su orden relativo. Rosa y Manuel cobran lo mismo, y al ordenar por salario seguirán en el mismo orden que antes.
8. Tablas
Una tabla es una lista con más estructura, y tiene sus propias exigencias.
import { useState } from 'react';
import { empleados, type Empleado } from './datos';
type Campo = 'apellidos' | 'oficio' | 'salario' | 'comision';
type Direccion = 'asc' | 'desc';
interface Orden {
campo: Campo;
direccion: Direccion;
}
const COLUMNAS: { campo: Campo; titulo: string; numero: boolean }[] = [
{ campo: 'apellidos', titulo: 'Apellidos', numero: false },
{ campo: 'oficio', titulo: 'Oficio', numero: false },
{ campo: 'salario', titulo: 'Salario', numero: true },
{ campo: 'comision', titulo: 'Comisión', numero: true },
];
const colacion = new Intl.Collator('es');
function comparar(a: Empleado, b: Empleado, { campo, direccion }: Orden) {
const valorA = a[campo];
const valorB = b[campo];
// Los valores nulos van siempre al final, sea cual sea la dirección
if (valorA === null && valorB === null) return 0;
if (valorA === null) return 1;
if (valorB === null) return -1;
const resultado =
typeof valorA === 'number' && typeof valorB === 'number'
? valorA - valorB
: colacion.compare(String(valorA), String(valorB));
return direccion === 'asc' ? resultado : -resultado;
}
export default function TablaOrdenable() {
const [orden, setOrden] = useState<Orden>({ campo: 'apellidos', direccion: 'asc' });
// toSorted devuelve una copia: el array original no se toca
const filas = empleados.toSorted((a, b) => comparar(a, b, orden));
function ordenarPor(campo: Campo) {
setOrden((actual) =>
actual.campo === campo
? { campo, direccion: actual.direccion === 'asc' ? 'desc' : 'asc' }
: { campo, direccion: 'asc' },
);
}
return (
<section className="tarjeta">
<table className="tabla">
<caption>Plantilla. Pulsa una cabecera para ordenar.</caption>
<thead>
<tr>
{COLUMNAS.map((columna) => {
const activa = orden.campo === columna.campo;
const sentido = orden.direccion === 'asc' ? 'ascending' : 'descending';
return (
<th
key={columna.campo}
scope="col"
className={columna.numero ? 'numero' : undefined}
aria-sort={activa ? sentido : 'none'}
>
<button className="cabecera-orden" onClick={() => ordenarPor(columna.campo)}>
{columna.titulo} {activa && (orden.direccion === 'asc' ? '▲' : '▼')}
</button>
</th>
);
})}
</tr>
</thead>
<tbody>
{filas.map((e) => (
<tr key={e.empId}>
<td>
{e.apellidos}, {e.nombre}
</td>
<td>{e.oficio}</td>
<td className="numero">{e.salario.toLocaleString('es-ES')} €</td>
<td className="numero">{e.comision === null ? '—' : `${e.comision} €`}</td>
</tr>
))}
</tbody>
</table>
</section>
);
}

Figura 11. Orden descendente por comisión: el 0 de Sergio es un valor y va antes que los null.
Las columnas también son una lista
COLUMNAS describe las columnas como datos, y el <thead> se genera con map. Una constante fuera del componente no se recrea en cada render, y añadir una columna es añadir una línea. La key es el campo, que es único y estable.
El estado guarda solo la decisión del usuario: { campo, direccion }. La lista ordenada se calcula. ordenarPor usa la función actualizadora del módulo 04 porque el valor nuevo depende del anterior.
HTML válido
El navegador tiene reglas estrictas para las tablas. Si pones <tr> directamente en <table>, React 19.3.1 avisa así:
In HTML, <tr> cannot be a child of <table>. Add a <tbody>, <thead> or <tfoot> to your code to match
the DOM tree generated by the browser.
This will cause a hydration error.
En React 18.3 el aviso existe con otro texto (validateDOMNesting). Cuando el navegador lee un HTML con <tr> sin <tbody>, añade el <tbody> por su cuenta. Entonces el DOM y lo que React espera dejan de coincidir, y eso importa cuando el HTML viene del servidor, como verás en el módulo 23.
Si un componente devuelve varias celdas, usa un fragmento: un <div> entre <tr> y <td> sería inválido.
function CeldasSalario({ empleado }: { empleado: Empleado }) {
return (
<>
<td>{empleado.salario} €</td>
<td>{empleado.comision ?? 0} €</td>
</>
);
}
Accesibilidad
| Elemento | Para qué sirve |
|---|---|
<caption> | Da nombre a la tabla |
<th scope="col"> | Asocia cada cabecera con su columna; el lector de pantalla la lee junto a cada celda |
aria-sort | Indica la columna ordenada y su sentido, cosa que ▲ no comunica por sí solo |
<button> dentro de <th> | Hace la cabecera accesible por teclado |
No pongas onClick en el <th>: un <th> no recibe foco y no se puede activar con el teclado. El botón sí, y con CSS puede parecer texto normal. La clase cabecera-orden le quita el aspecto de botón y conserva un contorno de foco visible.
💡 Consejo: Observa los importes:
toLocaleString('es-ES')da2100y no2.100. No es un error. En español, los datos de localización deIntlsolo agrupan los miles a partir de cinco cifras, mientras que18800sí se escribe18.800.
9. Listas vacías, carga y errores
Lo habitual es diseñar pensando en la lista llena. Pero un usuario nuevo verá primero una lista vacía, y antes de eso cualquier lista remota pasa por cargar y puede fallar.
| Situación | Qué ha pasado | Qué debe ver el usuario |
|---|---|---|
| Carga inicial | Los datos no han llegado | Indicador, y el botón desactivado |
| Error | La petición ha fallado | Qué ha pasado y un botón para reintentar |
| Vacía | No hay ningún dato | Explicación y una acción, por ejemplo “Crear el primer empleado” |
| Sin resultados | Hay datos, pero el filtro los descarta todos | Aviso y una forma de quitar los filtros |
Un mensaje en blanco es la peor opción, porque el usuario no sabe si la aplicación se ha quedado colgada o si realmente no hay nada. Distinguir “vacía” de “sin resultados” también importa: decir “No hay empleados” cuando el filtro los oculta hace pensar que se han borrado.
Figura 12. Los estados por los que pasa una consulta.
La forma natural de modelarlo es una unión discriminada: un solo estado con un campo fase que indica la situación. Así, un estado imposible como “cargando y con error a la vez” ni siquiera se puede escribir. Es el diseño del módulo 04 aplicado a una lista real. Este ejemplo consulta la API al enviar el formulario:
import { useState } from 'react';
const API = 'http://localhost:3000/api';
interface EmpleadoApi {
empId: number;
nombre: string;
apellidos: string;
oficio: string;
}
// Unión discriminada: cada situación lleva solo los datos que tienen sentido
type Consulta =
| { fase: 'inicial' }
| { fase: 'cargando' }
| { fase: 'error'; mensaje: string }
| { fase: 'lista'; empleados: EmpleadoApi[]; total: number };
export default function EstadosLista() {
const [texto, setTexto] = useState('');
const [consulta, setConsulta] = useState<Consulta>({ fase: 'inicial' });
async function buscar() {
setConsulta({ fase: 'cargando' });
try {
const parametros = new URLSearchParams({ q: texto, tamano: '5' });
const respuesta = await fetch(`${API}/empleados?${parametros}`);
if (!respuesta.ok) throw new Error(`La API respondió ${respuesta.status}`);
const datos = await respuesta.json();
setConsulta({ fase: 'lista', empleados: datos.contenido, total: datos.total });
} catch (error) {
const mensaje = error instanceof Error ? error.message : 'Error desconocido';
setConsulta({ fase: 'error', mensaje });
}
}
return (
<section className="tarjeta">
<h2>Empleados en la API</h2>
<form
className="fila"
onSubmit={(e) => {
e.preventDefault();
void buscar();
}}
>
<input
aria-label="Texto de búsqueda"
placeholder="Nombre, oficio..."
value={texto}
onChange={(e) => setTexto(e.target.value)}
/>
<button disabled={consulta.fase === 'cargando'}>Buscar</button>
</form>
<Resultado consulta={consulta} alReintentar={buscar} />
</section>
);
}
interface ResultadoProps {
consulta: Consulta;
alReintentar: () => void;
}
function Resultado({ consulta, alReintentar }: ResultadoProps) {
switch (consulta.fase) {
case 'inicial':
return <p className="tenue">Escribe algo y pulsa Buscar.</p>;
case 'cargando':
return <p role="status">Cargando empleados...</p>;
case 'error':
return (
<div className="aviso" role="alert">
<p>No se ha podido cargar la lista: {consulta.mensaje}</p>
<button onClick={alReintentar}>Reintentar</button>
</div>
);
case 'lista':
if (consulta.empleados.length === 0) {
return <p className="aviso">No hay empleados que coincidan con la búsqueda.</p>;
}
return (
<>
<p className="tenue" role="status">
Mostrando {consulta.empleados.length} de {consulta.total}
</p>
<ul className="lista">
{consulta.empleados.map((e) => (
<li key={e.empId}>
<span>
{e.nombre} {e.apellidos}
</span>
<span className="insignia">{e.oficio}</span>
</li>
))}
</ul>
</>
);
}
}

Figura 13. Lista con datos: ?q=programador devuelve tres empleados.

Figura 14. La respuesta correcta sin filas también es un estado que hay que pintar.

Figura 15. Con la API detenida, Chromium rechaza la promesa con Failed to fetch.
Detalles que conviene observar:
switchy estrechamiento. Dentro decase 'error', TypeScript sabe que existeconsulta.mensaje. Dentro decase 'lista', sabe que existenempleadosytotal. Fuera de esos casos, ni siquiera puedes tocar esos campos.fetchno falla por un 404 o un 500. Solo rechaza la promesa si no hay respuesta. Por eso se compruebarespuesta.ok.role="alert"hace que los lectores de pantalla anuncien el error en cuanto aparece.- Se muestra “3 de 3”. La API devuelve páginas, y
totalindica cuántos resultados hay en total. Es lo que permite decir “Mostrando 5 de 12”.
⚠️ Importante: Aquí pedimos los datos desde un evento, porque es lo que conoces hasta ahora. Pedirlos al cargar el componente exige
useEffect(módulo 09), donde verás también las condiciones de carrera: si salen dos peticiones, la más antigua podría llegar la última. Desactivar el botón evita las más obvias, pero no es una solución general. En una aplicación real, usarás TanStack Query (módulo 19), que gestiona todo esto por ti.
10. Agrupar
Agrupar es pasar de una lista plana a una lista de grupos. Para ello hacen falta dos map anidados: uno para los grupos y otro para los elementos.
Object.groupBy y Map.groupBy
ES2024 añadió dos funciones. Reciben la lista y una función que devuelve la clave de cada elemento:
const datos = [{ n: 'Ana', d: null }, { n: 'Luis', d: 10 }, { n: 'Rosa', d: 10 }];
Object.groupBy(datos, (e) => e.d);
Map.groupBy(datos, (e) => e.d);
La salida real muestra la diferencia entre ambas:
[Object: null prototype] {
'10': [ { n: 'Luis', d: 10 }, { n: 'Rosa', d: 10 } ],
null: [ { n: 'Ana', d: null } ]
}
Map(2) {
null => [ { n: 'Ana', d: null } ],
10 => [ { n: 'Luis', d: 10 }, { n: 'Rosa', d: 10 } ]
}
Object.groupBy | Map.groupBy | |
|---|---|---|
| Claves | Se convierten a cadena: 10 pasa a '10' y null a 'null' | Conservan su tipo |
| Lectura | grupos[clave], que puede ser undefined | grupos.get(clave), que puede ser undefined |
| Recorrer | Object.keys, Object.entries | for...of, [...grupos] |
| Orden | Las claves numéricas salen primero, de menor a mayor | Orden de primera aparición |
Usa Object.groupBy si las claves son textos, y Map.groupBy si son números o pueden ser null.
⚠️ Importante: La plantilla de Vite trae
"lib": ["ES2023", "DOM"]entsconfig.app.json, así que estas dos funciones no compilan:
error TS2550: Property 'groupBy' does not exist on type 'MapConstructor'. Do you need to change your target library? Try changing the 'lib' compiler option to 'es2024' or later.Cambia esa línea por
"lib": ["ES2024", "DOM"]. Este ajuste solo le dice a TypeScript qué existe: no añade código para navegadores antiguos. Si debes dar soporte a navegadores anteriores a ES2024, usa la funciónagruparde abajo.
Si no puedes usar ES2024, escribir tu propia versión son pocas líneas:
function agrupar<T, K>(lista: T[], claveDe: (elemento: T) => K) {
const grupos = new Map<K, T[]>();
for (const elemento of lista) {
const clave = claveDe(elemento);
const grupo = grupos.get(clave);
if (grupo) grupo.push(elemento);
else grupos.set(clave, [elemento]);
}
return grupos;
}
Tabla agrupada
Agrupar directamente los datos tiene un inconveniente: un grupo sin elementos no existe, y por eso groupBy nunca devolverá INVESTIGACIÓN. Si quieres mostrar los grupos vacíos, parte de la lista de departamentos y busca sus miembros:
import { departamentos, empleados, type Empleado } from './datos';
interface Grupo {
clave: string;
titulo: string;
miembros: Empleado[];
}
const moneda = new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 });
function agruparPorDepartamento(lista: Empleado[]): Grupo[] {
const porDepartamento = Map.groupBy(lista, (e) => e.deptId);
// Partimos de los departamentos para que salgan también los vacíos
const grupos: Grupo[] = departamentos.map((d) => ({
clave: String(d.deptId),
titulo: d.nombre,
miembros: porDepartamento.get(d.deptId) ?? [],
}));
const sinAsignar = porDepartamento.get(null);
if (sinAsignar) {
grupos.push({ clave: 'sin-asignar', titulo: 'SIN DEPARTAMENTO', miembros: sinAsignar });
}
return grupos;
}
export default function Agrupados() {
const grupos = agruparPorDepartamento(empleados);
return (
<section className="tarjeta">
<table className="tabla">
<caption>Plantilla por departamento</caption>
<thead>
<tr>
<th scope="col">Empleado</th>
<th scope="col">Oficio</th>
<th scope="col" className="numero">
Salario
</th>
</tr>
</thead>
{/* Una tabla puede tener varios tbody: uno por grupo */}
{grupos.map((grupo) => {
const masa = grupo.miembros.reduce((suma, e) => suma + e.salario, 0);
return (
<tbody key={grupo.clave}>
<tr className="grupo">
<th scope="colgroup" colSpan={2}>
{grupo.titulo} ({grupo.miembros.length})
</th>
<th className="numero">{moneda.format(masa)}</th>
</tr>
{grupo.miembros.length === 0 ? (
<tr>
<td colSpan={3} className="tenue">
Este departamento no tiene personal.
</td>
</tr>
) : (
grupo.miembros.map((e) => (
<tr key={e.empId}>
<td>
{e.nombre} {e.apellidos}
</td>
<td>{e.oficio}</td>
<td className="numero">{moneda.format(e.salario)}</td>
</tr>
))
)}
</tbody>
);
})}
</table>
</section>
);
}

Figura 16. Los dos casos especiales: un grupo vacío y un grupo creado para los datos sin clave.
Una tabla puede tener varios <tbody>, y usar uno por grupo es válido y semántico. Por eso key va en <tbody>. Las key internas solo tienen que ser únicas dentro de cada <tbody>.
La clave de SIN DEPARTAMENTO es la cadena 'sin-asignar', que no choca con los ids de departamento porque se convierten a cadenas como '10'. El total del grupo se calcula con reduce dentro del map: es otro valor derivado.
Varios elementos por dato: Fragment con key
A veces, cada dato produce varios elementos hermanos sin un contenedor. Un <dl> es el caso típico: <dt> y <dd> deben ser hijos directos. Si los envolvieras en un <div> por dato, la estructura sería otra, y <> no sirve porque no admite key:
import { Fragment } from 'react';
import { empleados } from './datos';
export default function ListaOficios() {
const porOficio = Object.groupBy(empleados, (e) => e.oficio);
const oficios = Object.keys(porOficio).toSorted((a, b) => a.localeCompare(b, 'es'));
return (
<section className="tarjeta">
<h2>Oficios</h2>
<dl>
{oficios.map((oficio) => (
// <></> no admite key: aquí hace falta Fragment
<Fragment key={oficio}>
<dt>
<b>{oficio}</b>
</dt>
<dd>{porOficio[oficio]?.map((e) => e.nombre).join(', ')}</dd>
</Fragment>
))}
</dl>
</section>
);
}
El ?. hace falta porque TypeScript declara el resultado de Object.groupBy con valores opcionales: no puede saber que oficio procede de sus propias claves.
11. Paginación en el cliente
Paginar en el cliente es mostrar una parte de un array que ya tienes en memoria. La operación es un simple slice:
import { useState } from 'react';
import { empleados } from './datos';
function paginar<T>(lista: T[], pagina: number, tamano: number) {
const inicio = (pagina - 1) * tamano;
return lista.slice(inicio, inicio + tamano);
}
interface PaginadorProps {
pagina: number;
totalPaginas: number;
alCambiar: (pagina: number) => void;
}
function Paginador({ pagina, totalPaginas, alCambiar }: PaginadorProps) {
// Array.from con longitud crea [1, 2, ..., totalPaginas]
const numeros = Array.from({ length: totalPaginas }, (_, i) => i + 1);
return (
<nav className="paginador" aria-label="Paginación">
<button disabled={pagina === 1} onClick={() => alCambiar(pagina - 1)}>
Anterior
</button>
{numeros.map((numero) => (
<button
key={numero}
aria-current={numero === pagina ? 'page' : undefined}
onClick={() => alCambiar(numero)}
>
{numero}
</button>
))}
<button disabled={pagina === totalPaginas} onClick={() => alCambiar(pagina + 1)}>
Siguiente
</button>
</nav>
);
}
export default function Paginacion() {
const [pagina, setPagina] = useState(1);
const [tamano, setTamano] = useState(5);
const totalPaginas = Math.ceil(empleados.length / tamano);
const visibles = paginar(empleados, pagina, tamano);
const primero = (pagina - 1) * tamano + 1;
const ultimo = primero + visibles.length - 1;
return (
<section className="tarjeta">
<h2>Plantilla</h2>
<div className="fila">
<label>
Filas por página{' '}
<select
value={tamano}
onChange={(e) => {
setTamano(Number(e.target.value));
setPagina(1); // Con otro tamaño, la página antigua ya no significa nada
}}
>
{[5, 10, 20].map((n) => (
<option key={n}>{n}</option>
))}
</select>
</label>
<span className="tenue" role="status">
{primero}-{ultimo} de {empleados.length}
</span>
</div>
<ul className="lista">
{visibles.map((e) => (
<li key={e.empId}>
<span>
{e.nombre} {e.apellidos}
</span>
<span className="insignia">{e.oficio}</span>
</li>
))}
</ul>
<Paginador pagina={pagina} totalPaginas={totalPaginas} alCambiar={setPagina} />
</section>
);
}

Figura 17. Página 2 de 5, con la posición indicada en el texto.
Las cuentas
Con tamano = 5 y 24 empleados:
| Dato | Fórmula | Resultado en la página 2 |
|---|---|---|
| Total de páginas | Math.ceil(24 / 5) | 5 |
| Primer índice | (2 - 1) * 5 | 5 |
| Fin (sin incluir) | 5 + 5 | 10 |
| Texto para el usuario | primero = 5 + 1, ultimo = 6 + 5 - 1 | 6-10 |
La página se cuenta desde 1 porque así lo ve el usuario, mientras que slice trabaja desde 0: de ahí la resta. El -1 de ultimo se calcula con visibles.length, no con tamano, porque la última página puede ser más corta: 21-24. slice no falla si se pasa del final, sino que devuelve lo que haya.
Array.from({ length: n }, (_, i) => i + 1) es la forma idiomática de crear [1, 2, ..., n]. El guion bajo indica que el primer parámetro no se usa.
La página debe seguir teniendo sentido
Aquí hay una trampa. Si estás en la página 5 y cambias a 20 filas por página, el total pasa a 2 páginas, y la 5 ya no existe: slice devolvería una lista vacía aunque haya datos. Por eso el manejador del select hace setPagina(1). Lo mismo ocurre al filtrar. En la sección siguiente verás una segunda defensa.
¿Cliente o servidor?
| Filtrar y paginar en el cliente | Filtrar y paginar en el servidor | |
|---|---|---|
| Datos | Se descargan todos al principio | Se descarga una página cada vez |
| Respuesta | Inmediata | Depende de la red |
| Volumen | Decenas o pocos miles de registros | Sin límite práctico |
| Búsqueda | Sobre lo descargado | Sobre todo, con índices en la base de datos |
| Código | Solo React | React más parámetros en la URL |
⚠️ Importante: No mezcles los dos planos. Si pides al servidor 20 filas y filtras en el cliente, el filtro solo mira esas 20. Un usuario puede buscar a alguien que está en la página 3 y recibir “sin resultados”. Si los datos llegan paginados, los filtros también deben enviarse al servidor.
La API EMPRESA admite esto mismo con ?q=, ?orden=-salario, ?pagina=2 y ?tamano=5, y devuelve { contenido, pagina, tamano, total, paginas }. El ejercicio 11 la usa, y el módulo 19 lo resuelve con caché.
12. Caso práctico: panel de empleados
Vamos a juntarlo todo: búsqueda, filtro, orden, paginación, estado vacío y selección. La clave está en pensar el proceso como una tubería de cálculos:
Figura 18. Solo se guardan las decisiones del usuario. Todo lo demás se recalcula en cada render.
El orden de los pasos importa. Primero se filtra, porque no tiene sentido ordenar lo que vas a descartar. Después se ordena. Y al final se pagina: si paginaras antes de ordenar, solo ordenarías los elementos de la página actual.
import { useState } from 'react';
import { departamentos, empleados, type Empleado } from './datos';
// ---------- Lógica pura, sin nada de React ----------
type Campo = 'apellidos' | 'oficio' | 'salario' | 'fechaAlta';
type Direccion = 'asc' | 'desc';
interface Orden {
campo: Campo;
direccion: Direccion;
}
const POR_PAGINA = 6;
const colacion = new Intl.Collator('es');
function normalizar(texto: string) {
return texto
.normalize('NFD')
.replace(/\p{Diacritic}/gu, '')
.toLowerCase();
}
function filtrar(lista: Empleado[], texto: string, deptId: string) {
const buscado = normalizar(texto.trim());
return lista.filter(
(e) =>
normalizar(`${e.nombre} ${e.apellidos} ${e.oficio}`).includes(buscado) &&
(deptId === 'todos' || e.deptId === Number(deptId)),
);
}
function ordenar(lista: Empleado[], { campo, direccion }: Orden) {
const signo = direccion === 'asc' ? 1 : -1;
return lista.toSorted((a, b) => {
const valorA = a[campo];
const valorB = b[campo];
const resultado =
typeof valorA === 'number' && typeof valorB === 'number'
? valorA - valorB
: colacion.compare(String(valorA), String(valorB));
return resultado * signo;
});
}
function paginar<T>(lista: T[], pagina: number) {
const inicio = (pagina - 1) * POR_PAGINA;
return lista.slice(inicio, inicio + POR_PAGINA);
}
// ---------- Componentes de presentación ----------
interface CabeceraProps {
campo: Campo;
titulo: string;
orden: Orden;
alOrdenar: (campo: Campo) => void;
}
function Cabecera({ campo, titulo, orden, alOrdenar }: CabeceraProps) {
const activa = orden.campo === campo;
const sentido = orden.direccion === 'asc' ? 'ascending' : 'descending';
return (
<th scope="col" aria-sort={activa ? sentido : 'none'}>
<button className="cabecera-orden" onClick={() => alOrdenar(campo)}>
{titulo} {activa && (orden.direccion === 'asc' ? '▲' : '▼')}
</button>
</th>
);
}
interface PaginadorProps {
pagina: number;
totalPaginas: number;
alCambiar: (pagina: number) => void;
}
function Paginador({ pagina, totalPaginas, alCambiar }: PaginadorProps) {
const numeros = Array.from({ length: totalPaginas }, (_, i) => i + 1);
return (
<nav className="paginador" aria-label="Paginación">
<button disabled={pagina === 1} onClick={() => alCambiar(pagina - 1)}>
Anterior
</button>
{numeros.map((numero) => (
<button
key={numero}
aria-current={numero === pagina ? 'page' : undefined}
onClick={() => alCambiar(numero)}
>
{numero}
</button>
))}
<button disabled={pagina === totalPaginas} onClick={() => alCambiar(pagina + 1)}>
Siguiente
</button>
</nav>
);
}
// ---------- Componente principal ----------
export default function PanelEmpleados() {
// Estado: solo las decisiones del usuario
const [texto, setTexto] = useState('');
const [deptId, setDeptId] = useState('todos');
const [orden, setOrden] = useState<Orden>({ campo: 'apellidos', direccion: 'asc' });
const [pagina, setPagina] = useState(1);
const [seleccionadoId, setSeleccionadoId] = useState<number | null>(null);
// Tubería: filtrar, ordenar y paginar. Todo es estado derivado
const filtrados = filtrar(empleados, texto, deptId);
const ordenados = ordenar(filtrados, orden);
const totalPaginas = Math.max(1, Math.ceil(ordenados.length / POR_PAGINA));
const paginaVisible = Math.min(pagina, totalPaginas);
const visibles = paginar(ordenados, paginaVisible);
// Se busca por id: el detalle no depende del orden ni de la página
const seleccionado = empleados.find((e) => e.empId === seleccionadoId);
function ordenarPor(campo: Campo) {
setOrden((actual) =>
actual.campo === campo
? { campo, direccion: actual.direccion === 'asc' ? 'desc' : 'asc' }
: { campo, direccion: 'asc' },
);
setPagina(1);
}
return (
<section className="tarjeta">
<h2>Panel de empleados</h2>
<div className="fila">
<input
type="search"
aria-label="Buscar empleado"
placeholder="Nombre u oficio..."
value={texto}
onChange={(e) => {
setTexto(e.target.value);
setPagina(1);
}}
/>
<select
aria-label="Departamento"
value={deptId}
onChange={(e) => {
setDeptId(e.target.value);
setPagina(1);
}}
>
<option value="todos">Todos los departamentos</option>
{departamentos.map((d) => (
<option key={d.deptId} value={d.deptId}>
{d.nombre}
</option>
))}
</select>
<span className="tenue" role="status">
{ordenados.length} resultados
</span>
</div>
{ordenados.length === 0 ? (
<div className="aviso">
<p>Ningún empleado coincide con estos filtros.</p>
<button
onClick={() => {
setTexto('');
setDeptId('todos');
setPagina(1);
}}
>
Quitar filtros
</button>
</div>
) : (
<>
<table className="tabla">
<caption>Pulsa una fila para ver su detalle.</caption>
<thead>
<tr>
<Cabecera campo="apellidos" titulo="Apellidos" orden={orden} alOrdenar={ordenarPor} />
<Cabecera campo="oficio" titulo="Oficio" orden={orden} alOrdenar={ordenarPor} />
<Cabecera campo="salario" titulo="Salario" orden={orden} alOrdenar={ordenarPor} />
<Cabecera campo="fechaAlta" titulo="Alta" orden={orden} alOrdenar={ordenarPor} />
</tr>
</thead>
<tbody>
{visibles.map((e) => (
<tr
key={e.empId}
className={e.empId === seleccionadoId ? 'marcada' : undefined}
onClick={() => setSeleccionadoId(e.empId)}
>
<td>
{e.apellidos}, {e.nombre}
</td>
<td>{e.oficio}</td>
<td>{e.salario.toLocaleString('es-ES')} €</td>
<td>{e.fechaAlta}</td>
</tr>
))}
</tbody>
</table>
<Paginador pagina={paginaVisible} totalPaginas={totalPaginas} alCambiar={setPagina} />
</>
)}
{seleccionado && (
<p className="tenue">
Seleccionado: <b>{seleccionado.nombre} {seleccionado.apellidos}</b> (n.º {seleccionado.empId})
</p>
)}
</section>
);
}

Figura 19. El panel completo: orden, página 2 y selección.
Decisiones de diseño que merece la pena revisar:
- Cinco estados, y ninguna lista.
texto,deptId,orden,paginayseleccionadoIdson las decisiones del usuario.filtrados,ordenados,visiblesytotalPaginasse derivan. No hay que sincronizar nada. - La lógica está fuera del componente.
filtrar,ordenarypaginarson funciones puras: no dependen de React, se leen solas y son fáciles de probar. El módulo 22 lo aprovecha. - Se guarda el id seleccionado, no el índice ni el objeto. Un índice dejaría de apuntar a la misma persona al ordenar. Con el id, la selección sobrevive al cambio de página y a los filtros, y
seleccionadose deriva confind. paginaVisiblees la segunda defensa. Los manejadores vuelven a la página 1 al filtrar, pero si la lista encoge por otro motivo, por ejemplo al borrar un empleado,Math.min(pagina, totalPaginas)evita mostrar una página que no existe. Fíjate en que no corrige el estado: lo interpreta al pintar.Math.max(1, ...)garantiza que haya al menos una página con la lista vacía. Sin esto,paginaVisiblevaldría 0 yiniciosería negativo.- El estado vacío ofrece una salida. “Quitar filtros” resuelve la situación en un clic, mejor que un mensaje que obliga a buscar qué hay que cambiar.
⚠️ Importante: Un
<tr onClick>no se puede usar con el teclado, porque la fila no recibe el foco. Aquí lo hacemos para que el ejemplo sea breve, pero en una aplicación real añade en cada fila un botón “Ver” o un enlace con el nombre. El módulo 24 trata el tema a fondo.
💡 Consejo: Tal como está, el panel pierde el filtro y la página al recargar, y no se puede compartir un enlace a “la página 2 de VENTAS”. En una aplicación real, estos valores suelen guardarse en la URL, con
useSearchParams(módulo 18). Fíjate en lo poco que cambiaría: solo la fuente de esos estados, no la tubería.
13. Errores frecuentes y cómo depurarlos
| Síntoma | Causa probable | Solución |
|---|---|---|
Aparece un 0 suelto en la pantalla | numero && <Algo /> | Compara: numero > 0 && ... |
Aparece NaN en la pantalla | Cálculo sobre undefined, o Number('') mal tratado | Revisa el dato antes de pintarlo; NaN también se pinta |
Each child in a list should have a unique "key" prop | Falta key, o está en un elemento interior | Ponla en el elemento que devuelve map |
Encountered two children with the same key | Datos repetidos o clave poco única | Usa un id real; no uses el texto visible |
| Un campo muestra el dato de otra fila al reordenar | key por índice | Usa el id del dato |
| Las filas pierden el foco al escribir | key aleatoria, o componente definido dentro de otro | key estable; define los componentes fuera |
map no pinta nada | Función con llaves y sin return | Paréntesis, o añade return |
Objects are not valid as a React child | Has puesto un objeto o una fecha en el JSX | Pinta una propiedad o conviértelo a texto |
| La lista se reordena sola o los datos originales cambian | sort o reverse sobre el original | toSorted, toReversed |
| El filtro funciona, pero al cambiar los datos no se actualiza | Lista filtrada guardada en estado | Deriva la lista en cada render |
Álvaro sale después de Zoe | Orden por defecto | localeCompare o Intl.Collator |
| Lista vacía aunque hay datos | Página fuera de rango tras filtrar | setPagina(1) y Math.min |
Property 'groupBy' does not exist | lib de TypeScript en ES2023 | "lib": ["ES2024", "DOM"] |
<tr> cannot be a child of <table> | Falta <tbody> | Añádelo |
| Se ha borrado el texto de un panel al ocultarlo | && desmonta y destruye el estado | hidden, <Activity> 🆕 19.2, o eleva el estado |
Para depurar listas, React DevTools es la mejor herramienta: la pestaña Components muestra la key de cada componente y su estado. Si reordenas la lista y el estado no acompaña a su dato, ya sabes qué mirar. Instálala siguiendo el módulo 01.
14. Ejercicios
Salvo que se indique otra cosa, cada ejercicio usa el proyecto de la sección 1 y sustituye a src/App.tsx.
Ejercicio 1: Semáforo de stock
Crea una lista de productos con una insignia que indique su situación. Los productos tienen productoId, nombre, stock y descatalogado. Reglas: si está descatalogado, “Descatalogado” (gris), aunque tenga existencias; con stock 0, “Agotado” (rojo); con menos de 5, “Quedan N” (naranja); y si no, “Disponible” (verde).
Ver solución
interface Producto {
productoId: number;
nombre: string;
stock: number;
descatalogado: boolean;
}
const productos: Producto[] = [
{ productoId: 1, nombre: 'Portátil UltraBook 14', stock: 25, descatalogado: false },
{ productoId: 5, nombre: 'Torre Workstation', stock: 4, descatalogado: false },
{ productoId: 7, nombre: 'Teclado mecánico', stock: 0, descatalogado: false },
{ productoId: 9, nombre: 'Monitor 19 pulgadas', stock: 12, descatalogado: true },
];
function InsigniaStock({ producto }: { producto: Producto }) {
// El orden importa: el primer if que se cumple decide
if (producto.descatalogado) {
return <span className="insignia">Descatalogado</span>;
}
if (producto.stock === 0) {
return <span className="insignia peligro">Agotado</span>;
}
if (producto.stock < 5) {
return <span className="insignia alerta">Quedan {producto.stock}</span>;
}
return <span className="insignia exito">Disponible</span>;
}
export default function SemaforoStock() {
return (
<section className="tarjeta">
<h2>Inventario</h2>
<ul className="lista">
{productos.map((producto) => (
<li key={producto.productoId}>
<span>{producto.nombre}</span>
<InsigniaStock producto={producto} />
</li>
))}
</ul>
</section>
);
}
Las salidas tempranas evitan anidar ternarios, y su orden es parte de la lógica: si el if del descatalogado fuese el último, un producto retirado con stock 0 saldría como “Agotado”. Las condiciones se leen de la más específica a la más general.
Ejercicio 2: Avisos sin ceros
Este componente muestra un 0 cuando no quedan avisos. Encuentra el error y corrígelo. Además, debe mostrar “Todo al día.” cuando no haya avisos.
{avisos.length && <p>Tienes {avisos.length} avisos pendientes.</p>}
{avisos.length && <ul>{avisos.map((aviso) => <li>{aviso}</li>)}</ul>}
Ver solución
import { useState } from 'react';
const iniciales = ['Revisar nóminas', 'Renovar contrato de limpieza'];
export default function Avisos() {
const [avisos, setAvisos] = useState(iniciales);
return (
<section className="tarjeta">
<h2>Avisos</h2>
{avisos.length > 0 && <p>Tienes {avisos.length} avisos pendientes.</p>}
{avisos.length > 0 ? (
<ul>
{avisos.map((aviso) => (
<li key={aviso}>{aviso}</li>
))}
</ul>
) : (
<p className="tenue">Todo al día.</p>
)}
<button onClick={() => setAvisos([])}>Marcar todo como leído</button>
</section>
);
}
Había tres problemas. El primero: avisos.length vale 0, y && lo devuelve, de modo que se pinta. Se resuelve con una comparación. El segundo: la <ul> no tenía key en sus <li>. El tercero: hacía falta una alternativa, así que el segundo && pasa a ser un ternario. Aquí usamos el propio texto como key, porque estos avisos son únicos y la lista no se reordena; con datos reales usarías un id.
Ejercicio 3: Lista de tareas con identidad
Crea una lista de tareas en la que cada fila tenga una casilla “hecha” con estado local y un botón Borrar. Añade un formulario para crear tareas. Comprueba que, si marcas la segunda tarea y borras la primera, la casilla sigue marcada en la tarea correcta. Después cambia la key por el índice y observa qué pasa.
Ver solución
import { useState } from 'react';
interface Tarea {
id: string;
texto: string;
}
function FilaTarea({ tarea, alBorrar }: { tarea: Tarea; alBorrar: () => void }) {
const [hecha, setHecha] = useState(false);
return (
<li>
<label>
<input type="checkbox" checked={hecha} onChange={(e) => setHecha(e.target.checked)} />{' '}
{tarea.texto}
</label>
<button onClick={alBorrar}>Borrar</button>
</li>
);
}
export default function ListaTareas() {
const [texto, setTexto] = useState('');
// Inicialización perezosa: los id iniciales solo se generan una vez
const [tareas, setTareas] = useState<Tarea[]>(() => [
{ id: crypto.randomUUID(), texto: 'Preparar nóminas' },
{ id: crypto.randomUUID(), texto: 'Enviar facturas' },
{ id: crypto.randomUUID(), texto: 'Llamar a proveedores' },
]);
function anadir() {
if (texto.trim() === '') return;
// El id se genera una vez, al crear el dato, y ya nunca cambia
setTareas([...tareas, { id: crypto.randomUUID(), texto: texto.trim() }]);
setTexto('');
}
return (
<section className="tarjeta">
<h2>Tareas</h2>
<form
className="fila"
onSubmit={(e) => {
e.preventDefault();
anadir();
}}
>
<input aria-label="Nueva tarea" value={texto} onChange={(e) => setTexto(e.target.value)} />
<button>Añadir</button>
</form>
<ul className="lista">
{tareas.map((tarea) => (
<FilaTarea
key={tarea.id}
tarea={tarea}
alBorrar={() => setTareas(tareas.filter((t) => t.id !== tarea.id))}
/>
))}
</ul>
</section>
);
}
El id se crea junto con el dato y viaja con él. Si usas el índice como key y borras la primera, la tarea marcada pasa a ocupar la posición 0, pero el estado hecha = true se queda en la posición 1. Verás marcada la tarea que iba después.
La función flecha de useState es la inicialización perezosa del módulo 04. Sin ella, se generarían tres UUID en cada render para tirarlos. En este caso, el resultado sería el mismo, pero es un trabajo inútil.
Ejercicio 4: Rango salarial
Añade dos campos numéricos, “Mínimo” y “Máximo”, que filtren a los empleados por salario, y muestra el resultado de mayor a menor. Un campo vacío significa sin límite. Si el mínimo supera al máximo, muestra un error. Si nadie cumple el rango, muestra un mensaje.
Ver solución
import { useState } from 'react';
import { empleados } from './datos';
export default function RangoSalario() {
// Se guardan como texto: un campo vacío significa "sin límite"
const [minimo, setMinimo] = useState('');
const [maximo, setMaximo] = useState('');
const desde = minimo === '' ? -Infinity : Number(minimo);
const hasta = maximo === '' ? Infinity : Number(maximo);
const rangoInvalido = desde > hasta;
const resultado = rangoInvalido
? []
: empleados
.filter((e) => e.salario >= desde && e.salario <= hasta)
.toSorted((a, b) => b.salario - a.salario);
return (
<section className="tarjeta">
<h2>Salarios por rango</h2>
<div className="fila">
<label>
Mínimo{' '}
<input type="number" value={minimo} onChange={(e) => setMinimo(e.target.value)} />
</label>
<label>
Máximo{' '}
<input type="number" value={maximo} onChange={(e) => setMaximo(e.target.value)} />
</label>
</div>
{rangoInvalido ? (
<p className="aviso" role="alert">
El mínimo no puede ser mayor que el máximo.
</p>
) : resultado.length === 0 ? (
<p className="aviso">Nadie cobra ese salario.</p>
) : (
<ul className="lista">
{resultado.map((e) => (
<li key={e.empId}>
<span>{e.nombre}</span>
<span>{e.salario} €</span>
</li>
))}
</ul>
)}
</section>
);
}
Los campos se guardan como texto para distinguir “vacío” de “cero”: Number('') da 0, y un máximo vacío se convertiría en “cobra como mucho 0 euros”. -Infinity e Infinity hacen que el filtro no necesite casos especiales.
rangoInvalido y resultado se derivan. Un ternario de dos niveles se aparta de nuestra propia regla, pero con tres ramas tan cortas sigue siendo legible. Si creciera, se extraería a un componente con salidas tempranas.
Ejercicio 5: Filtro por chips
Muestra un botón por departamento. Cada uno se activa y desactiva por separado, y se pueden combinar varios. Sin ninguno activo, se ven todos los empleados. Añade un botón “Limpiar” que solo aparezca si hay alguno activo, e indica con aria-pressed si cada botón está pulsado.
Ver solución
import { useState } from 'react';
import { departamentos, empleados } from './datos';
export default function ChipsDepartamentos() {
const [elegidos, setElegidos] = useState<number[]>([]);
// Sin chips activos se muestran todos
const resultado =
elegidos.length === 0
? empleados
: empleados.filter((e) => e.deptId !== null && elegidos.includes(e.deptId));
function alternar(deptId: number) {
setElegidos((actuales) =>
actuales.includes(deptId) ? actuales.filter((id) => id !== deptId) : [...actuales, deptId],
);
}
return (
<section className="tarjeta">
<h2>Filtrar por departamentos</h2>
<div className="fila">
{departamentos.map((d) => (
<button
key={d.deptId}
className="chip"
aria-pressed={elegidos.includes(d.deptId)}
onClick={() => alternar(d.deptId)}
>
{d.nombre}
</button>
))}
{elegidos.length > 0 && <button onClick={() => setElegidos([])}>Limpiar</button>}
</div>
<p className="tenue" role="status">
{resultado.length} empleados
</p>
{resultado.length === 0 ? (
<p className="aviso">Los departamentos elegidos no tienen personal.</p>
) : (
<p>{resultado.map((e) => e.nombre).join(', ')}</p>
)}
</section>
);
}
El estado es un array de ids, y alternar añade o quita sin mutar. e.deptId !== null no es solo prudencia: includes espera un number, y esa comprobación estrecha el tipo para que compile. aria-pressed comunica el estado del botón, y además el CSS lo usa como selector, de modo que estilo y accesibilidad no pueden contradecirse.
Ejercicio 6: Subtotales
Crea una tabla con la retribución (salario más comisión) de cada empleado, agrupada por departamento. Tras cada grupo, añade una fila con el subtotal, y al final un <tfoot> con el total de la empresa. Usa un solo <tbody>. Comprueba que el total coincide con la suma de los 24 empleados: 69 500 €, incluyendo al becario sin departamento.
Ver solución
import { Fragment } from 'react';
import { departamentos, empleados, type Empleado } from './datos';
const euros = new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' });
function retribucion(e: Empleado) {
return e.salario + (e.comision ?? 0);
}
function total(lista: Empleado[]) {
return lista.reduce((suma, e) => suma + retribucion(e), 0);
}
export default function TablaTotales() {
const porDepartamento = Map.groupBy(empleados, (e) => e.deptId);
// Solo los departamentos con personal
const grupos = departamentos.flatMap((d) => {
const miembros = porDepartamento.get(d.deptId);
return miembros ? [{ departamento: d, miembros }] : [];
});
return (
<section className="tarjeta">
<table className="tabla">
<caption>Retribución mensual por departamento</caption>
<thead>
<tr>
<th scope="col">Empleado</th>
<th scope="col" className="numero">
Retribución
</th>
</tr>
</thead>
<tbody>
{grupos.map(({ departamento, miembros }) => (
<Fragment key={departamento.deptId}>
{miembros.map((e) => (
<tr key={e.empId}>
<td>
{e.nombre} {e.apellidos}
</td>
<td className="numero">{euros.format(retribucion(e))}</td>
</tr>
))}
<tr className="grupo">
<th scope="row">Subtotal {departamento.nombre}</th>
<th className="numero">{euros.format(total(miembros))}</th>
</tr>
</Fragment>
))}
</tbody>
<tfoot>
<tr>
<td>Total de la empresa</td>
<td className="numero">{euros.format(total(empleados))}</td>
</tr>
</tfoot>
</table>
</section>
);
}
Cada grupo produce varias filas hermanas dentro de un mismo <tbody>, así que hace falta Fragment con key. flatMap permite transformar y descartar a la vez: devuelve un array de un elemento si el departamento tiene personal, y un array vacío si no. El 0 de comision ?? 0 es correcto: aquí sí queremos tratar null como cero para sumar.
El total se calcula sobre empleados, no sumando subtotales, y por eso incluye a Tomás, que no aparece en ninguna fila. Es una decisión que conviene aclarar al usuario; prueba a añadir un grupo “Sin departamento” para que las cuentas cuadren a la vista.
Ejercicio 7: Paginador con ventana
Con 20 páginas no caben todos los botones. Escribe una función pura ventanaPaginas(pagina, total, margen = 1) que devuelva los números que hay que mostrar, con null en lugar de cada hueco:
ventanaPaginas(10, 20) -> [1, null, 9, 10, 11, null, 20]
ventanaPaginas(1, 20) -> [1, 2, null, 20]
ventanaPaginas(3, 20) -> [1, 2, 3, 4, null, 20]
Si el hueco es de una sola página, muestra esa página: no tiene sentido sustituir un botón por unos puntos suspensivos. Usa la función en un paginador.
Ver solución
import { useState } from 'react';
// Devuelve los números que se muestran; null representa los puntos suspensivos
export function ventanaPaginas(pagina: number, total: number, margen = 1): (number | null)[] {
const resultado: (number | null)[] = [];
let anterior = 0;
for (let n = 1; n <= total; n++) {
const extremo = n === 1 || n === total;
const cercana = Math.abs(n - pagina) <= margen;
if (extremo || cercana) {
if (n - anterior === 2) resultado.push(n - 1); // Hueco de una sola página: se muestra
else if (n - anterior > 2) resultado.push(null);
resultado.push(n);
anterior = n;
}
}
return resultado;
}
export default function PaginadorVentana() {
const [pagina, setPagina] = useState(10);
const total = 20;
return (
<section className="tarjeta">
<h2>Página {pagina} de {total}</h2>
<nav className="paginador" aria-label="Paginación">
<button disabled={pagina === 1} onClick={() => setPagina(pagina - 1)}>
Anterior
</button>
{ventanaPaginas(pagina, total).map((numero, posicion) =>
numero === null ? (
// Los huecos no tienen datos propios ni estado: aquí la posición es válida
<span key={`hueco-${posicion}`} aria-hidden="true">
…
</span>
) : (
<button
key={numero}
aria-current={numero === pagina ? 'page' : undefined}
onClick={() => setPagina(numero)}
>
{numero}
</button>
),
)}
<button disabled={pagina === total} onClick={() => setPagina(pagina + 1)}>
Siguiente
</button>
</nav>
</section>
);
}
El bucle recorre las páginas y conserva los extremos y las cercanas. anterior guarda la última conservada: si la distancia es 2, hay una página entre ambas y se añade; si es mayor, se añade un hueco.
Este es uno de los pocos casos en que la posición es una key correcta: un hueco no representa ningún dato, ni tiene estado. El prefijo hueco- evita chocar con los números de página. Esa key no tiene por qué ser estable entre renders.
oxlint avisará de que este fichero exporta una función junto a un componente (only-export-components), porque Fast Refresh funciona mejor si cada fichero exporta solo componentes. En un proyecto real, ventanaPaginas iría en su propio fichero.
Ejercicio 8: Selección múltiple
Crea una tabla con una casilla por fila y un filtro por oficio. La casilla de la cabecera selecciona todos los visibles, y si ya lo están, los deselecciona. La selección debe conservarse al cambiar el filtro. Muestra cuántos empleados hay seleccionados y la suma de sus salarios.
Ver solución
import { useState } from 'react';
import { empleados } from './datos';
export default function SeleccionMultiple() {
const [texto, setTexto] = useState('');
const [marcados, setMarcados] = useState<number[]>([]);
const visibles = empleados.filter((e) => e.oficio.toLowerCase().includes(texto.toLowerCase()));
const idsVisibles = visibles.map((e) => e.empId);
// Todo esto se deriva: marcados guarda ids, no filas ni índices
const todosMarcados = visibles.length > 0 && idsVisibles.every((id) => marcados.includes(id));
const masa = empleados
.filter((e) => marcados.includes(e.empId))
.reduce((suma, e) => suma + e.salario, 0);
function alternarUno(empId: number) {
setMarcados((actuales) =>
actuales.includes(empId) ? actuales.filter((id) => id !== empId) : [...actuales, empId],
);
}
function alternarVisibles() {
setMarcados((actuales) =>
todosMarcados
? actuales.filter((id) => !idsVisibles.includes(id))
: [...new Set([...actuales, ...idsVisibles])],
);
}
return (
<section className="tarjeta">
<h2>Selección múltiple</h2>
<input
aria-label="Filtrar por oficio"
placeholder="Oficio..."
value={texto}
onChange={(e) => setTexto(e.target.value)}
/>
<p className="tenue" role="status">
{marcados.length} seleccionados · {masa} € de masa salarial
</p>
<table className="tabla">
<thead>
<tr>
<th scope="col">
<input
type="checkbox"
aria-label="Seleccionar todos los visibles"
checked={todosMarcados}
disabled={visibles.length === 0}
onChange={alternarVisibles}
/>
</th>
<th scope="col">Empleado</th>
<th scope="col">Oficio</th>
</tr>
</thead>
<tbody>
{visibles.map((e) => (
<tr key={e.empId} className={marcados.includes(e.empId) ? 'marcada' : undefined}>
<td>
<input
type="checkbox"
aria-label={`Seleccionar a ${e.nombre}`}
checked={marcados.includes(e.empId)}
onChange={() => alternarUno(e.empId)}
/>
</td>
<td>{e.nombre}</td>
<td>{e.oficio}</td>
</tr>
))}
</tbody>
</table>
</section>
);
}
Lo importante es guardar ids. Con índices, cambiar el filtro haría que la misma posición señalara a otra persona. new Set elimina los repetidos al unir los ids ya seleccionados con los visibles. La suma se calcula sobre empleados, no sobre visibles, porque un seleccionado oculto por el filtro sigue seleccionado.
todosMarcados comprueba visibles.length > 0 porque [].every(...) devuelve true: sin esa condición, un filtro sin resultados marcaría la casilla de la cabecera.
Una mejora que requiere una ref (módulo 10) es el estado intermedio de la casilla, indeterminate, que no se puede poner desde JSX.
Ejercicio 9 (propuesto): Cargar más
Sustituye el paginador del caso práctico por un botón “Cargar más” que añada 6 filas cada vez y desaparezca cuando no queden. Al filtrar, debe volver a mostrar 6. Piensa qué estado necesitas: basta con un número.
Ejercicio 10 (propuesto): Orden por varias columnas
Amplía TablaOrdenable para que el estado sea un array de criterios. Pulsar una cabecera ordena por esa columna; con la tecla Mayús, la añade como criterio secundario. La función de comparación debe recorrer los criterios hasta encontrar uno que no empate. Prueba a ordenar por oficio y después por salario.
Ejercicio 11 (propuesto): Paginación en el servidor
Adapta EstadosLista para que pagine con la API: añade el estado pagina, envía ?pagina= y ?tamano=, y usa paginas y total de la respuesta para pintar el paginador. Haz que cada cambio de página lance una petición desde el manejador, y que el texto de búsqueda vuelva a la página 1. Compara la experiencia con la paginación en el cliente usando la variable RETARDO de la API: RETARDO=500 node server.mjs.
Resumen
| Concepto | API | Idea clave |
|---|---|---|
| Salida temprana | if (...) return ... | Lo mejor para casos especiales; estrecha los tipos |
| Pintar nada | return null | El componente se ejecuta, pero no produce DOM |
| Dos alternativas | a ? b : c | Un solo nivel; si se anida, extrae un componente |
| Algo o nada | condicion && <X /> | La izquierda debe ser un booleano real |
| Trampa del cero | 0 && <X /> | Devuelve 0, y 0 se pinta |
| Valor de reserva | ?? frente a \|\| | ?? respeta 0 y '' |
| Varios casos | Record<Union, ...>, switch | Un caso olvidado no compila |
| Quitar | {visible && <X />} | Se destruye el estado |
| Esconder | hidden, <Activity> 🆕 19.2 | Se conserva el estado |
| Listas | map | De datos a elementos |
| Identidad | key | Identifica al dato, no a su posición |
Índice como key | map((x, i) => ...) | El estado se queda en la posición |
| Varios elementos por dato | <Fragment key> | <> no admite key |
| Filtrar | filter | Se deriva, nunca se guarda en otro estado |
| Búsqueda sin tildes | normalize('NFD') | Sirve para buscar, no para ordenar |
| Ordenar sin mutar | toSorted | sort modifica el original |
| Ordenar en español | Intl.Collator, localeCompare | El orden por defecto falla con tildes y mayúsculas |
| Tablas | caption, scope, aria-sort | Botón dentro del <th> |
| Estados de una lista | Unión discriminada | Carga, error, vacía y sin resultados |
| Agrupar | Map.groupBy, Object.groupBy | ES2024: exige lib en tsconfig |
| Grupos vacíos | Partir de la lista de grupos | groupBy no los crea |
| Paginar | slice | Comprueba que la página sigue existiendo |
| Tubería | Filtrar, ordenar, paginar | Solo se guardan las decisiones del usuario |
| Cliente o servidor | ?q=, ?pagina= | No filtres en el cliente datos ya paginados |