Módulo 02: JSX a fondo
Objetivos del módulo
- Entender qué es JSX, qué problema resuelve y por qué no es HTML
- Saber en qué se convierte al compilar: llamadas a
jsx()yjsxs()y objetos llamados elementos - Distinguir componente, elemento y nodo del DOM
- Aplicar las reglas de JSX e interpretar sus mensajes de error
- Insertar expresiones con llaves y predecir qué pinta cada tipo de valor
- Escribir atributos correctamente:
className,htmlFor, booleanos,data-*,aria-*, SVG y elementos personalizados - Usar
stylecomo objeto, incluidas las variables CSS con TypeScript - Agrupar sin ensuciar el DOM con fragmentos, con y sin
key - Controlar comentarios, espacios en blanco y entidades HTML
- Explicar cómo te protege el escapado frente a XSS y dónde deja de hacerlo
- Usar
dangerouslySetInnerHTMLsolo cuando toca, y con el HTML saneado - Pasar atributos con spread sin abrir agujeros ni pisar valores
- Tratar el JSX como un valor: guardarlo, devolverlo y pasarlo como prop
1. Qué es JSX y qué problema resuelve
Una interfaz de usuario mezcla dos cosas: una estructura, que recuerda mucho a HTML, y una lógica, que es JavaScript. Hay tres formas de juntarlas.
La primera es construir la interfaz con cadenas de texto y innerHTML, como habrás hecho alguna vez:
contenedor.innerHTML = `
<article class="tarjeta">
<h2>${empleado.nombre}</h2>
</article>
`;
Se lee bien, pero para el navegador es solo texto. Un error en una etiqueta no se detecta hasta ejecutarlo, y si empleado.nombre contiene HTML, el navegador lo interpretará como HTML. La sección 11 demuestra hasta dónde puede llegar eso.
La segunda es usar la API del DOM o la función createElement de React. Es segura, pero escribir una ficha sencilla ya resulta pesado:
import { createElement } from 'react';
const ficha = createElement(
'article',
{ className: 'tarjeta' },
createElement('h2', null, empleado.nombre),
createElement('p', null, empleado.oficio),
);
La estructura, que era lo más fácil de entender, ha quedado enterrada entre paréntesis.
La tercera es JSX. Es una extensión de la sintaxis de JavaScript que permite escribir etiquetas directamente en el código:
const ficha = (
<article className="tarjeta">
<h2>{empleado.nombre}</h2>
<p>{empleado.oficio}</p>
</article>
);
Este fragmento y el anterior hacen lo mismo. La diferencia es que aquí la forma del código se parece a la forma del resultado, y el compilador sigue viendo la estructura: si cierras mal una etiqueta o te equivocas en el nombre de un atributo, te lo dice antes de ejecutar nada.
📘 Concepto: JSX es azúcar sintáctico: una forma más cómoda de escribir llamadas a funciones de JavaScript. No es HTML, no es una cadena de texto y no forma parte del estándar de JavaScript. El navegador no sabe leerlo, así que una herramienta lo traduce antes de que llegue a él.
Tres consecuencias de esa definición explican casi todo el módulo:
- Como no es HTML, tiene reglas propias: todas las etiquetas se cierran, se escribe
classNameen lugar declass, y los comentarios son distintos. - Como es JavaScript, un trozo de JSX es un valor: puedes guardarlo en una variable, devolverlo de una función o pasarlo como argumento.
- Como no es una cadena, React sabe distinguir la estructura de los datos, y eso le permite escapar los datos automáticamente.
💡 Consejo: JSX es opcional: React funciona sin él. Pero todo el código real, la documentación y este curso lo usan, y el
createElementde arriba solo te interesa para entender lo que pasa por dentro.
Prepara el proyecto
Para probar los ejemplos crea un proyecto como en el módulo 01:
npm create vite@latest modulo-02 -- --template react-ts
cd modulo-02
npm install
npm run dev
Cada ejemplo sustituye a src/App.tsx, que debe exportar por defecto un componente. 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: 620px; margin: 0 0 1rem; }
.tarjeta h2 { margin: 0 0 0.5rem; font-size: 1.15rem; color: #1a237e; }
.fila { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; margin-bottom: 0.75rem; }
.datos { display: grid; grid-template-columns: max-content 1fr; gap: 0.25rem 1rem; margin: 0.75rem 0 0; }
.datos dt { font-weight: 600; color: #1a237e; }
.datos dd { margin: 0; }
button, input, select { font: inherit; padding: 0.35rem 0.7rem; border: 1px solid #3f51b5; border-radius: 6px; background: #fff; color: #1a237e; }
button { cursor: pointer; background: #e8eaf6; }
table { border-collapse: collapse; width: 100%; }
th, td { text-align: left; padding: 0.35rem 0.5rem; border-bottom: 1px solid #e8eaf6; }
th { color: #1a237e; }
code { background: #eceff1; border-radius: 4px; padding: 0.05rem 0.3rem; font-size: 0.9em; }
.insignia { display: inline-block; padding: 0.1rem 0.6rem; border-radius: 999px; font-size: 0.85rem; font-weight: 600; }
.insignia-entregado { background: #e0f2e9; color: #1b6b45; }
.insignia-enviado { background: #e3edfb; color: #1c4f96; }
.insignia-pendiente { background: #fff3d6; color: #8a5a00; }
.insignia-cancelado { background: #fbe4e0; color: #a33a22; }
.resaltada { background: #fff8e1; }
.barra { height: 0.8rem; border-radius: 999px; background: #eceff1; overflow: hidden; }
.barra-relleno { height: 100%; background: var(--color-barra, #3f51b5); }
.boton { border-radius: 6px; }
.boton-primario { background: #3f51b5; color: #fff; }
.boton-peligro { background: #fff; color: #c62828; border-color: #c62828; }
.grande { font-size: 1.15rem; }
.aviso { display: flex; gap: 0.6rem; align-items: center; padding: 0.5rem 0.75rem; border-radius: 8px; margin-bottom: 0.5rem; }
.aviso-info { background: #e3edfb; }
.aviso-exito { background: #e0f2e9; }
.aviso-error { background: #fbe4e0; }
Aún no hemos visto los componentes con detalle, porque son el tema del módulo 03. De momento basta con esto: un componente es una función con nombre en mayúscula que devuelve JSX. Algunos ejemplos reciben datos mediante un parámetro { nombre }: { nombre: string }; esas son las props, y aquí puedes leerlas como si fueran atributos. Tampoco usamos estado todavía: las pantallas de este módulo pintan datos fijos, copiados de la API de ejemplo EMPRESA.
Este es el primer ejemplo, una ficha de empleado:
const empleado = {
empId: 110,
nombre: 'Carmen',
apellidos: 'Ruiz Ortega',
email: 'carmen.ruiz@empresa.es',
oficio: 'VENDEDOR',
salario: 2200,
comision: 500,
};
const euros = new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' });
export default function App() {
const nombreCompleto = `${empleado.nombre} ${empleado.apellidos}`;
const total = empleado.salario + empleado.comision;
return (
<article className="tarjeta">
<h2>{nombreCompleto}</h2>
<p>Empleado n.º {empleado.empId}, departamento VENTAS</p>
<dl className="datos">
<dt>Oficio</dt>
<dd>{empleado.oficio.toLowerCase()}</dd>
<dt>Correo</dt>
<dd><a href={`mailto:${empleado.email}`}>{empleado.email}</a></dd>
<dt>Retribución</dt>
<dd>{euros.format(total)} al mes</dd>
</dl>
</article>
);
}

Figura 1. La ficha en el navegador. Todo lo que hay entre llaves se ha calculado con JavaScript.
Fíjate en lo natural que es la mezcla. Calculas nombreCompleto y total con JavaScript normal justo antes del return, y en el JSX solo los colocas. El resto del módulo explica por qué funciona cada pieza.
2. De JSX a jsx(): la transformación
Para entender JSX de verdad conviene mirar qué sale al otro lado. Esta es la salida real de compilar con Oxc, el transformador que usa Vite 8, sin comentarios internos:
// Lo que escribes
const nombre = 'Ana';
const saludo = <h1 className="titulo">Hola, {nombre}</h1>;
// Lo que llega al navegador
import { jsxs as _jsxs } from "react/jsx-runtime";
const nombre = "Ana";
const saludo = _jsxs("h1", {
className: "titulo",
children: ["Hola, ", nombre]
});
La traducción es mecánica, sin magia: el nombre de la etiqueta pasa a ser el primer argumento, los atributos pasan a ser un objeto, y lo que había entre las etiquetas pasa a la propiedad children.
Figura 2. El camino completo. La traducción ocurre al compilar; la creación del objeto y del nodo, al ejecutar.
Estos casos cubren todo lo que puede aparecer. Son salidas reales de la compilación, simplificadas solo en el nombre _jsx:
| JSX | JavaScript generado |
|---|---|
<input disabled /> | jsx("input", { disabled: true }) |
<li key="100">Ana</li> | jsx("li", { children: "Ana" }, "100") |
<Ficha nombre="Ana" salario={6500} /> | jsx(Ficha, { nombre: "Ana", salario: 6500 }) |
<><dt>a</dt><dd>b</dd></> | jsxs(Fragment, { children: [jsx("dt", ...), jsx("dd", ...)] }) |
<div {...props}>x</div> | jsx("div", { ...props, children: "x" }) |
Tres detalles merecen atención:
- Minúscula o mayúscula.
<input>genera la cadena"input", mientras que<Ficha>genera el identificadorFicha, una referencia a tu función. Por eso la inicial importa tanto. keyno es una prop. Es el tercer argumento dejsx(), separado del resto. El componente nunca la recibe. La usaremos en el módulo 06.jsxojsxs.jsxsse usa cuando hay varios hijos fijos escritos en el código. Así React sabe que ese array es estático y no necesitakey. En cambio, sichildrenes un array conjsx, React entiende que es dinámico y avisa si faltan claves. Lo hemos comprobado con el mismo array de dos<li>:jsxsno avisa, yjsxescribeEach child in a list should have a unique "key" prop.
Quién hace cada trabajo
Es fácil confundirse, porque intervienen varias herramientas:
| Pieza | Qué hace |
|---|---|
| Plugin de React para Vite y Oxc | Transforma JSX y TypeScript en JavaScript, y añade Fast Refresh en desarrollo |
tsc (npm run build) | Comprueba los tipos. Con noEmit no genera ningún fichero |
"jsx": "react-jsx" en tsconfig.app.json | Le dice a TypeScript cómo tratar el JSX para poder comprobarlo |
react/jsx-runtime | La pequeña biblioteca que define jsx y jsxs |
react-dom | Convierte los elementos en nodos reales del DOM |
Desarrollo y producción
En desarrollo la transformación es otra. Vite genera llamadas a jsxDEV, importada de react/jsx-dev-runtime, con información extra:
_jsxDEV("h1", { className: "titulo", children: ["Hola, ", nombre] }, void 0, true, {
fileName: _jsxFileName,
lineNumber: 3,
columnNumber: 18
}, this);
Ese fileName y ese lineNumber son los que permiten que los avisos de la consola y las React DevTools te lleven a la línea exacta de tu código. En la compilación de producción desaparecen.
La transformación clásica
En código antiguo y en tutoriales verás otra salida, con React.createElement:
const saludo = React.createElement("h1", { className: "titulo" }, "Hola, ", nombre);
Esta es la transformación classic, y explica una línea que quizá hayas visto al principio de muchos ficheros: import React from 'react'. Como el código generado nombraba a React, esa variable tenía que existir aunque tú no la escribieras. Con la transformación automática actual el compilador añade solo el import que necesita, y puedes borrar esa línea.
Hay una excepción que conviene conocer: incluso hoy, cuando escribes key después de un spread, el compilador vuelve a createElement. Lo vemos en la sección 13.
💡 Consejo:
jsx-runtimees sustituible. La opciónjsxImportSourcedetsconfig.jsonindica de qué paquete salen esas funciones, y por eso bibliotecas como Preact pueden aprovechar la misma sintaxis. JSX no pertenece a React: es una sintaxis, y React es uno de sus usuarios.
El elemento de React
¿Qué devuelve jsx()? No devuelve HTML ni un nodo del DOM, sino un objeto corriente. Este es el resultado real de imprimir <div id="ficha"> con dos hijos en producción:
{
'$$typeof': Symbol(react.transitional.element),
type: 'div',
key: null,
ref: null,
props: {
id: 'ficha',
children: [
{
'$$typeof': Symbol(react.transitional.element),
type: 'h2',
key: null,
ref: null,
props: { children: 'Ana' }
},
{
'$$typeof': Symbol(react.transitional.element),
type: 'p',
key: null,
ref: null,
props: { children: 'Directora' }
}
]
}
}
📘 Concepto: Un elemento de React es un objeto que describe lo que quieres ver: qué etiqueta o componente (
type) y con qué datos (props). Es como un plano o una comanda: no es la cosa en sí.
Figura 3. Los elementos forman un árbol a través de props.children.
Si el type es un componente, el elemento guarda la propia función, sin ejecutarla:
{
'$$typeof': Symbol(react.transitional.element),
type: [Function: Ficha],
key: null,
ref: null,
props: { nombre: 'Ana', salario: 6500 }
}
Los elementos son inmutables. En desarrollo, React congela tanto el elemento como props con Object.freeze; lo hemos comprobado con Object.isFrozen, que devuelve true en ambos casos. En producción no se congelan, por rendimiento, pero la regla sigue siendo la misma: no los modifiques. Si la interfaz debe cambiar, se crea un elemento nuevo.
Crear estos objetos es muy barato, y por eso React puede permitirse generar el árbol completo cada vez que hay que actualizar la pantalla. Después compara el árbol nuevo con el anterior y aplica al DOM solo las diferencias. Ese proceso lo estudiarás en el módulo 05.
La forma del elemento 🔄 19.0
🔄 Cambia en React 19.0. En React 18.3 el símbolo es
react.elementyrefes un campo propio del elemento. Desde 19.0 el símbolo esreact.transitional.elementyrefse lee enelement.props.ref. Si usas una versión anterior: leeelement.ref. Solo te afecta si inspeccionas elementos a mano, algo que el código normal no debería hacer.
Hemos comprobado la diferencia en las cinco líneas de versiones: 18.3.1 usa Symbol(react.element) y no guarda ref en las props, mientras que 19.0.9, 19.1.10, 19.2.9 y 19.3.1 usan el símbolo nuevo y sí la guardan. El campo ref del objeto sigue existiendo en React 19 por compatibilidad, pero ya no es el lugar donde mirar.
El cambio de símbolo tiene una consecuencia práctica: React 19 no acepta un elemento creado por React 18. Hemos pintado uno con react-dom 19.3.1 y lanza Objects are not valid as a React child (found: object with keys {$$typeof, type, key, ref, props, _owner, _store}). Si ves ese error con claves como $$typeof, sospecha que hay dos copias de react en node_modules, por ejemplo por una biblioteca antigua. Compruébalo con npm ls react.
Componente, elemento y nodo
Estas tres palabras se usan a menudo como sinónimos, y no lo son:
Figura 4. La receta, el pedido y el plato.
| Componente | Elemento | Nodo del DOM | |
|---|---|---|---|
| Qué es | Una función | Un objeto | Un objeto del navegador |
| Ejemplo | function Ficha() {...} | <Ficha />, <p>Ana</p> | HTMLParagraphElement |
| Quién lo crea | Tú | JSX, al ejecutarse | react-dom |
| Cuánto dura | Toda la aplicación | Hasta el siguiente render | Mientras siga en pantalla |
| Coste | No aplica | Muy bajo | Alto |
3. Las reglas de JSX
Si vienes de HTML, estas cuatro reglas son las que más errores producen al principio. Todas tienen una explicación lógica una vez que sabes en qué se convierte JSX.
Regla 1: un único elemento raíz
Esto no compila:
function Ficha() {
return (
<h2>Ana</h2>
<p>Directora</p>
);
}
Oxc responde Adjacent JSX elements must be wrapped in an enclosing tag. y TypeScript responde TS2657: JSX expressions must have one parent element.
La razón es la misma por la que return f(), g() no devuelve dos cosas: cada etiqueta raíz es una llamada a función, y una función devuelve un solo valor. La solución es envolverlas. Si no quieres añadir un nodo al DOM, usa un fragmento (sección 8):
function Ficha() {
return (
<>
<h2>Ana</h2>
<p>Directora</p>
</>
);
}
⚠️ Importante: Los paréntesis alrededor del JSX multilínea no son decoración. Sin ellos, si escribes
returny saltas de línea, la inserción automática de punto y coma de JavaScript convierte eso enreturn;y la función devuelveundefined.
Regla 2: todas las etiquetas se cierran
En HTML algunas etiquetas no tienen cierre. En JSX todas lo necesitan, porque el analizador tiene que saber dónde termina cada llamada:
<input type="text" /> // no <input type="text">
<br /> // no <br>
<img src="/foto.png" alt="Ana" />
<div className="separador" /> // también vale para etiquetas con contenido vacío
Una etiqueta sin cerrar da un error que despista bastante: <br> provoca un escueto Unexpected token en Oxc, y TS17008: JSX element 'input' has no corresponding closing tag. en TypeScript, a menudo señalando la línea siguiente. Las etiquetas vacías de HTML tampoco admiten hijos: <br>x</br> compila, pero al ejecutarse lanza br is a self-closing tag and must neither have children nor use dangerouslySetInnerHTML.
Regla 3: atributos en camelCase
Los atributos se convierten en claves de un objeto, y React usa los nombres de las propiedades del DOM, en camelCase: tabIndex, maxLength, onClick. Dos de ellos cambian de nombre porque class y for son palabras reservadas de JavaScript: className y htmlFor. La sección 5 los recoge todos.
Regla 4: la inicial decide qué es cada cosa
| Escribes | Se genera | Significado |
|---|---|---|
<ficha /> | jsx("ficha", {}) | Una etiqueta HTML llamada ficha, que no existe |
<Ficha /> | jsx(Ficha, {}) | Tu componente |
<Iconos.Correo /> | jsx(Iconos.Correo, {}) | Propiedad de un objeto; con un punto siempre es una referencia, sea cual sea la inicial |
Si escribes un componente con minúscula, TypeScript te avisa con TS2339: Property 'etiqueta' does not exist on type 'JSX.IntrinsicElements'. Y si lo haces al revés, con una etiqueta HTML en mayúscula como <Empleado> sin haber definido nada, la consola muestra <Empleado /> is using incorrect casing. Use PascalCase for React components, or lowercase for HTML elements.
Esta regla también explica cómo elegir la etiqueta de forma dinámica: guarda el valor en una variable con mayúscula y úsala como etiqueta.
import type { ReactNode } from 'react';
function Encabezado({ nivel, children }: { nivel: 1 | 2 | 3; children: ReactNode }) {
const Etiqueta = `h${nivel}` as const;
return <Etiqueta>{children}</Etiqueta>;
}
const Iconos = {
Correo: () => <span aria-hidden="true">✉️</span>,
Telefono: () => <span aria-hidden="true">☎️</span>,
};
export default function App() {
return (
<div className="tarjeta">
<Encabezado nivel={2}>Contacto</Encabezado>
<Encabezado nivel={3}>Ventas</Encabezado>
<p><Iconos.Correo /> compras@empresa.es</p>
<p><Iconos.Telefono /> 961 234 567</p>
</div>
);
}
Con const etiqueta = 'h1' y <etiqueta /> no funcionaría: el compilador, al ver la minúscula, generaría la cadena "etiqueta" y no miraría tu variable. Y el as const hace que TypeScript deduzca "h1" | "h2" | "h3" en lugar de string, de modo que sabe que son etiquetas válidas.
Ficheros .tsx y genéricos
JSX solo se admite en ficheros .tsx (o .jsx). En un .ts normal, <p> no significa nada. Además, dentro de .tsx hay dos sintaxis de TypeScript que chocan con las etiquetas:
const uno = <string>valor; // ❌ TS17008: lo lee como una etiqueta sin cerrar
const dos = valor as string; // ✅ usa siempre as en .tsx
const f = <T>(x: T) => x; // ❌ TS17008: mismo problema
const g = <T,>(x: T) => x; // ✅ la coma aclara que es un genérico
El módulo 13 trata los genéricos en componentes.
4. Expresiones con llaves
Las llaves {} abren una ventana a JavaScript dentro de JSX. Puedes ponerlas en dos sitios: como contenido de una etiqueta y como valor de un atributo.
<a href={`mailto:${empleado.email}`}>{empleado.email.toLowerCase()}</a>
No se pueden usar para el nombre de una etiqueta ni para el nombre de un atributo. <{etiqueta} /> y <p {nombre}="x"> son errores de sintaxis.
Expresión, no sentencia
Dentro de las llaves cabe cualquier expresión: algo que produce un valor. No caben sentencias como if, for o const. Lo explica la traducción: el contenido de las llaves acaba como argumento de una función, y en JavaScript tampoco puedes escribir f(if (a) b).
<p>{if (activo) { 'Sí' }}</p> // ❌ Unexpected token
<p>{activo ? 'Sí' : 'No'}</p> // ✅ un ternario es una expresión
Dentro de {} | ¿Vale? |
|---|---|
Variable, propiedad: {empleado.nombre} | Sí |
Operación: {salario * 12} | Sí |
Plantilla de texto: {`${a} ${b}`} | Sí |
Llamada: {formatear(precio)} | Sí |
Ternario: {activo ? 'Sí' : 'No'} | Sí |
Lógica: {activo && <Insignia />} | Sí, con cuidado |
Otro JSX: {<b>Hola</b>} | Sí |
map: {lista.map((x) => <li key={x}>{x}</li>)} | Sí |
if, for, while, switch | No |
Declaración: const x = 1 | No |
Si necesitas lógica con sentencias, escríbela antes del return, guárdala en una variable y colócala. Es lo que hicimos con nombreCompleto en la ficha, y lo verás ampliado en la sección 14. Los condicionales y las listas tienen su propio módulo 06; aquí los usamos solo lo imprescindible.
💡 Consejo: Mantén las llaves pequeñas. Si una expresión ocupa varias líneas o tiene un ternario dentro de otro, sácala a una variable con un buen nombre. El JSX debe leerse como una plantilla.
Qué pinta cada valor
No todo lo que metes entre llaves acaba en pantalla. Este ejemplo lo muestra con los valores que React acepta como hijos:
import type { ReactNode } from 'react';
const casos: [string, ReactNode][] = [
['"Ana"', 'Ana'],
['42', 42],
['0', 0],
['NaN', NaN],
['""', ''],
['true', true],
['false', false],
['null', null],
['undefined', undefined],
['["a", 1, null, "b"]', ['a', 1, null, 'b']],
['9007199254740993n', 9007199254740993n],
['<strong>Ana</strong>', <strong>Ana</strong>],
];
export default function App() {
return (
<section className="tarjeta">
<h2>Qué pinta cada valor dentro de llaves</h2>
<table>
<thead>
<tr><th>Valor</th><th>Resultado en pantalla</th></tr>
</thead>
<tbody>
{casos.map(([codigo, valor]) => (
<tr key={codigo}>
<td><code>{codigo}</code></td>
<td>{valor}</td>
</tr>
))}
</tbody>
</table>
</section>
);
}

Figura 5. Las celdas vacías son las de los valores que React ignora.
El tipo que describe todo lo que se puede pintar es ReactNode. Esta es la tabla completa, con lo que hemos obtenido al ejecutar cada caso:
| Valor | Resultado | Motivo o aviso |
|---|---|---|
| Cadena | Se pinta | Escapada, como verás en la sección 11 |
| Número | Se pinta | Incluido 0 |
NaN | Pinta NaN | Avisa en la consola: Received NaN for the children attribute |
true, false | Nada | Para que funcione {condicion && <X />} |
null, undefined | Nada | Para poder decir “aquí no va nada” |
'' | Nada | Una cadena vacía no crea texto visible |
| Array | Se pinta cada posición | Si son elementos, necesitan key |
| Elemento JSX | Se pinta | |
| Objeto normal | Lanza un error | Objects are not valid as a React child |
Date | Lanza un error | Es un objeto |
| Función | Nada | Avisa: Functions are not valid as a React child |
bigint como hijo 🔄 19.0
🔄 Cambia en React 19.0. Un
bigintdentro de llaves se pinta como un número. En React 18.3 no pintaba nada, sin ningún error. Si usas una versión anterior: conviértelo a texto con{valor.toString()}.
Hemos pintado 9007199254740993n en 18.3.1 y el resultado fue <p></p>. Desde 19.0.9 el resultado es <p>9007199254740993</p>. Con bigint los identificadores enormes que llegan de una base de datos pueden mostrarse sin perder precisión.
Objetos y fechas
El error más típico de este apartado es intentar pintar un objeto entero:
<p>{empleado}</p> // ❌ Objects are not valid as a React child
<p>{new Date()}</p> // ❌ el mismo error
<p>{empleado.nombre}</p> // ✅
<p>{fecha.toLocaleDateString('es-ES')}</p> // ✅
El mensaje real es Objects are not valid as a React child (found: object with keys {a}). If you meant to render a collection of children, use an array instead., y con una fecha dice found: [object Date]. Lo bueno es que TypeScript lo detecta al compilar: TS2322: Type 'Date' is not assignable to type 'ReactNode'.
¿Por qué React no llama a toString(), como haría una plantilla de texto? Porque el resultado sería el inútil [object Object] en pantalla. Es preferible un error claro.
⚠️ Importante: Este error se lanza durante el render. Si nadie lo captura, React desmonta toda la aplicación y la página se queda en blanco. Por eso, cuando trabajes con datos de una API, un campo que de repente llega como objeto puede tirar la pantalla entera. Veremos cómo contenerlo con Error Boundaries en el módulo 15.
La trampa del cero
Como false no pinta nada, es tentador escribir esto para mostrar algo solo si hay existencias:
{producto.stock && <p>Quedan {producto.stock} unidades</p>}
Funciona con stock = 25. Pero con stock = 0, 0 && ... devuelve 0, y 0 sí se pinta. El producto mostrará un cero suelto en la página. Solución: que la condición sea un booleano de verdad.
{producto.stock > 0 && <p>Quedan {producto.stock} unidades</p>}
Lo mismo pasa con NaN y con lista.length && .... La cadena vacía, en cambio, no da problemas visibles.
5. Atributos
Un atributo admite dos formas, y solo dos: una cadena entre comillas o una expresión entre llaves.
<img
src={producto.foto} // expresión
alt="Foto del producto" // cadena literal
width={120} // número
/>
No las mezcles: src="{producto.foto}" no sustituye nada, y el atributo vale literalmente {producto.foto}. Esto también es un error habitual: en cuanto hay comillas alrededor, deja de ser una expresión. Para combinar, usa una plantilla de texto dentro de las llaves: src={`/fotos/${id}.png`}.
Cuidado con los números: tabIndex="2" es la cadena "2", y TypeScript la rechaza con TS2322: Type 'string' is not assignable to type 'number'. Escribe tabIndex={2}.
Los nombres que cambian
| HTML | JSX | Motivo |
|---|---|---|
class | className | class es palabra reservada, y además es el nombre de la propiedad del DOM |
for | htmlFor | for es palabra reservada |
tabindex | tabIndex | camelCase |
maxlength | maxLength | camelCase |
readonly | readOnly | camelCase |
autofocus | autoFocus | camelCase |
colspan, rowspan | colSpan, rowSpan | camelCase |
onclick="f()" | onClick={f} | Recibe una función, no una cadena con código |
style="color: red" | style={{ color: 'red' }} | Recibe un objeto (sección 7) |
stroke-width (SVG) | strokeWidth | camelCase |
data-*, aria-* | Sin cambios | Se mantienen en minúsculas y con guiones |
Con estos errores tienes dos redes de seguridad. TypeScript los detiene al compilar:
TS2322: Type '{ class: string; }' is not assignable to type 'DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>'.
TS2322: Type 'string' is not assignable to type 'MouseEventHandler<HTMLButtonElement>'.
Y si llegan a ejecutarse, React avisa en la consola con Invalid DOM property class. Did you mean className?. Aun así, React escribe class en el HTML: es un aviso, no un error, y por eso puede pasarte inadvertido. Con onclick, en cambio, no se añade nada al DOM: Invalid event handler property onclick. React events use the camelCase naming convention.
Booleanos
Un atributo sin valor equivale a true, igual que en HTML. Es la salida real de jsx("input", { disabled: true }).
<input disabled /> // disabled={true}
<input disabled={false} /> // el atributo no aparece
<input disabled={!puedeEditar} />
Lo importante es que disabled={false} quita el atributo. En HTML no existe esa posibilidad: disabled="false" deshabilita el campo, porque solo cuenta que el atributo esté presente. TypeScript rechaza esa forma, y React también avisa si llega a ejecutarse: Received the string false for the boolean attribute hidden. The browser will interpret it as a truthy value.
Hay un caso curioso: hidden="" tampoco pinta el atributo, porque React trata la cadena vacía como falsa en los atributos booleanos.
Qué llega al DOM
Esta captura compara el JSX con el HTML generado. Se ha producido con renderToStaticMarkup, una función de react-dom/server que convierte elementos en una cadena:

Figura 6. Observa la diferencia entre disabled={false}, que desaparece, y data-activo={false}, que se escribe.
Reglas que se deducen de la tabla:
undefinedynulleliminan el atributo. Es la forma de decir “este atributo, no”.- Los booleanos en
data-*yaria-*se escriben como texto, porque ahí"false"es un valor con significado.aria-hidden="false"no es lo mismo que no ponerlo. Por tanto, elfalsese conserva. - La cadena vacía se respeta en atributos normales:
title=""sigue en el HTML. - En atributos no booleanos,
falseytrueno se escriben.title={false}no pinta nada y avisa conReceived false for a non-boolean attribute title. - Los objetos se convierten en texto:
title={{ a: 1 }}escribetitle="[object Object]". TypeScript lo impide.
Atributos desconocidos
React pasa al DOM los atributos que no conoce, siempre que estén en minúsculas. miatributo="a" llega tal cual. Si lo escribes en camelCase, llega también, pero con un aviso: React does not recognize the miAtributo prop on a DOM element. TypeScript rechaza ambos.
Si quieres guardar un dato propio en una etiqueta, usa data-*. Es el mecanismo estándar de HTML y el único que TypeScript deja pasar sin definirlo: dataId y foo dan error, y data-id no. Esto ocurre porque TypeScript no comprueba en JSX los nombres con guion.
<tr data-id={empleado.empId} data-dept={empleado.deptId}>
SVG
El SVG se escribe en JSX casi igual que en el navegador, solo que con los atributos en camelCase. Si pegas un SVG de un programa de dibujo tendrás que cambiar stroke-width, fill-opacity y similares, o React te dirá Invalid DOM property stroke-width. Did you mean strokeWidth?. Se mantienen viewBox, que ya está en camelCase, y data-*.
<svg viewBox="0 0 24 24" width={24} height={24} aria-hidden="true">
<circle cx={12} cy={12} r={10} fill="none" stroke="#3f51b5" strokeWidth={2} />
</svg>
Elementos personalizados 🔄 19.0
🔄 Cambia en React 19.0. React asigna propiedades a los elementos personalizados (Web Components) si el elemento las define, entiende
classNamey tratatrueyfalsecomo atributos booleanos. En React 18.3 todo se convertía en atributos de texto. Si usas una versión anterior: escribeclassen lugar declassNamey pasa objetos o funciones con unrefyelemento.propiedad = valor.
Un elemento personalizado es una etiqueta con guion, definida con customElements.define. Hemos pintado el mismo elemento en las dos versiones, con una clase que define la propiedad datos:
<mi-etiqueta className="a" datos={{ a: 1 }} activo={true} apagado={false} otro={{ b: 1 }} />
| React 18.3.1 | React 19.3.1 | |
|---|---|---|
className="a" | Atributo classname="a" | Atributo class="a" |
datos={{ a: 1 }} | Atributo datos="[object Object]"; la propiedad queda undefined | Propiedad datos con el objeto |
activo={true} | Atributo activo="true" | Atributo activo="" |
apagado={false} | Atributo apagado="false" | Sin atributo |
otro={{ b: 1 }} (no definida) | Atributo otro="[object Object]" | Atributo otro="[object Object]" |
La regla de 19.x: si el elemento tiene una propiedad con ese nombre, se asigna como propiedad; si no, se escribe como atributo. Para usarlo con TypeScript tendrás que declarar la etiqueta en JSX.IntrinsicElements, algo que veremos en el módulo 13.
6. className y clases dinámicas
className recibe una cadena. Lo interesante es construirla según los datos. Este ejemplo resalta los pedidos pendientes y elige el color de la insignia a partir del estado:
type Estado = 'ENTREGADO' | 'ENVIADO' | 'PENDIENTE' | 'CANCELADO';
const pedidos: { pedidoId: number; fechaPedido: string; estado: Estado }[] = [
{ pedidoId: 5001, fechaPedido: '2025-01-15', estado: 'ENTREGADO' },
{ pedidoId: 5021, fechaPedido: '2026-06-15', estado: 'ENVIADO' },
{ pedidoId: 5022, fechaPedido: '2026-07-01', estado: 'PENDIENTE' },
{ pedidoId: 5023, fechaPedido: '2026-07-08', estado: 'PENDIENTE' },
];
export default function App() {
return (
<section className="tarjeta">
<h2>Pedidos recientes</h2>
<table>
<thead>
<tr><th>Pedido</th><th>Fecha</th><th>Estado</th></tr>
</thead>
<tbody>
{pedidos.map((pedido) => {
const pendiente = pedido.estado === 'PENDIENTE';
return (
// Ternario: la clase solo se añade si el pedido está pendiente
<tr key={pedido.pedidoId} className={pendiente ? 'resaltada' : undefined}>
<td>{pedido.pedidoId}</td>
<td>{pedido.fechaPedido}</td>
<td>
{/* Plantilla de texto: una parte fija y otra calculada */}
<span className={`insignia insignia-${pedido.estado.toLowerCase()}`}>
{pedido.estado}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</section>
);
}

Figura 7. Las clases calculadas a partir de los datos son la forma habitual de dar estilo según el estado.
Fíjate en className={pendiente ? 'resaltada' : undefined}: si no aplica, undefined elimina el atributo, y evitas dejar un class="" vacío.
Estos son los tres patrones que más usarás:
// Una clase condicional
<tr className={activo ? 'resaltada' : undefined}>
// Una parte fija y otra condicional
<button className={`boton ${peligro ? 'boton-peligro' : 'boton-primario'}`}>
// Varias clases condicionales: array, filter y join
const clases = ['boton', activo && 'activo', grande && 'grande']
.filter(Boolean)
.join(' ');
⚠️ Importante: Cuidado con
className={`boton ${activo && 'activo'}`}. Dentro de una plantilla de texto,falsese convierte en la cadena"false"y el resultado esclass="boton false". No rompe nada, pero ensucia el HTML. En plantillas usa siempre un ternario.
Cuando la lógica de clases crece, la biblioteca clsx lo simplifica. Ese tema, junto con CSS Modules y Tailwind, está en el módulo 07.
7. El atributo style
En JSX, style no recibe una cadena, sino un objeto. Por eso verás a menudo una doble llave, que no es ninguna sintaxis especial: la externa abre la expresión y la interna es el literal del objeto.
<p style={{ color: 'red', fontSize: 16 }}>Texto</p>
// Es exactamente lo mismo que:
const estilos = { color: 'red', fontSize: 16 };
<p style={estilos}>Texto</p>
Si pasas una cadena, React no intenta interpretarla: lanza un error durante el render, The style prop expects a mapping from style properties to values, not a string. TypeScript ya lo detecta antes: TS2559: Type 'string' has no properties in common with type 'Properties<string | number, string & {}>'.
Nombres y unidades
Las propiedades van en camelCase, como en elemento.style del DOM: background-color pasa a ser backgroundColor. Los prefijos de navegador empiezan por mayúscula: WebkitLineClamp.
Si das un número, React añade px, excepto en las propiedades que no tienen unidad. Esta es una salida real:
<div style={{
fontSize: 14, marginTop: 0, padding: '1rem',
lineHeight: 1.5, opacity: 0.5, zIndex: 2, flexGrow: 1,
width: undefined,
}} />
<div style="font-size:14px;margin-top:0;padding:1rem;line-height:1.5;opacity:0.5;z-index:2;flex-grow:1"></div>
fontSize: 14 se convierte en 14px, y lineHeight: 1.5 se queda igual, porque esa propiedad admite números sin unidad. 0 no lleva unidad, y undefined elimina la propiedad. Para cualquier otra unidad, usa una cadena: '1rem', '50%'.
Un caso real
Los estilos en línea encajan cuando un valor se calcula en ejecución, como el ancho de una barra:
import type { CSSProperties } from 'react';
const departamentos = [
{ deptId: 20, nombre: 'VENTAS', presupuesto: 350000 },
{ deptId: 30, nombre: 'INFORMÁTICA', presupuesto: 420000 },
{ deptId: 40, nombre: 'CONTABILIDAD', presupuesto: 150000 },
{ deptId: 60, nombre: 'LOGÍSTICA', presupuesto: 180000 },
];
const maximo = 500000;
function colorPara(porcentaje: number) {
if (porcentaje >= 70) return '#ff7043';
if (porcentaje >= 40) return '#3f51b5';
return '#26a69a';
}
export default function App() {
return (
<section className="tarjeta">
<h2>Presupuesto por departamento</h2>
{departamentos.map((departamento) => {
const porcentaje = Math.round((departamento.presupuesto / maximo) * 100);
const variables = { '--color-barra': colorPara(porcentaje) } as CSSProperties;
return (
<div key={departamento.deptId} style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span>{departamento.nombre}</span>
<span>{porcentaje} %</span>
</div>
<div className="barra" style={variables}>
<div className="barra-relleno" style={{ width: `${porcentaje}%` }} />
</div>
</div>
);
})}
</section>
);
}

Figura 8. La anchura se calcula en cada fila. El aspecto fijo está en el CSS.
Observa el reparto: el border-radius, la altura y el fondo están en .barra, en el CSS. En style solo va lo que depende de los datos.
Variables CSS y TypeScript
El ejemplo pasa el color con una variable CSS, --color-barra, que el CSS lee con var(). Es una técnica muy útil: el dato viaja por el style, y el CSS decide cómo usarlo, incluso en pseudoelementos o :hover, que style no puede alcanzar.
En ejecución funciona sin más: { '--color': 'red', '--n': 4 } se escribe como --color:red;--n:4, y fíjate en que al 4 no le añade px. Pero TypeScript no conoce esa propiedad:
TS2353: Object literal may only specify known properties, and ''--color'' does not exist in type 'Properties<string | number, string & {}>'.
Por eso aparece as CSSProperties, que le dice a TypeScript que aceptamos ese objeto.
!important no funciona
<p style={{ color: 'red !important' }}>Dos</p>
Hemos comprobado en Chromium que esta declaración no surte efecto en el cliente: el atributo style queda vacío y el color sigue siendo el heredado. El motivo es que React asigna elemento.style.color = valor, y esa asignación rechaza !important. Para que funcione hay que llamar a style.setProperty('color', 'red', 'important') desde un ref, que verás en el módulo 10. Pero si necesitas !important, es mejor revisar el CSS.
Una trampa añadida: en el servidor (renderToStaticMarkup) esa cadena sí se escribe en el HTML, así que el servidor y el cliente podrían mostrar cosas distintas.
Cuándo usar style
| Situación | Mejor opción |
|---|---|
| Aspecto fijo | Clases CSS |
| Estados: activo, error, deshabilitado | Clases condicionales |
:hover, :focus, @media, animaciones | Clases CSS; style no puede expresarlos |
| Valor calculado en ejecución: ancho, posición, color del usuario | style |
| Pasar un dato al CSS | Variable CSS en style |
8. Fragmentos
La regla de la raíz única obliga a envolver, pero un <div> extra no siempre es inofensivo. Dentro de una tabla, una lista o un contenedor con grid o flex, la estructura del DOM importa.
Este ejemplo devuelve dos celdas desde un componente, primero con <div> y después con un fragmento:
const filasDepartamento = [
{ deptId: 30, nombre: 'INFORMÁTICA', localidad: 'Valencia' },
{ deptId: 40, nombre: 'CONTABILIDAD', localidad: 'Sevilla' },
];
// ❌ MAL: el <div> rompe la tabla, porque <tr> solo admite celdas
function CeldasConDiv({ nombre, localidad }: { nombre: string; localidad: string }) {
return (
<div>
<td>{nombre}</td>
<td>{localidad}</td>
</div>
);
}
// ✅ BIEN: el fragmento agrupa sin añadir ningún nodo al DOM
function CeldasConFragmento({ nombre, localidad }: { nombre: string; localidad: string }) {
return (
<>
<td>{nombre}</td>
<td>{localidad}</td>
</>
);
}
export default function App() {
return (
<>
<section className="tarjeta">
<h2>Con un div de envoltorio</h2>
<table>
<thead>
<tr><th>Departamento</th><th>Localidad</th></tr>
</thead>
<tbody>
{filasDepartamento.map((d) => (
<tr key={d.deptId}><CeldasConDiv {...d} /></tr>
))}
</tbody>
</table>
</section>
<section className="tarjeta">
<h2>Con un fragmento</h2>
<table>
<thead>
<tr><th>Departamento</th><th>Localidad</th></tr>
</thead>
<tbody>
{filasDepartamento.map((d) => (
<tr key={d.deptId}><CeldasConFragmento {...d} /></tr>
))}
</tbody>
</table>
</section>
</>
);
}

Figura 9. Arriba, el <div> rompe la tabla. Abajo, el fragmento deja las celdas donde deben estar.
El DOM real de la primera fila en cada caso es:
<!-- Con div -->
<div><td>INFORMÁTICA</td><td>Valencia</td></div>
<!-- Con fragmento -->
<td>INFORMÁTICA</td><td>Valencia</td>
TypeScript no detecta el problema, porque el <div> es un JSX válido. Quien sí avisa es React, en la consola y solo en desarrollo: In HTML, <div> cannot be a child of <tr>., y <div> cannot contain a nested <td>.. Conviene leerlos: son avisos de HTML no válido.
📘 Concepto: Un fragmento agrupa varios elementos sin generar ningún nodo en el DOM.
<>...</>compila ajsxs(Fragment, { children: [...] }), conFragmentimportado dereact/jsx-runtime. React recorre sus hijos y los coloca directamente en el padre.
Además de corregir tablas, los fragmentos sirven para listas de definición (<dt> y <dd>), para no romper un display: grid o flex, y simplemente para no llenar el DOM de <div> inútiles.
Fragment con key
La sintaxis corta <> no admite atributos: < key="1"> es un error de sintaxis. Si necesitas una key, porque generas grupos dentro de un map, escribe el nombre completo:
import { Fragment } from 'react';
{departamentos.map((d) => (
<Fragment key={d.deptId}>
<dt>{d.nombre}</dt>
<dd>{d.localidad}</dd>
</Fragment>
))}
key es prácticamente lo único que acepta. Cualquier otro atributo es rechazado por TypeScript (TS2322: Type '{ id: string; children: string; }' is not assignable to type 'IntrinsicAttributes & FragmentProps'). React 19 lo ignora sin avisar, mientras que React 18.3 advertía con Invalid prop id supplied to React.Fragment.
Fragment con ref 🆕 19.3
🆕 Desde React 19.3. No existe en React 19.2 ni anteriores. Si usas una versión anterior: envuelve el grupo en un elemento real, como
<div style={{ display: 'contents' }} ref={...}>, o usa unrefen cada hijo. No hay equivalente sin un nodo extra.
Desde 19.3 un Fragment acepta ref, y recibe un FragmentInstance, un objeto que permite actuar sobre todos sus hijos del DOM sin envolverlos:
import { Fragment, useRef, type FragmentInstance } from 'react';
import type {} from 'react-dom';
export default function App() {
const grupo = useRef<FragmentInstance>(null);
return (
<div className="tarjeta">
<Fragment ref={grupo}>
<input aria-label="Nombre" />
<input aria-label="Apellidos" />
</Fragment>
<button type="button" onClick={() => grupo.current?.focus()}>Ir al primer campo</button>
</div>
);
}
Al pulsar el botón, el foco va al primer campo. Lo hemos comprobado en Chromium. La línea import type {} from 'react-dom' hace falta porque los métodos están declarados en @types/react-dom, y sin ella TypeScript responde Property 'focus' does not exist on type 'FragmentInstance'. Este ejemplo usa useRef, que se explica en el módulo 10, junto con el resto de los Fragment Refs.
9. Comentarios
JSX no tiene sintaxis propia de comentarios. Como las llaves abren JavaScript, un comentario en el contenido es un comentario de JavaScript dentro de unas llaves:
export default function App() {
return (
<div className="tarjeta">
{/* Comentario normal entre etiquetas */}
<h2>Comentarios</h2>
{/*
También puede
ocupar varias líneas
*/}
<p
// Entre atributos no hacen falta llaves
id="nota" /* y este también es válido */
title="Nota"
>
Esto se ve // y esto también se ve, porque es texto
</p>
</div>
);
}
Puntos clave:
{/* */}es lo correcto entre etiquetas. Un{}vacío es válido como hijo, y esto es lo que queda al quitar el comentario: la compilación de<div>{}</div>producejsx("div", {}).- Dentro de una etiqueta, entre atributos, ya estás en terreno de JavaScript, y puedes usar
/* */y//sin llaves. //en el contenido no es un comentario: es texto, y se ve en la página. Es un error discreto, porque compila sin avisar.<!-- -->no existe en JSX. Provoca un error de sintaxisUnexpected token.
⚠️ Importante: A diferencia de los comentarios HTML, estos no llegan al navegador: no se ven en “Ver código fuente”. Y al revés: si necesitas un comentario HTML real en la salida, JSX no te lo permite.
10. Texto, espacios y entidades
Aquí JSX se comporta de forma distinta a HTML, y las diferencias causan pequeños errores visuales. Este ejemplo reúne los casos típicos:
const nombre = 'Ana';
const apellidos = 'García';
export default function App() {
return (
<section className="tarjeta">
<h2>Espacios en blanco</h2>
<p>Hola, {nombre} y compañía</p>
<p>
Este texto ocupa
dos líneas en el código
</p>
<p>
{nombre}
{apellidos}
</p>
<p>
{nombre}{' '}
{apellidos}
</p>
<p>
Pulsa <strong>Guardar</strong>
para continuar
</p>
<p>
Pulsa <strong>Guardar</strong>{' '}
para continuar
</p>
<p>20 %, © EMPRESA, 3 < 5, 5 > 3, {llaves}</p>
<p>{'20 %, © EMPRESA'}</p>
<p title="François & Cía">Atributo con entidades</p>
<p>{'{'}llaves{'}'} con expresiones, 3 {'<'} 5, 5 {'>'} 3</p>
</section>
);
}

Figura 10. Observa “AnaGarcía” y “Guardarpara”: se ha perdido el espacio al saltar de línea.
Este es el textContent real de cada párrafo:
"Hola, Ana y compañía"
"Este texto ocupa dos líneas en el código"
"AnaGarcía"
"Ana García"
"Pulsa Guardarpara continuar"
"Pulsa Guardar para continuar"
"20 %, © EMPRESA, 3 < 5, 5 > 3, {llaves}"
"20 %, © EMPRESA"
"Atributo con entidades" (title: François & Cía)
"{llaves} con expresiones, 3 < 5, 5 > 3"
Las reglas de los espacios
El compilador aplica estas reglas al texto que hay entre etiquetas:
- Los espacios dentro de una línea se conservan. El DOM contiene los tres espacios de
Hola, Ana, aunque luego el navegador los muestre como uno, según las reglas de CSS. - Los espacios al principio y al final de cada línea se eliminan, junto con el salto de línea.
- Si un texto continúa en la línea siguiente, las líneas se unen con un espacio. Por eso las dos líneas del segundo párrafo se leen bien.
- Si el salto de línea está junto a una etiqueta o unas llaves, se elimina por completo, y no queda ningún espacio.
La regla 4 es la que causa “AnaGarcía”. Dos expresiones en líneas distintas no tienen ningún texto entre ellas, y lo mismo ocurre con </strong> al final de una línea. La solución es el modismo {' '}, un espacio explícito. Prettier lo añade solo cuando reparte una línea larga.
Entidades HTML
Puedes usar entidades como , © o < en el texto de JSX y en los atributos literales. El compilador las decodifica, y el title de arriba muestra François & Cía.
Pero dentro de una cadena de JavaScript no se decodifican. {'20 %'} muestra literalmente 20 %, porque ya no es texto JSX, sino una cadena normal. Si necesitas un espacio duro en una cadena, usa el carácter Unicode: '20 %'.
💡 Consejo: Como los ficheros están en UTF-8, puedes escribir directamente
©,€oá. Las entidades solo merecen la pena para caracteres invisibles, como , o conflictivos, como<.
Caracteres especiales
Los símbolos {, }, < y > tienen significado en JSX. Si los necesitas como texto:
<p>a > b</p> // ❌ Unexpected token. Did you mean `{'>'}` or `>`?
<p>a } b</p> // ❌ Unexpected token. Did you mean `{'}'}` or `}`?
<p>a {'>'} b</p> // ✅
<p>a > b</p> // ✅
Estos errores son de los pocos que te dicen exactamente qué hacer. Tanto Oxc como TypeScript (TS1382) proponen la misma corrección.
11. Escapado y XSS
Cross-Site Scripting (XSS) es uno de los ataques más comunes en la web: un atacante consigue que su código se ejecute en la página de otra persona. Si lo logra, ese código puede hacer todo lo que puede hacer tu propia aplicación: leer datos, llamar a tu API con la sesión del usuario o modificar lo que ve.
Imagina que la aplicación EMPRESA tiene comentarios de clientes sobre los productos. Un usuario malintencionado escribe esto:
Muy buen <b>portátil</b>.<img src="x" onerror="...código del atacante..."><script>...</script>
Si tu aplicación lo mete en la página como HTML, cada persona que lea ese comentario ejecutará el código del atacante.
React escapa por ti
Con React, el comportamiento por defecto es el seguro. Todo lo que pones entre llaves se trata como texto:
<p>{comentario}</p>
Esta es la salida real al convertir en HTML un texto y un atributo peligrosos:
<p><script>alert("x")</script> & 'y'</p>
<p title=""><script>alert('x')</script>"></p>
Los caracteres con significado en HTML se sustituyen por entidades, de modo que <script> es texto y nunca una etiqueta. Además, el atributo no puede cerrarse antes de tiempo con unas comillas.
En el navegador ni siquiera hace falta escapar: react-dom crea nodos de texto, y un nodo de texto nunca se analiza como HTML.
📘 Concepto: Escapar es sustituir los caracteres especiales de un lenguaje para que se traten como datos y no como código. En JSX ocurre automáticamente porque, a diferencia de una plantilla de texto, JSX separa la estructura de los datos desde el principio. Por eso una cadena nunca puede convertirse en etiqueta.
Figura 11. El peligro no está en la cadena, sino en quién la interpreta.
Por qué existe $$typeof
Queda un hueco que un atacante podría intentar usar. Si un elemento es solo un objeto, ¿no podría enviar desde una API un JSON con forma de elemento, con dangerouslySetInnerHTML en sus props? Por eso cada elemento lleva $$typeof con un Symbol, y JSON no puede representar símbolos. Hemos probado a pintar uno falsificado, con $$typeof como cadena:
Objects are not valid as a React child (found: object with keys {$$typeof, type, key, props}).
React lo rechaza, porque solo acepta como elemento lo que lleva el símbolo auténtico.
URLs javascript: 🔄 19.0
🔄 Cambia en React 19.0. React bloquea las URL
javascript:en atributos comohref,srcyaction: las sustituye por código que lanza un error. En React 18.3 las deja pasar y solo avisa en la consola. Si usas una versión anterior: valida tú cada URL que venga de datos externos con una lista de protocolos permitidos, como haceurlSegura.
El escapado no te salva aquí, porque la cadena es un atributo correcto y es el navegador quien la ejecuta al hacer clic. Imagina un perfil en el que cada empleado escribe la URL de su web:
// Una URL escrita por un usuario en su perfil
const web = 'javascript:window.atacado = true';
function urlSegura(texto: string) {
try {
const url = new URL(texto, location.origin);
return url.protocol === 'http:' || url.protocol === 'https:' ? url.href : undefined;
} catch {
return undefined;
}
}
export default function App() {
return (
<div className="tarjeta">
<p><a id="directo" href={web}>Web del empleado (sin validar)</a></p>
<p><a id="validado" href={urlSegura(web)}>Web del empleado (validada)</a></p>
<p><a id="bueno" href={urlSegura('https://empresa.es/carmen')}>Otra web (validada)</a></p>
</div>
);
}
Este es el href real en Chromium para cada enlace:
directo href=javascript:throw new Error('React has blocked a javascript: URL as a security precaution.')
validado href=null
bueno href=https://empresa.es/carmen
Al pulsar el primero, se lanza ese error y el código del atacante no se ejecuta. Lo comprobamos en las cinco líneas de versiones: en 18.3.1 el href queda como javascript:alert(1), con el aviso A future version of React will block javascript: URLs as a security precaution, y desde 19.0.9 queda bloqueado. Las variantes con mayúsculas o un espacio delante ( JaVaScRiPt:alert(1)) también se bloquean.
Aun así, sigue validando: el bloqueo solo cubre javascript:. Un href="data:text/html,..." llega al DOM sin cambios. Lo correcto es una lista de permitidos como la de urlSegura, que acepta solo http: y https:, y no una lista de prohibidos, que siempre se queda corta.
Otras puertas abiertas
React te protege de inyectar HTML desde una cadena, pero no de todo:
| Riesgo | Ejemplo | Defensa |
|---|---|---|
| HTML sin sanear | dangerouslySetInnerHTML | Sanear con DOMPurify (sección 12) |
| URLs de usuario | href={web} con data: | Lista de protocolos permitidos |
| Spread de datos externos | <div {...desdeApi} /> | Elegir las props una a una (sección 13) |
| Eludir React | ref.current.innerHTML = texto | Usar JSX; solo textContent |
| Datos en HTML del servidor | <script> con JSON incrustado en SSR | Se ve en el módulo 23 |
| Dependencias | Un paquete comprometido | Auditar: módulo 25 |
12. dangerouslySetInnerHTML
A veces tienes HTML que debe mostrarse como HTML: una descripción de producto escrita en un editor de texto enriquecido, una página de ayuda que viene de un CMS o Markdown ya convertido. Para eso React ofrece una salida de emergencia, con un nombre difícil de olvidar:
<div dangerouslySetInnerHTML={{ __html: htmlDelProducto }} />
Tiene un nombre largo y un objeto raro por diseño. La incomodidad es intencionada: dangerously se ve en cualquier revisión de código, y la clave __html te obliga a construir un objeto a propósito. Es difícil escribirlo sin querer.
Sus reglas, con los errores reales:
| Regla | Si la incumples |
|---|---|
El valor es un objeto { __html: ... } | props.dangerouslySetInnerHTML must be in the form {__html: ...} |
| No puede tener hijos a la vez | Can only set one of children or props.dangerouslySetInnerHTML. |
| No se usa en etiquetas vacías | br is a self-closing tag and must neither have children nor use dangerouslySetInnerHTML. |
Los tres casos, en el navegador
El siguiente ejemplo pinta el mismo comentario de tres maneras. La cookie simula una sesión:
import DOMPurify from 'dompurify';
// Simula una cookie de sesión que el atacante querría robar
document.cookie = 'sesion=clave-secreta-123';
// Comentario tal como lo devolvería la API: lo escribió un usuario cualquiera
const comentario =
'Muy buen <b>portátil</b>.<img src="x" onerror="' +
"document.getElementById('robado').textContent = 'Robado: ' + document.cookie" +
'"><script>window.scriptEjecutado = true</script>';
export default function App() {
return (
<>
<p id="robado" style={{ color: '#c62828', fontWeight: 600, minHeight: '1.5em', margin: '0 0 0.75rem' }} />
<section className="tarjeta">
<h2>1. Con llaves (React escapa)</h2>
<p id="llaves">{comentario}</p>
</section>
<section className="tarjeta">
<h2>2. dangerouslySetInnerHTML sin limpiar</h2>
<p id="crudo" dangerouslySetInnerHTML={{ __html: comentario }} />
</section>
<section className="tarjeta">
<h2>3. dangerouslySetInnerHTML con DOMPurify</h2>
<p id="limpio" dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(comentario) }} />
</section>
</>
);
}
Para el tercer caso, instala DOMPurify en tu proyecto:
npm install dompurify
Incluye sus propios tipos, así que no necesitas nada más.

Figura 12. El texto en rojo de arriba lo ha escrito el código del atacante, que ha leído la cookie. Solo lo provoca la segunda tarjeta.
Lo que ocurre en Chromium, comprobado por programa:
{
"robado": "Robado: sesion=clave-secreta-123",
"scriptEjecutado": false,
"limpio": "Muy buen <b>portátil</b>.<img src=\"x\">"
}
Analicemos cada caso:
- Con llaves. Se ve todo el texto, con sus
<b>y su<img>como caracteres. Es seguro y feo. - Sin sanear. El
<img>es una imagen de verdad, falla al cargar y suonerrorroba la cookie. ObservascriptEjecutado: false: el<script>se insertó, pero no se ejecutó, porque el navegador no ejecuta los scripts insertados medianteinnerHTML. Esto lleva a un error de seguridad muy extendido: creer que basta con vigilar<script>. Los atributoson*funcionan igual de bien. - Con DOMPurify. Conserva el HTML inocuo (
<b>,<img src="x">) y elimina lo activo (onerror,<script>). El comentario se ve con formato y sin ataque.
⚠️ Importante: Nunca sanees con expresiones regulares propias. Los navegadores aceptan HTML mal formado de formas muy imaginativas, y hay muchísimas variantes de cada ataque. Usa siempre una biblioteca mantenida, como DOMPurify, que se apoya en el analizador del propio navegador.
Cómo decidir
- ¿De verdad necesitas HTML? Si solo quieres negritas o enlaces, usa un formato limitado y conviértelo a JSX, como en el ejercicio 8. Es lo más seguro, porque nunca se interpreta HTML.
- ¿Quién escribe el HTML? Si es contenido de tu propio equipo, generado en tu compilación, el riesgo es bajo. Si lo escribe cualquier usuario, debes sanearlo siempre.
- Sanea en el último momento, justo antes de pintar. Sanear al guardar en la base de datos no basta: las reglas cambian y puede haber datos antiguos.
Otras dos cosas que debes saber: el HTML insertado así no contiene componentes de React, y sus enlaces no pasan por tu enrutador. Es una isla que React no gestiona. DOMPurify necesita un DOM, así que en el servidor exige una configuración aparte, y por eso volveremos a ello en el módulo 23. Si tu sitio exige Trusted Types, consulta el módulo 17.
Cuándo se reasigna innerHTML 🔄 19.3
🔄 Cambia en React 19.3. React no vuelve a asignar
innerHTMLsi la cadena__htmlno ha cambiado, aunque el objeto sea nuevo. Hasta 19.2 lo reasignaba en cada render en el que cambiara el objeto, y destruía los nodos internos. Si usas una versión anterior: guarda el objeto{ __html }en una constante o conuseMemopara que no cambie entre renders.
Lo hemos comprobado volviendo a renderizar un elemento con la misma cadena y otro title: en 19.2.9 el <b> interior es un nodo nuevo, y en 19.3.1 sigue siendo el mismo. Importa si un script externo ha modificado ese HTML, o si contiene imágenes o un <iframe>, que se recargarían sin motivo.
13. Atributos con spread
El operador de propagación de JavaScript también funciona en JSX: {...objeto} convierte cada propiedad del objeto en un atributo. Si necesitas repasarlo, consulta el Curso de JavaScript.
const datos = { type: 'email', placeholder: 'Correo', required: true };
<input {...datos} />
// equivale a
<input type="email" placeholder="Correo" required />
El orden importa
Se aplica como en un objeto normal: si hay un atributo repetido, gana el último. Esta es la salida real, con datos = { type: 'password', placeholder: 'Clave' }:
<input type="text" {...datos} /> // <input type="password" placeholder="Clave"/>
<input {...datos} type="text" /> // <input type="text" placeholder="Clave"/>
<input {...datos} placeholder={undefined} /> // <input type="password"/>
Esto da una regla práctica:
- Lo que va antes del spread es un valor por defecto que se puede sobrescribir.
- Lo que va después es un valor forzado que no se puede sobrescribir.
TypeScript detecta el caso absurdo: si datos tiene un type obligatorio, el primer type="text" nunca se usará, y responde TS2783: 'type' is specified more than once, so this usage will be overwritten.
Un botón reutilizable
El uso más habitual es un componente que envuelve una etiqueta HTML y deja pasar todos sus atributos:
import type { ComponentProps } from 'react';
type BotonProps = ComponentProps<'button'> & {
variante?: 'primario' | 'peligro';
};
function Boton({ variante = 'primario', className = '', ...resto }: BotonProps) {
return (
<button
type="button"
{...resto}
className={`boton boton-${variante} ${className}`.trim()}
/>
);
}
export default function App() {
return (
<div className="tarjeta fila">
<Boton onClick={() => console.log('Guardado')}>Guardar</Boton>
<Boton variante="peligro" aria-label="Eliminar empleado 110">Eliminar</Boton>
<Boton className="grande" disabled title="Sin permisos">Exportar</Boton>
<Boton type="submit" data-accion="enviar">Enviar</Boton>
</div>
);
}
Este es el HTML real de los botones:
<button type="button" class="boton boton-primario">Guardar</button>
<button type="button" aria-label="Eliminar empleado 110" class="boton boton-peligro">Eliminar</button>
<button type="button" disabled="" title="Sin permisos" class="boton boton-primario grande">Exportar</button>
<button type="submit" data-accion="enviar" class="boton boton-primario">Enviar</button>
Mira cómo se aplica la regla del orden:
type="button"va antes del spread, así que es un valor por defecto. Evita que el botón envíe un formulario sin querer, y el cuarto botón lo cambia asubmit.classNameva después, para que nadie pueda perder las clases base. La clase que llega de fuera se combina con ellas....restoen los parámetros es JavaScript normal: recoge lo que no hemos nombrado. Asívarianteno llega al DOM, donde provocaría un aviso.ComponentProps<'button'>añade todos los atributos válidos de un<button>con sus tipos. Se ve a fondo en el módulo 13.
Los riesgos
Datos externos. Nunca hagas spread de un objeto que venga de fuera:
// Lo que llega de un API que no controlas: JSON puro
const desdeApi = JSON.parse(`{
"title": "Perfil",
"dangerouslySetInnerHTML": {
"__html": "<img src=x onerror=\\"window.atacado = true\\">"
}
}`);
export default function App() {
return <div className="tarjeta" {...desdeApi} />;
}
En Chromium, window.atacado queda en true: el ataque se ejecuta. Un JSON no puede falsificar un elemento, gracias a $$typeof, pero sí puede falsificar props, y dangerouslySetInnerHTML es una prop. Por eso hay que elegirlas una a una: <div title={desdeApi.title} />.
key en el spread. No se puede pasar key dentro del objeto:
const props = { key: 'a', id: 'b' };
<p {...props} />
La salida sigue siendo correcta, pero la consola muestra A props object containing a "key" prop is being spread into JSX. Escríbela aparte:
const { key, ...resto } = props;
<p key={key} {...resto} />
Hay una sutileza de compilación: si escribes key después del spread, el compilador no puede usar jsx(), y recurre a createElement. Esta es la salida real:
<div {...props} key="k">x</div> → createElement("div", { ...props, key: "k" }, "x")
<div key="k" {...props}>x</div> → jsx("div", { ...props, children: "x" }, "k")
Abuso. Con demasiado spread, nadie sabe qué props llegan a cada sitio, y se pierden los avisos útiles. Úsalo en componentes envoltorio, y sé explícito en el resto.
14. JSX como valor
Esta es la idea más potente del módulo. Como un elemento es un objeto, puedes hacer con él lo mismo que con cualquier otro valor:
import type { ReactNode } from 'react';
type Tipo = 'info' | 'exito' | 'error';
// JSX guardado en una constante, fuera de cualquier componente
const pie = <small>Datos de la API EMPRESA</small>;
// JSX como valor de un objeto: una tabla de consulta
const iconos: Record<Tipo, ReactNode> = {
info: <span aria-hidden="true">ℹ️</span>,
exito: <span aria-hidden="true">✅</span>,
error: <span aria-hidden="true">❌</span>,
};
// Una función normal que devuelve JSX
function resaltar(texto: string) {
return <strong>{texto.toUpperCase()}</strong>;
}
// JSX como prop: Aviso no sabe qué le pasan, solo dónde colocarlo
function Aviso({ tipo, accion, texto }: { tipo: Tipo; accion?: ReactNode; texto: ReactNode }) {
return (
<div className={`aviso aviso-${tipo}`}>
{iconos[tipo]}
<span style={{ flex: 1 }}>{texto}</span>
{accion}
</div>
);
}
export default function App() {
const hayPendientes = true;
// JSX en una variable, según una condición
let resumen = <p>Sin pedidos pendientes.</p>;
if (hayPendientes) {
resumen = <p>Hay {resaltar('3 pedidos')} pendientes.</p>;
}
// JSX dentro de un array
const etiquetas = [<li key="a">Madrid</li>, <li key="b">Valencia</li>];
return (
<section className="tarjeta">
<h2>JSX como valor</h2>
{resumen}
<Aviso tipo="info" texto="Se ha sincronizado la lista de clientes." />
<Aviso tipo="exito" texto={<>Empleado <b>110</b> guardado.</>} />
<Aviso tipo="error" texto="No se pudo enviar." accion={<button type="button">Reintentar</button>} />
<ul>{etiquetas}</ul>
{pie}
</section>
);
}

Figura 13. Todo lo que ves son valores que se guardan, se pasan y se colocan.
Fíjate en cada caso:
| Uso | Dónde aparece |
|---|---|
| En una constante fuera del componente | pie |
| En una variable, según una condición | resumen, asignado en un if |
| Devuelto por una función | resaltar() |
| En un objeto, como tabla de consulta | iconos[tipo] |
| Como prop | texto y accion en <Aviso> |
| En un array | etiquetas |
| Como fragmento pasado por prop | texto={<>...</>} |
El caso del if responde a lo que dejamos pendiente en la sección 4. Aunque no puedes escribir un if dentro del JSX, sí puedes escribir JSX dentro de un if.
La tabla de consulta iconos sustituye a una cadena de if o a un switch. Con Record<Tipo, ReactNode>, TypeScript exige una entrada por cada valor posible: si añades un tipo nuevo y olvidas el icono, no compila.
Pasar JSX como prop es la base de la composición: Aviso no sabe si recibe un botón, un enlace o nada. Solo sabe dónde colocarlo. Esto se desarrolla en el módulo 03, con la prop especial children.
Crear un elemento no ejecuta nada
Un punto que suele sorprender. Mira qué pasa al crear un elemento y no usarlo:
function InformeCaro() {
console.log('InformeCaro se está ejecutando');
return <p>Informe</p>;
}
export default function App() {
const sinUsar = <InformeCaro />;
console.log('¿Es un objeto?', typeof sinUsar === 'object');
console.log('type es la función:', sinUsar.type === InformeCaro);
return <p className="tarjeta">El informe no aparece en esta pantalla.</p>;
}
¿Es un objeto? true
type es la función: true
El mensaje InformeCaro se está ejecutando no aparece nunca. Escribir <InformeCaro /> solo crea el objeto que dice “quiero esto aquí”. Quien llama a la función es React, y solo si ese elemento acaba en el árbol que se devuelve.
Por eso nunca debes llamar a un componente como una función normal, InformeCaro(), en lugar de escribir <InformeCaro />. Si lo haces, React no lo trata como componente, y fallan el estado y los efectos que tenga dentro. El módulo 05 explica el porqué. Una función auxiliar como resaltar() es otra cosa: se usa con minúscula, se llama de forma explícita y no tiene estado.
Los tipos: ReactNode, ReactElement y JSX.Element
| Tipo | Qué admite | Cuándo usarlo |
|---|---|---|
ReactNode | Todo lo que se puede pintar: texto, números, null, booleanos, arrays, elementos | Para props y children. Es la opción por defecto |
ReactElement | Solo un elemento | Si necesitas que sea un elemento, para leer su type o sus props |
JSX.Element | Un elemento; es lo que produce una expresión JSX | Para el tipo de retorno, que casi nunca hace falta escribir |
El espacio de nombres JSX 🔄 19.0
🔄 Cambia en React 19.0.
@types/react19 eliminó el espacio de nombresJSXglobal. Si usas una versión anterior: en@types/react18.3 funcionan las dos formas, así que puedes escribir yaReact.JSX.Element, o importarJSXdereact, y migrar sin cambios.
Si copias código antiguo con const x: JSX.Element, TypeScript responde:
TS2503: Cannot find namespace 'JSX'.
La solución es importarlo:
import type { JSX } from 'react';
export const nota: JSX.Element = <p>Nota</p>;
Hemos comprobado que esa línea compila con @types/react 19.3.0, y que en 18.3.31 existen a la vez el espacio global y el de React.
15. Depuración y errores frecuentes
Con esta tabla resuelves la mayoría de los errores de este módulo. Aparecen en tres momentos: al compilar, en los tipos y en la consola.
| Mensaje | Dónde | Causa | Solución |
|---|---|---|---|
Adjacent JSX elements must be wrapped in an enclosing tag. | Oxc | Varias raíces | Envuelve con <>...</> |
TS2657: JSX expressions must have one parent element. | tsc | Varias raíces | Igual |
Unexpected token | Oxc | <br>, <!-- --> o if entre llaves | Cierra la etiqueta, usa {/* */} o saca la lógica |
TS17008: JSX element 'x' has no corresponding closing tag. | tsc | Etiqueta sin cerrar o <T> en .tsx | Cierra la etiqueta o usa <T,> |
Expected corresponding JSX closing tag for 'div'. | Oxc | Etiquetas cruzadas | Revisa el orden de cierre |
JSX attributes must only be assigned a non-empty 'expression'. | Oxc | a={} | Pon un valor o quita el atributo |
Unexpected token. Did you mean {'>'} or >? | Oxc y tsc | > o } en el texto | Usa {'>'} o > |
TS2322 ... { class: string; } | tsc | class, for, atributo inventado | className, htmlFor, data-* |
TS2559 ... no properties in common | tsc | style como cadena | Usa un objeto |
TS2353 ... ''--color'' does not exist | tsc | Variable CSS | as CSSProperties |
TS2339 ... does not exist on type 'JSX.IntrinsicElements' | tsc | Componente en minúscula | Inicial en mayúscula |
TS2503: Cannot find namespace 'JSX'. | tsc | JSX global en React 19 | import type { JSX } from 'react' |
Objects are not valid as a React child | Ejecución | Objeto o Date entre llaves | Pinta un campo o da formato |
The style prop expects a mapping... | Ejecución | style como cadena | Usa un objeto |
Functions are not valid as a React child | Consola | {Ficha} en lugar de <Ficha /> | Crea el elemento o llama a la función |
Invalid DOM property class | Consola | Atributo HTML sin traducir | Usa el nombre de JSX |
In HTML, <div> cannot be a child of <tr>. | Consola | HTML no válido | Usa un fragmento |
Each child in a list should have a unique "key" prop. | Consola | Array sin key | Módulo 06 |
💡 Consejo: Lee el HTML resultante con la pestaña Elements del navegador y el árbol de componentes con las React DevTools, que instalaste en el módulo 01. Un
0suelto, un espacio perdido o unclass="boton false"se detectan al momento.
Ten en cuenta que npm run dev no comprueba tipos: Vite elimina los tipos sin analizarlos. Puede que un class llegue al navegador con un simple aviso en la consola, y que solo falle npm run build. Instala la extensión de TypeScript en tu editor, o ejecuta npx tsc -b de vez en cuando.
16. Ejercicios
Ejercicio 1: De HTML a JSX
Convierte este formulario a JSX. Busca todos los cambios necesarios: hay más de diez.
<!-- Alta rápida -->
<form class="tarjeta" onsubmit="return false">
<label for="nombre">Nombre</label><br>
<input id="nombre" type="text" maxlength="40" required autofocus><br>
<label for="dept">Departamento</label><br>
<select id="dept" tabindex="2">
<option value="10">DIRECCIÓN</option>
<option value="20" selected>VENTAS</option>
</select><br>
<img src="/favicon.svg" alt="Logotipo" style="width: 40px; margin-top: 8px"><br>
<button class="boton" disabled>Guardar</button>
</form>
Ver solución
export default function App() {
return (
<>
{/* Alta rápida */}
<form className="tarjeta" onSubmit={(evento) => evento.preventDefault()}>
<label htmlFor="nombre">Nombre</label>
<br />
<input id="nombre" type="text" maxLength={40} required autoFocus />
<br />
<label htmlFor="dept">Departamento</label>
<br />
<select id="dept" tabIndex={2} defaultValue="20">
<option value="10">DIRECCIÓN</option>
<option value="20">VENTAS</option>
</select>
<br />
<img src="/favicon.svg" alt="Logotipo" style={{ width: 40, marginTop: 8 }} />
<br />
<button className="boton" disabled>Guardar</button>
</form>
</>
);
}
Cambios realizados:
- El comentario pasa a
{/* */}, y como es hermano de<form>hace falta un fragmento. Podrías moverlo fuera del JSX, con un comentario normal de JavaScript, y ahorrarte el fragmento. classpasa aclassName, yforahtmlFor.onsubmitpasa aonSubmit, con una función. Elreturn falseno sirve en React; se usapreventDefault(), que verás en el módulo 05.<br>,<input>e<img>se cierran.maxlength,tabindexyautofocuspasan amaxLength={40},tabIndex={2}yautoFocus: camelCase, y los números entre llaves.stylepasa a ser un objeto.selecteddesaparece. En React se indica condefaultValueen el<select>; si lo dejas, la consola avisa:Use the `defaultValue` or `value` props on <select> instead of setting `selected` on <option>.Se explica en el módulo 08.
Ejercicio 2: Compilador humano
Escribe a mano, sin JSX y usando jsx y jsxs de react/jsx-runtime, el equivalente de este fragmento. Comprueba con renderToStaticMarkup que ambos generan el mismo HTML.
<section className="panel">
<h2>Ventas</h2>
<Resumen total={1200} />
</section>
Ver solución
import { jsx, jsxs } from 'react/jsx-runtime';
import { renderToStaticMarkup } from 'react-dom/server';
function Resumen({ total }: { total: number }) {
return <p>Total: {total}</p>;
}
const conJsx = (
<section className="panel">
<h2>Ventas</h2>
<Resumen total={1200} />
</section>
);
const aMano = jsxs('section', {
className: 'panel',
children: [
jsx('h2', { children: 'Ventas' }),
jsx(Resumen, { total: 1200 }),
],
});
const iguales = renderToStaticMarkup(conJsx) === renderToStaticMarkup(aMano);
export default function App() {
return (
<div className="tarjeta">
<p id="iguales">¿Mismo HTML? {String(iguales)}</p>
{aMano}
</div>
);
}
La pantalla muestra ¿Mismo HTML? true. section tiene dos hijos, así que usa jsxs con un array. h2 y Resumen tienen uno o ninguno, así que usan jsx. Resumen va sin comillas porque es una referencia a la función. Este ejercicio es solo didáctico: nunca escribirás esto en una aplicación.
Ejercicio 3: Tarjeta de producto
Crea un componente TarjetaProducto que reciba un producto y muestre nombre, descripción y precio en euros, con Intl.NumberFormat. Si hay existencias, debe decir cuántas. Si no hay, “Sin existencias”. Si está descatalogado, una insignia. Prueba con estos tres productos de la API, y evita que aparezca un 0 suelto:
| productoId | nombre | precio | stock | descatalogado |
|---|---|---|---|---|
| 1 | Portátil UltraBook 14 | 899 | 25 | false |
| 8 | Pack teclado y ratón | 34.5 | 0 | false |
| 14 | Altavoz Inteligente | 49 | 0 | true |
Ver solución
type Producto = {
productoId: number;
nombre: string;
descripcion: string;
precio: number;
stock: number;
descatalogado: boolean;
};
const euros = new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' });
function TarjetaProducto({ producto }: { producto: Producto }) {
const { nombre, descripcion, precio, stock, descatalogado } = producto;
return (
<article className="tarjeta">
<h2>{nombre}</h2>
<p>{descripcion}</p>
<p><strong>{euros.format(precio)}</strong></p>
{stock > 0 && <p>Quedan {stock} unidades</p>}
{stock === 0 && !descatalogado && <p>Sin existencias</p>}
{descatalogado && <p><span className="insignia insignia-cancelado">Descatalogado</span></p>}
</article>
);
}
const productos: Producto[] = [
{ productoId: 1, nombre: 'Portátil UltraBook 14', descripcion: 'Portátil ligero de 14 pulgadas, 16 GB RAM', precio: 899, stock: 25, descatalogado: false },
{ productoId: 8, nombre: 'Pack teclado y ratón', descripcion: 'Conjunto inalámbrico de teclado y ratón', precio: 34.5, stock: 0, descatalogado: false },
{ productoId: 14, nombre: 'Altavoz Inteligente', descripcion: 'Altavoz con asistente de voz', precio: 49, stock: 0, descatalogado: true },
];
export default function App() {
return (
<>
<TarjetaProducto producto={productos[0]} />
<TarjetaProducto producto={productos[1]} />
<TarjetaProducto producto={productos[2]} />
</>
);
}

Figura 14. Los tres casos, sin ningún cero suelto.
Las condiciones comparan con stock > 0 y stock === 0, y producen siempre un booleano. El Intl.NumberFormat se crea una sola vez fuera del componente, porque crearlo es costoso.
Ejercicio 4: Caza los errores
Este componente tiene al menos diez errores. Encuéntralos todos antes de mirar la solución, y usa los mensajes del compilador como pista. Atención: al corregir los de sintaxis aparecerán otros, porque TypeScript no comprueba los tipos hasta que puede analizar el fichero.
const empleado = { nombre: 'Luis', alta: new Date('2012-03-01'), jefes: 0, activo: true };
export default function App() {
return (
<div class="tarjeta" style="padding: 8px">
<label for="nota">Nota</label>
<input id="nota" tabindex="1">
<!-- Datos del empleado -->
<h2>{empleado.nombre}</h2>
<p>Alta: {empleado.alta}</p>
{empleado.jefes && <p>Tiene jefes</p>}
<button onclick="guardar()">Guardar</button>
</div>
<p>Fin de la ficha</p>
);
}
Ver solución
const empleado = { nombre: 'Luis', alta: new Date('2012-03-01'), jefes: 0, activo: true };
function guardar() {
console.log('Guardado');
}
export default function App() {
return (
<>
<div className="tarjeta" style={{ padding: 8 }}>
<label htmlFor="nota">Nota</label>
<input id="nota" tabIndex={1} />
{/* Datos del empleado */}
<h2>{empleado.nombre}</h2>
<p>Alta: {empleado.alta.toLocaleDateString('es-ES')}</p>
{empleado.jefes > 0 && <p>Tiene jefes</p>}
<button onClick={guardar}>Guardar</button>
</div>
<p>Fin de la ficha</p>
</>
);
}
- Dos raíces:
<div>y<p>. Se envuelven en un fragmento. classpasa aclassName.stylecomo cadena pasa a ser un objeto. Con8se entiende8px.forpasa ahtmlFor.tabindex="1"pasa atabIndex={1}.<input>se cierra.<!-- -->pasa a{/* */}.{empleado.alta}es unDate, y lanzaría un error. Se formatea.{empleado.jefes && ...}pintaría un0. Pasa aempleado.jefes > 0.onclick="guardar()"pasa aonClick={guardar}, y además había que definir la función.
Las raíces múltiples y las etiquetas sin cerrar salen primero, como errores de sintaxis (TS2657, TS17008). Solo después de corregirlos aparecen los TS2322. Los fallos 8 y 9 son de otro tipo: el 8 lo detecta TypeScript, y el 9 no lo detecta nadie, porque es código válido.
Ejercicio 5: Insignias con tabla de consulta
Crea un componente InsigniaEstado que reciba un estado de pedido (ENTREGADO, ENVIADO, PENDIENTE o CANCELADO) y muestre un icono y un texto. Usa un objeto como tabla de consulta en lugar de if o switch, de forma que TypeScript exija una entrada por estado.
Ver solución
import type { ReactNode } from 'react';
type Estado = 'ENTREGADO' | 'ENVIADO' | 'PENDIENTE' | 'CANCELADO';
const presentacion: Record<Estado, { texto: string; icono: ReactNode }> = {
ENTREGADO: { texto: 'Entregado', icono: <span aria-hidden="true">✅</span> },
ENVIADO: { texto: 'En camino', icono: <span aria-hidden="true">🚚</span> },
PENDIENTE: { texto: 'Pendiente', icono: <span aria-hidden="true">⏳</span> },
CANCELADO: { texto: 'Cancelado', icono: <span aria-hidden="true">❌</span> },
};
function InsigniaEstado({ estado }: { estado: Estado }) {
const { texto, icono } = presentacion[estado];
return (
<span className={`insignia insignia-${estado.toLowerCase()}`}>
{icono} {texto}
</span>
);
}
export default function App() {
return (
<div className="tarjeta fila">
<InsigniaEstado estado="ENTREGADO" />
<InsigniaEstado estado="ENVIADO" />
<InsigniaEstado estado="PENDIENTE" />
<InsigniaEstado estado="CANCELADO" />
</div>
);
}

Figura 15. Cuatro estados, un solo componente.
Record<Estado, ...> obliga a tener las cuatro claves. Los iconos son JSX guardado en un objeto y marcado con aria-hidden, porque el texto ya transmite la información y un lector de pantalla no debe anunciar el emoji.
Ejercicio 6: Barra de existencias
Crea un componente BarraStock con nombre, stock y capacidad. Muestra una barra con el porcentaje, en rojo por debajo del 20 %, en naranja por debajo del 50 % y en verde en el resto. Pasa el color con la variable CSS --color-barra y añade los atributos ARIA de una barra de progreso.
Ver solución
import type { CSSProperties } from 'react';
function BarraStock({ nombre, stock, capacidad }: { nombre: string; stock: number; capacidad: number }) {
const porcentaje = Math.min(100, Math.round((stock / capacidad) * 100));
const color = porcentaje < 20 ? '#c62828' : porcentaje < 50 ? '#ef6c00' : '#2e7d32';
return (
<div style={{ marginBottom: 12 }}>
<div>{nombre}: {stock} de {capacidad}</div>
<div
className="barra"
role="progressbar"
aria-label={`Stock de ${nombre}`}
aria-valuenow={porcentaje}
aria-valuemin={0}
aria-valuemax={100}
style={{ '--color-barra': color } as CSSProperties}
>
<div className="barra-relleno" style={{ width: `${porcentaje}%` }} />
</div>
</div>
);
}
export default function App() {
return (
<section className="tarjeta">
<h2>Existencias</h2>
<BarraStock nombre="Ratón inalámbrico" stock={200} capacidad={250} />
<BarraStock nombre="Portátil Gaming 16" stock={10} capacidad={30} />
<BarraStock nombre="Torre Workstation" stock={4} capacidad={30} />
<BarraStock nombre="Pack teclado y ratón" stock={0} capacidad={50} />
</section>
);
}

Figura 16. Los colores dependen de los datos; la forma de la barra, del CSS.
aria-valuenow y compañía son atributos con guion, y no cambian de nombre. Sus valores numéricos van entre llaves. El rol progressbar permite que un lector de pantalla anuncie el dato, algo que veremos en el módulo 24.
Ejercicio 7: Filas dobles en una tabla
Pinta los departamentos 10, 20 y 70 en una tabla. Cada departamento ocupa dos filas: una con id, nombre y localidad, y otra, resaltada, con el presupuesto. El presupuesto de 70 es null, y debe aparecer “sin asignar”.
Ver solución
import { Fragment } from 'react';
const departamentos = [
{ deptId: 10, nombre: 'DIRECCIÓN', localidad: 'Madrid', presupuesto: 500000 },
{ deptId: 20, nombre: 'VENTAS', localidad: 'Madrid', presupuesto: 350000 },
{ deptId: 70, nombre: 'INVESTIGACIÓN', localidad: 'Bilbao', presupuesto: null },
];
const euros = new Intl.NumberFormat('es-ES', {
style: 'currency',
currency: 'EUR',
maximumFractionDigits: 0,
});
export default function App() {
return (
<section className="tarjeta">
<h2>Departamentos</h2>
<table>
<thead>
<tr><th>Id</th><th>Nombre</th><th>Localidad</th></tr>
</thead>
<tbody>
{departamentos.map((d) => (
<Fragment key={d.deptId}>
<tr>
<td>{d.deptId}</td>
<td>{d.nombre}</td>
<td>{d.localidad}</td>
</tr>
<tr className="resaltada">
<td colSpan={3}>
Presupuesto: {d.presupuesto === null ? 'sin asignar' : euros.format(d.presupuesto)}
</td>
</tr>
</Fragment>
))}
</tbody>
</table>
</section>
);
}
No puedes envolver las dos <tr> en un <div>, porque <tbody> solo admite filas. Y no puedes usar <> porque necesitas key. Solo queda Fragment, el caso de uso para el que existe. Fíjate en colSpan, con mayúscula, y en la comparación === null, que evita tratar un presupuesto 0 como si no existiera.
Ejercicio 8: Negritas sin dangerouslySetInnerHTML
Crea ComentarioFormateado, que reciba un texto y muestre en <strong> lo que esté entre asteriscos: Envío *muy rápido*. No uses dangerouslySetInnerHTML. Comprueba que un texto con <img onerror> no se ejecuta.
Ver solución
function ComentarioFormateado({ texto }: { texto: string }) {
// Con un grupo de captura, split conserva las partes que coinciden
const partes = texto.split(/(\*[^*]+\*)/);
return (
<p>
{partes.map((parte, indice) =>
parte.startsWith('*') && parte.endsWith('*') && parte.length > 2 ? (
<strong key={indice}>{parte.slice(1, -1)}</strong>
) : (
parte
),
)}
</p>
);
}
export default function App() {
return (
<div className="tarjeta">
<ComentarioFormateado texto="Envío *muy rápido*, pero el embalaje *no* era bueno." />
<ComentarioFormateado texto={'Intento de ataque: *<img src=x onerror="alert(1)">* sin efecto.'} />
</div>
);
}
Este es el HTML generado:
Envío <strong>muy rápido</strong>, pero el embalaje <strong>no</strong> era bueno.
Intento de ataque: <strong><img src=x onerror="alert(1)"></strong> sin efecto.
La técnica es importante: en lugar de construir una cadena de HTML, construyes un array de valores, con cadenas y elementos. Como son cadenas normales en JSX, siguen escapadas. Es lo que hacen las bibliotecas serias de Markdown para React. Aquí el índice es una key aceptable, porque las partes de una cadena fija no se reordenan; el módulo 06 explica cuándo no lo es.
Ejercicio 9 (propuesto): Enlace externo seguro
Crea un componente EnlaceExterno que reciba href, y use ComponentProps<'a'> y spread. Debe aceptar solo http: y https:; si no, debe pintar el texto sin enlace. Además, debe forzar target="_blank" y rel="noopener noreferrer" de forma que quien lo use no pueda cambiarlos. Piensa en qué lado del spread debe ir cada atributo.
Ejercicio 10 (propuesto): Lista de definición
Crea un componente Detalles que reciba un objeto Record<string, ReactNode> y lo pinte como <dl>, con un Fragment con key por cada par. Úsalo con los datos de un empleado, incluido un enlace mailto: como valor.
Ejercicio 11 (propuesto): Ficha de ayuda saneada
Crea un componente AyudaHtml que reciba una cadena HTML y la pinte con DOMPurify, permitiendo solo las etiquetas p, b, i, ul, li y a. Consulta la opción ALLOWED_TAGS y comprueba con un texto malicioso que funciona.
Ejercicio 12 (propuesto): Icono SVG con tamaño
Convierte a JSX un icono SVG copiado de cualquier web, con stroke-width y fill-rule. Haz un componente que reciba tamano y titulo. Si llega titulo, añade <title> y role="img"; si no, marca el icono con aria-hidden.
Resumen
| Concepto | API | Idea clave |
|---|---|---|
| JSX | <etiqueta /> | Sintaxis que se traduce a llamadas a funciones; no es HTML |
| Transformación | jsx, jsxs, jsxDEV | La hace Oxc en Vite; tsc solo comprueba tipos |
| Elemento | { type, key, props } | Objeto inmutable que describe la interfaz |
| Componente | function Ficha() | Función que llama React; un elemento no la ejecuta |
| Raíz única | <>...</> | Cada raíz es una llamada; una función devuelve un valor |
| Inicial | <ficha />, <Ficha /> | Minúscula, cadena; mayúscula, referencia |
| Expresiones | {valor} | Solo expresiones; if y for van antes del return |
| Valores | ReactNode | true, false, null y undefined no pintan; 0 sí; un objeto lanza error |
| Atributos | className, htmlFor | camelCase; data-* y aria-* no cambian |
| Booleanos | disabled={false} | false quita el atributo; "false" lo pone |
| Quitar atributos | undefined, null | Eliminan el atributo |
| Clases | className={`a ${b}`} | En plantillas usa ternarios, no && |
| Estilos | style={{ fontSize: 14 }} | Objeto en camelCase; un número añade px |
| Variables CSS | as CSSProperties | Llevan datos al CSS; !important no funciona |
| Fragmentos | <>, <Fragment key> | Agrupan sin crear nodos |
| Comentarios | {/* */} | // en el texto se ve en pantalla |
| Espacios | {' '} | Un salto junto a una etiqueta se pierde |
| Entidades | | Se decodifican en JSX, pero no en cadenas |
| Escapado | {texto} | Las cadenas nunca se convierten en etiquetas |
| HTML crudo | dangerouslySetInnerHTML | Sanea siempre con DOMPurify; onerror se ejecuta |
| URLs | href={urlSegura(x)} | Lista de protocolos permitidos; bloqueo de javascript: desde 19.0 |
| Spread | {...resto} | Gana el último; nunca con datos externos |
| JSX como valor | ReactNode | Se guarda, se devuelve y se pasa como prop |
| Tipos | import type { JSX } from 'react' | El JSX global no existe en React 19 |