Módulo 03: Componentes y props
Objetivos del módulo
- Escribir componentes función y entender por qué su nombre empieza por mayúscula
- Distinguir componente, elemento y render, y saber qué puede devolver un componente
- Pasar datos con props y tiparlas con TypeScript: obligatorias, opcionales, uniones, funciones y objetos
- Dar valores por defecto con desestructuración y saber por qué
defaultPropsya no funciona - Entender por qué las props son de solo lectura y cómo fluyen los datos en una sola dirección
- Componer interfaces con
childreny con huecos en forma de props - Escribir componentes puros y detectar las impurezas
- Leer una interfaz como un árbol de componentes
- Elegir entre exportación por defecto y con nombre, y organizar los ficheros de un proyecto
- Recibir
refcomo una prop normal (React 19.0) y conocerforwardRef, su alternativa en versiones anteriores
1. Qué es un componente
Una página de gestión repite las mismas piezas una y otra vez: tarjetas, insignias, filas de tabla, botones. En HTML puro copiarías el mismo marcado en cada sitio, y un cambio de diseño te obligaría a buscarlo todo. React propone otra cosa: das nombre a cada pieza y la reutilizas como si fuera una etiqueta nueva.
📘 Concepto: Un componente es una función de JavaScript que recibe datos y devuelve una descripción de lo que debe aparecer en pantalla. Es la unidad con la que se construye toda aplicación React.
Este es uno completo:
function FichaEmpleado() {
return (
<article className="tarjeta">
<h2>Ana García López</h2>
<p>DIRECTOR GENERAL</p>
</article>
);
}
export default function App() {
return (
<main>
<h1>Plantilla</h1>
<FichaEmpleado />
<FichaEmpleado />
</main>
);
}
FichaEmpleado es una función normal: no hereda de nada ni se registra en ningún sitio. App la usa con la sintaxis <FichaEmpleado />, igual que usaría <p>. Las dos fichas son idénticas porque aún no le hemos pasado datos; eso lo arreglan las props en la sección 3.

Figura 1. El mismo componente usado dos veces. Falta decirle a cada uso qué datos debe mostrar.
Las tres reglas básicas
- El nombre empieza por mayúscula. Es lo único que distingue
<FichaEmpleado />de una etiqueta HTML. - Devuelve una sola raíz. Si necesitas varias, usa un fragmento
<>...</>, como viste en el módulo 02. - Se define en el nivel superior del módulo, nunca dentro de otro componente (lo veremos en la sección 2).
La primera regla no es una convención de estilo. Cuando el compilador de JSX ve una etiqueta en minúsculas, la trata como una cadena ('tarjeta'); cuando la ve en mayúsculas, la trata como una referencia a una variable (Tarjeta). Por eso esto falla:
function tarjeta() {
return <div>Hola</div>;
}
// ❌ MAL: React busca una etiqueta HTML llamada <tarjeta>
const elemento = <tarjeta />;
TypeScript lo detecta al compilar:
error TS2339: Property 'tarjeta' does not exist on type 'JSX.IntrinsicElements'.
Si te saltas TypeScript, el navegador crea una etiqueta vacía <tarjeta></tarjeta> y React avisa en la consola:
The tag <tarjeta> is unrecognized in this browser. If you meant to render a React component, start its name with an uppercase letter.
Prepara el proyecto
Para probar los ejemplos crea un proyecto como en el módulo 01:
npm create vite@latest modulo-03 -- --template react-ts
cd modulo-03
npm install
npm run dev
Cada ejemplo sustituye a src/App.tsx, salvo que el texto indique otros ficheros. 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; }
h1 { margin: 0 0 1rem; font-size: 1.5rem; color: #1a237e; }
.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; }
.tarjeta p { margin: 0.25rem 0; }
.tarjeta footer { margin-top: 0.75rem; padding-top: 0.5rem; border-top: 1px solid #e8eaf6; color: #546e7a; font-size: 0.9rem; }
.rejilla { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 1rem; max-width: 820px; }
.rejilla .tarjeta { margin: 0; }
.fila { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; margin-bottom: 0.75rem; }
.insignia { display: inline-block; padding: 0.1rem 0.6rem; border-radius: 999px; font-size: 0.8rem; font-weight: 600; background: #eceff1; color: #37474f; }
.insignia-exito { background: #c8e6c9; color: #1b5e20; }
.insignia-aviso { background: #ffe0b2; color: #8a4b00; }
.insignia-peligro { background: #ffcdd2; color: #8e1b1b; }
.aviso { border-left: 4px solid #ff7043; background: #fff3e0; padding: 0.5rem 0.75rem; max-width: 590px; margin: 0 0 1rem; }
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; }
label { display: block; margin-bottom: 0.75rem; }
label input { display: block; margin-top: 0.2rem; }
table { border-collapse: collapse; width: 100%; }
th, td { text-align: left; padding: 0.35rem 0.5rem; border-bottom: 1px solid #e8eaf6; }
ul.arbol { margin: 0.25rem 0; padding-left: 1.25rem; }
2. Componente, elemento y render
Tres palabras se confunden al empezar, y separarlas evita muchos errores después:
| Término | Qué es | Ejemplo |
|---|---|---|
| Componente | La función que tú escribes | function Saludo() { ... } |
| Elemento | Un objeto que describe qué mostrar | El resultado de <Saludo nombre="Ana" /> |
| Render | React ejecuta tu función para saber qué mostrar | Lo hace React, no tú |
Como viste en el módulo 02, JSX se transforma en una llamada a jsx(). Lo importante es lo que esa llamada no hace: no ejecuta tu componente. Solo crea un objeto que dice “aquí va un Saludo con estos datos”. El siguiente ejemplo lo demuestra:
import type { ReactNode } from 'react';
function Saludo({ nombre }: { nombre: string }) {
console.log('ejecuto Saludo');
return <p>Hola, {nombre}</p>;
}
function Marco({ children }: { children: ReactNode }) {
console.log('ejecuto Marco');
return <div className="tarjeta">{children}</div>;
}
// Se crea al cargar el módulo: la función Saludo todavía no se ha ejecutado
const elemento = <Saludo nombre="Ana" />;
console.log('elemento creado:', elemento.key, elemento.props);
export default function App() {
console.log('ejecuto App');
return <Marco>{elemento}</Marco>;
}
elemento creado: null {nombre: 'Ana'}
ejecuto App
ejecuto App
ejecuto Marco
ejecuto Marco
ejecuto Saludo
ejecuto Saludo
La salida cuenta tres cosas:
- El elemento existe antes de que
Saludose ejecute. Crear un elemento es barato: es un objeto. - React ejecuta de arriba abajo: primero
App, que devuelve unMarco; luegoMarco, que devuelve unSaludoentre sus hijos; y por últimoSaludo. - Cada línea sale dos veces porque la plantilla envuelve la aplicación en
<StrictMode>y, en desarrollo, React ejecuta cada componente dos veces para ayudarte a encontrar impurezas (sección 7). En producción no ocurre.
Figura 2. Tú escribes componentes; JSX crea elementos; React los recorre, ejecuta tus funciones y actualiza el DOM.
No llames a un componente como función
Si un componente es una función, ¿por qué no escribir {Saludo({ nombre: 'Ana' })}? A veces parece funcionar, pero al llamarla tú, React no sabe que ahí hay un componente: no aparece en las React DevTools, y su estado (que verás en el módulo 04) se mezclaría con el del componente que la llamó. Usa siempre la sintaxis JSX y deja que React decida cuándo ejecutarla.
Qué puede devolver un componente
| Devuelve | Qué se ve |
|---|---|
| Elemento JSX o fragmento | Lo que describe |
| Cadena o número | El texto, incluido el 0 |
Array de elementos con key | Cada elemento, en orden |
null, undefined, true o false | Nada |
Lo hemos comprobado con seis componentes juntos. undefined, null y false no dejan rastro en el HTML, mientras que el 0 sí se pinta:
<div>|texto|<i>a</i><i>b</i>|0</div>
Devolver null es la forma normal de decir “en este caso no pinto nada”:
function AvisoStock({ stock }: { stock: number }) {
if (stock > 0) return null;
return <p className="aviso">Producto agotado</p>;
}
Un objeto corriente, en cambio, no se puede pintar. Si el objeto llega a una función usada como componente, TypeScript lo rechaza con TS2786: 'Dep' cannot be used as a JSX component. Y si llega a las llaves de JSX sin que el compilador lo detecte (por ejemplo, un dato tipado como any), la aplicación se cae en ejecución:
Objects are not valid as a React child (found: object with keys {nombre}). If you meant to render a collection of children, use an array instead.
⚠️ Importante: Un componente de cliente no puede ser
async. Si escribesasync function Listado(), React lanzaAn unknown Component is an async Client Component. Only Server Components can be async at the moment.Para pedir datos usarás efectos (módulo 09) y, más adelante, otras herramientas. Los componentes de servidor sí admitenasync, y los verás en el módulo 23.
No definas componentes dentro de otros
Este error es de los más desconcertantes, porque el código parece correcto:
import { useState } from 'react';
export default function App() {
const [clics, setClics] = useState(0);
// ❌ MAL: cada render crea una función Campo nueva
function Campo() {
return <input id="nombre" placeholder="Escribe tu nombre" />;
}
return (
<div className="fila">
<Campo />
<button id="repintar" onClick={() => setClics(clics + 1)}>
Clics: {clics}
</button>
</div>
);
}
Aquí se adelanta useState, que verás a fondo en el módulo 04. Basta con saber que cada clic hace que App se ejecute de nuevo. Hemos escrito Hola en el campo y hemos pulsado el botón: el campo se queda vacío.
La razón está en cómo compara React. Para saber si <Campo /> es el mismo de antes, comprueba que su tipo sea la misma función. Pero cada ejecución de App crea una función Campo distinta, aunque el texto sea idéntico. React concluye que es otro componente, destruye el <input> y crea uno nuevo. La solución es sacar Campo fuera de App. El linter oxlint que trae la plantilla lo señala:
react(static-components): Cannot create components during render
3. Props: los parámetros de un componente
Las props (de properties) son los datos que un componente recibe de quien lo usa. Se escriben como atributos en JSX y llegan juntas, en un solo objeto, como primer parámetro de la función:
type FichaEmpleadoProps = {
nombre: string;
apellidos: string;
oficio: string;
salario: number;
};
function FichaEmpleado({ nombre, apellidos, oficio, salario }: FichaEmpleadoProps) {
return (
<article className="tarjeta">
<h2>{nombre} {apellidos}</h2>
<p>{oficio}</p>
<p>Salario: {salario.toLocaleString('es-ES')} €</p>
</article>
);
}
export default function App() {
return (
<main>
<h1>Plantilla</h1>
<FichaEmpleado nombre="Ana" apellidos="García López" oficio="DIRECTOR GENERAL" salario={6500} />
<FichaEmpleado nombre="Pablo" apellidos="Moreno Herrera" oficio="ANALISTA" salario={3500} />
</main>
);
}

Figura 3. El mismo componente con datos distintos. Compáralo con la figura 1.
Fíjate en dos detalles:
- Las cadenas van entre comillas (
nombre="Ana"). Cualquier otro tipo va entre llaves (salario={6500}). Si escribessalario="6500", estás pasando una cadena. { nombre, apellidos }en la cabecera es desestructuración de JavaScript, no una sintaxis de React. Equivale a recibirpropsy leerprops.nombre. Puedes repasarla en el Curso de JavaScript.
El tipado es donde TypeScript rinde más en React, porque convierte cada uso de un componente en una llamada comprobada. Estos son los errores reales que da con el tipo anterior reducido a nombre y edad:
| Escribes | TypeScript responde |
|---|---|
<Ficha nombre="Ana" /> | TS2741: Property 'edad' is missing in type '{ nombre: string; }' but required in type 'P'. |
<Ficha nombre="Ana" edad="30" /> | TS2322: Type 'string' is not assignable to type 'number'. |
<Ficha nombre="Ana" edad={30} apellidos="x" /> | TS2322: ... Property 'apellidos' does not exist on type 'IntrinsicAttributes & P'. |
function Ficha({ nombre }) { ... } | TS7031: Binding element 'nombre' implicitly has an 'any' type. |
Tipos que usarás a diario
type Empleado = {
empId: number;
nombre: string;
oficio: string;
};
type FilaProps = {
empleado: Empleado; // un objeto
destacado?: boolean; // opcional: puede no llegar
densidad?: 'compacta' | 'normal'; // unión: solo estos valores
onSeleccionar?: (empId: number) => void; // una función
};
function FilaEmpleado({ empleado, destacado, densidad, onSeleccionar }: FilaProps) {
return (
<tr style={{ fontWeight: destacado ? 700 : 400, fontSize: densidad === 'compacta' ? '0.85rem' : '1rem' }}>
<td>{empleado.empId}</td>
<td>{empleado.nombre}</td>
<td>{empleado.oficio}</td>
<td>
<button onClick={() => onSeleccionar?.(empleado.empId)}>Ver</button>
</td>
</tr>
);
}
const pablo: Empleado = { empId: 120, nombre: 'Pablo', oficio: 'ANALISTA' };
const carmen: Empleado = { empId: 110, nombre: 'Carmen', oficio: 'VENDEDOR' };
export default function App() {
function mostrar(empId: number) {
console.log('Seleccionado:', empId);
}
return (
<section className="tarjeta">
<table>
<tbody>
<FilaEmpleado empleado={pablo} destacado onSeleccionar={mostrar} />
<FilaEmpleado empleado={carmen} densidad="compacta" onSeleccionar={mostrar} />
</tbody>
</table>
</section>
);
}
Vamos prop por prop:
destacadose usa sin valor. En JSX,<Fila destacado />equivale adestacado={true}.densidades una unión de literales. Es mucho mejor questring: el editor te sugiere los valores y una errata no compila.onSeleccionares una función. Así es como un hijo avisa a su padre de que ha pasado algo. Por convenio, la prop empieza porony la función que la implementa suele llamarse con un verbo (mostrar,guardarEmpleado). Los eventos se ven a fondo en el módulo 05.onSeleccionar?.(...)usa el encadenamiento opcional, porque la prop puede no haber llegado.
type o interface, y el tipo FC
Para las props, type e interface sirven igual. En este curso usamos type porque admite uniones directamente.
Encontrarás mucho código con React.FC:
const Ficha: FC<FichaProps> = ({ nombre }) => <p>{nombre}</p>;
Funciona, pero no aporta nada que no dé el tipo en el parámetro, y estorba cuando quieres un componente genérico (módulo 13). Aquí escribimos funciones normales con el tipo en el parámetro, que es también lo que hace la documentación de React.
Pasar un objeto o sus campos sueltos
| Forma | Ventaja | Inconveniente |
|---|---|---|
<Ficha empleado={e} /> | Corto; encaja con los datos de la API | El componente depende de la forma de ese objeto |
<Ficha nombre={e.nombre} oficio={e.oficio} /> | Reutilizable con datos de cualquier origen | Más largo |
<Ficha {...e} /> | Muy corto | Pasa todo, aunque no haga falta, y oculta qué se usa |
Como norma práctica, usa el objeto para componentes de una entidad concreta (FilaEmpleado), y campos sueltos para piezas genéricas (Insignia, Tarjeta).
Reenviar props con ...resto
Muchos componentes envuelven una etiqueta HTML y quieren aceptar todos sus atributos sin declararlos uno a uno. ComponentProps<'button'> da el tipo con todas las props de <button>, y la sintaxis ...resto recoge lo que no hayas extraído:
import type { ComponentProps } from 'react';
type BotonProps = ComponentProps<'button'> & {
variante?: 'normal' | 'peligro';
};
function Boton({ variante = 'normal', style, ...resto }: BotonProps) {
const colores = variante === 'peligro' ? { borderColor: '#c62828', color: '#c62828' } : {};
return <button type="button" {...resto} style={{ ...colores, ...style }} />;
}
export default function App() {
return (
<div className="fila">
<Boton title="Guarda el empleado">Guardar</Boton>
<Boton variante="peligro" disabled>Borrar</Boton>
<Boton variante="peligro" style={{ fontWeight: 700 }} onClick={() => console.log('Cancelado')}>
Cancelar
</Boton>
</div>
);
}
El orden importa: lo que va después en JSX gana. Como type="button" va antes que {...resto}, quien use Boton puede cambiarlo a type="submit". Si lo pusieras después, siempre sería button. El valor por defecto evita además un error clásico: un <button> sin type dentro de un formulario lo envía.
key y ref no son como las demás
key la consume React y nunca llega al componente. Si intentas leerla, obtienes undefined y este aviso:
F: `key` is not a prop. Trying to access it will result in `undefined` being returned. If you need to access the same value within the child component, you should pass it as a different prop.
Si el componente necesita ese dato, pásalo también con otro nombre: <Fila key={e.empId} empId={e.empId} />. key se explica en el módulo 06. ref tuvo el mismo trato hasta React 18, pero ha cambiado: lo veremos en la sección 11.
4. Valores por defecto
Una prop opcional que no llega vale undefined. Para dar un valor por defecto se usa la desestructuración de JavaScript:
type ProductoProps = {
nombre: string;
precio: number;
moneda?: string;
stock?: number | null;
};
function Producto({ nombre, precio, moneda = 'EUR', stock = 0 }: ProductoProps) {
const formato = new Intl.NumberFormat('es-ES', { style: 'currency', currency: moneda });
return (
<article className="tarjeta">
<h2>{nombre}</h2>
<p>{formato.format(precio)}</p>
<p>Stock: {String(stock)}</p>
</article>
);
}
export default function App() {
return (
<div className="rejilla">
<Producto nombre="Portátil UltraBook 14" precio={899} stock={25} />
<Producto nombre="Portátil Gaming 16" precio={1499} />
<Producto nombre="Portátil Básico 15" precio={499} stock={undefined} />
<Producto nombre="Altavoz Inteligente" precio={49} moneda="USD" stock={null} />
</div>
);
}

Figura 4. El valor por defecto se aplica cuando falta la prop o vale undefined, pero no con null.
⚠️ Importante: El valor por defecto solo se aplica con
undefined.nullno lo activa, porque es un valor que alguien ha pasado a propósito. Te afectará con los datos de la API EMPRESA, dondecomisionopresupuestopueden sernull. En esos casos usa??al leer el dato:presupuesto ?? 0.
Los valores por defecto se calculan en cada render. Con primitivos no importa, pero lista = [] crea un array nuevo cada vez. Si esa referencia acaba siendo una dependencia de un efecto (módulo 09), el efecto se ejecutará en cada render. Si te hace falta, declara la constante fuera del componente:
const SIN_ETIQUETAS: string[] = [];
function Etiquetas({ lista = SIN_ETIQUETAS }: { lista?: string[] }) {
return <p>{lista.join(', ')}</p>;
}
defaultProps ya no funciona en funciones 🔄 19.0
🔄 Cambia en React 19.0. En React 18.3,
defaultPropsaún funciona en componentes función, con un aviso de que se retirará; desde 19.0 se ignora sin ningún aviso. Si usas una versión anterior: funcionará, pero pasa ya a valores por defecto con desestructuración, que sirven en todas las versiones.
Si mantienes código antiguo, verás esto:
function Saludo({ nombre }: { nombre?: string }) {
return <p>Hola, {nombre}</p>;
}
// ❌ Se ignora en React 19: nombre llega como undefined
Saludo.defaultProps = { nombre: 'invitado' };
Es un cambio peligroso porque nada lo señala: TypeScript lo compila sin error, y React 19.3 no escribe nada en la consola. Solo notarás que los valores no llegan. Si migras un proyecto, busca defaultProps en todo el código. Lo mismo sucede con propTypes, que también dejó de tener efecto en React 19: TypeScript hace ese trabajo al compilar.
5. Las props son de solo lectura
Un componente no debe cambiar sus propias props. Son como los argumentos de una función matemática: los usas para calcular un resultado, no los modificas. React lo hace cumplir en desarrollo, porque congela el objeto de props:
function Ficha(props: { nombre: string }) {
// ❌ MAL: las props no se modifican
props.nombre = props.nombre.toUpperCase();
return <p>{props.nombre}</p>;
}
Cannot assign to read only property 'nombre' of object '#<Object>'
Aquí hay dos trampas:
- TypeScript no lo detecta por sí solo. El código anterior compila, porque el tipo no dice que
nombresea de solo lectura. Si quieres el aviso al compilar, usaReadonly:function Ficha(props: Readonly<{ nombre: string }>), y obtendrásTS2540: Cannot assign to 'nombre' because it is a read-only property. - En producción no falla. La congelación es solo de desarrollo. En la compilación de producción el mismo código se ejecuta sin error y pinta el valor cambiado, de modo que un fallo que ignores en desarrollo se convierte en un comportamiento silencioso.
La congelación es además superficial: props.empleado.nombre = 'x' no lanza ningún error, porque el objeto empleado no está congelado. Sigue siendo igual de incorrecto, ya que ese objeto pertenece a quien lo creó y quizá lo muestre en otra parte.
La solución es crear un valor nuevo:
function Ficha({ nombre }: { nombre: string }) {
const nombreMayusculas = nombre.toUpperCase();
return <p>{nombreMayusculas}</p>;
}
Los datos fluyen en una sola dirección
¿Por qué esta restricción? Porque con ella, si un dato de la pantalla está mal, sabes dónde buscar: subiendo por los padres hasta quien lo creó. Es el flujo de datos unidireccional.
Figura 5. Los datos bajan por props y los avisos suben con funciones. El padre es quien decide si cambia algo.
Entonces, ¿cómo cambia nada en una interfaz? El hijo llama a una función recibida por props (onSeleccionar), y el padre, que es el dueño del dato, lo actualiza en su estado. Las props llegan entonces con el nuevo valor. Ese mecanismo es el tema del módulo 04.
| Props | Estado | |
|---|---|---|
| ¿Quién lo define? | El padre | El propio componente |
| ¿Se puede cambiar desde dentro? | No | Sí, con su función de actualización |
| Parecido a | Parámetros de una función | Variables locales que React recuerda |
6. children y composición
Hasta ahora hemos pasado datos. Pero también se puede pasar interfaz. Lo que escribes entre la etiqueta de apertura y la de cierre de un componente le llega en una prop especial llamada children:
import type { ReactNode } from 'react';
type TarjetaProps = {
titulo: string;
children: ReactNode;
};
function Tarjeta({ titulo, children }: TarjetaProps) {
return (
<section className="tarjeta">
<h2>{titulo}</h2>
{children}
</section>
);
}
export default function App() {
return (
<main>
<Tarjeta titulo="Empleado del mes">
<p>Elena Navarro Castro</p>
<p>VENDEDOR</p>
</Tarjeta>
<Tarjeta titulo="Avisos">
<p className="aviso">Hay 2 productos sin stock.</p>
</Tarjeta>
<Tarjeta titulo="Solo texto">Sin ninguna etiqueta dentro.</Tarjeta>
</main>
);
}

Figura 6. Tarjeta pone el marco y no sabe nada de lo que lleva dentro.
ReactNode es el tipo de todo lo que React puede pintar: elementos, cadenas, números, arrays, null, undefined y booleanos. Es el tipo correcto para children en casi todos los casos. Si no lo declaras y le pasas contenido, TypeScript se queja:
TS2322: Type '{ nombre: string; edad: number; children: string; }' is not assignable to type 'IntrinsicAttributes & P'.
Property 'children' does not exist on type 'IntrinsicAttributes & P'.
Existe también el atajo PropsWithChildren<P>, que añade un children opcional a tus props. Pero si el componente no tiene sentido sin contenido, es mejor declararlo obligatorio.
⚠️ Importante: Si olvidas escribir
{children}en el JSX, el contenido desaparece sin errores ni avisos. Es lo primero que debes comprobar cuando “lo que pongo dentro no sale”.
El valor de children no siempre es igual
children vale cosas distintas según lo que escribas dentro: nada da undefined, un texto da una cadena, un elemento da un objeto y varios dan un array. Por eso no debes tratarlo como un array ni llamar a children.map. Pásalo tal cual a {children} y deja que React lo resuelva.
Varios huecos: props con JSX
children solo es un hueco. Cuando hacen falta varios, se declaran más props de tipo ReactNode, porque el JSX es un valor como otro cualquiera:
import type { ReactNode } from 'react';
type PanelProps = {
titulo: string;
acciones?: ReactNode;
pie?: ReactNode;
children: ReactNode;
};
function Panel({ titulo, acciones, pie, children }: PanelProps) {
return (
<section className="tarjeta">
<div className="fila" style={{ justifyContent: 'space-between' }}>
<h2 style={{ margin: 0 }}>{titulo}</h2>
{acciones}
</div>
{children}
{pie && <footer>{pie}</footer>}
</section>
);
}
export default function App() {
return (
<Panel
titulo="Pedido 5001"
acciones={<button>Imprimir</button>}
pie={<span>Enviado el 17/01/2025</span>}
>
<p>Cliente: Talleres Martín S.L.</p>
<p>Estado: ENTREGADO</p>
</Panel>
);
}

Figura 7. Un mismo Panel con tres huecos: acciones, contenido y pie.
{pie && <footer>...} es renderizado condicional, que se trata en el módulo 06.
Composición en lugar de herencia
Si vienes de Java, quizá pienses en una clase TarjetaBase con subclases. En React eso no se hace: las variantes se consiguen componiendo componentes.
function TarjetaAviso({ children }: { children: ReactNode }) {
return <Tarjeta titulo="Atención">{children}</Tarjeta>;
}
TarjetaAviso es una versión especializada de Tarjeta: la usa, no hereda de ella. La composición resuelve también otro problema: pasar props a través de componentes que no las usan. Observa este caso:
// Con props: Pagina recibe usuario solo para pasárselo a Cabecera
<Pagina usuario={usuario} />
// Con composición: Pagina ni siquiera sabe que existe usuario
<Pagina cabecera={<Cabecera usuario={usuario} />}>
<Listado />
</Pagina>
Antes de recurrir a Context (módulo 11), prueba a recolocar los componentes de esta forma. Suele bastar.
7. Pureza: el componente como fórmula
📘 Concepto: Un componente es puro si cumple dos condiciones: con las mismas props, el mismo estado y el mismo contexto devuelve siempre lo mismo, y durante el render no cambia nada que existiera antes de llamarlo.
Es como una fórmula: y = 2x da 6 cada vez que x vale 3, y calcularla no borra nada de la pizarra. React depende de esta garantía, porque se reserva el derecho de renderizar cuando quiera, las veces que quiera, o de descartar un render a medias. Este componente la incumple:
let invitado = 0;
// ❌ MAL: modifica una variable que existía antes de llamarlo
function Invitado() {
invitado = invitado + 1;
return <p>Invitado número {invitado}</p>;
}
export default function App() {
return (
<section className="tarjeta">
<Invitado />
<Invitado />
<Invitado />
</section>
);
}
Esperas 1, 2 y 3, pero con <StrictMode> ves esto:

Figura 8. El componente impuro con Strict Mode: cada uno se ejecuta dos veces y el contador sube de más.
Sin <StrictMode>, o en producción, sale 1, 2, 3, y por eso este fallo se esconde tan bien. Pero el resultado depende de cuántas veces se ejecute el render, y en cuanto la pantalla se repinte seguirá subiendo. Strict Mode no ha creado el problema: lo ha hecho visible. Es exactamente para lo que ejecuta dos veces.
La versión pura recibe el número por props:
function Invitado({ numero }: { numero: number }) {
return <p>Invitado número {numero}</p>;
}
Qué rompe la pureza
| Durante el render | ¿Puro? | Alternativa |
|---|---|---|
| Modificar variables de fuera, props o datos recibidos | No | Calcular un valor nuevo |
Math.random(), Date.now(), new Date() | No | Recibir el valor por props o usar estado inicial (módulo 04) |
fetch, localStorage.setItem, document.title = ... | No | Manejador de eventos o efecto (módulo 09) |
console.log | Sí, en la práctica | Válido para depurar; quítalo después |
Crear un array local y hacerle push | Sí | Es correcto |
La última fila merece atención. La pureza prohíbe cambiar lo que ya existía. Lo que creas durante el render es tuyo y puedes modificarlo:
function ListaOficios({ oficios }: { oficios: string[] }) {
// ✅ BIEN: filas se crea aquí, así que modificarla no afecta a nadie
const filas: ReactNode[] = [];
for (const oficio of oficios) {
filas.push(<li key={oficio}>{oficio}</li>);
}
return <ul>{filas}</ul>;
}
Otra trampa: oficios.sort() ordena el propio array recibido, así que modifica una prop. Usa oficios.toSorted(), que devuelve una copia ordenada.
💡 Consejo: Los efectos secundarios no están prohibidos, solo que su lugar no es el render. Pregúntate qué los provoca: si es una acción del usuario, van en un manejador de eventos; si es que el componente se ha mostrado, van en un efecto.
El linter te ayuda en parte. Sin configurar nada, npm run lint avisa de las llamadas impuras y de las reasignaciones de variables externas:
react(purity): Cannot call impure function during render
react(globals): Cannot reassign variables declared outside of the component/hook
Pero no es infalible: en nuestra prueba señaló n = n + 1 y n += 1, y en cambio dejó pasar n++, que hace exactamente lo mismo. Conocer la regla sigue siendo cosa tuya. Y es la misma regla que te permitirá usar React Compiler en el módulo 14, que da por hecho que tus componentes son puros.
8. El árbol de componentes
Una aplicación es un árbol: cada componente contiene a otros, hasta llegar a las etiquetas HTML. Pensar así te ayuda a contestar a tres preguntas: dónde poner cada dato, qué se vuelve a renderizar y por qué camino llegan las props.
Figura 9. Árbol de un panel. Un mismo componente, como Insignia, puede aparecer en varias ramas.
Conviene saber distinguir dos árboles:
- El árbol de render: qué componente renderizó a qué en esta ejecución. Cambia según lo que se muestre.
- El árbol de módulos: qué fichero importa a qué. Es lo que usa Vite para generar el paquete final.
La pestaña Components de las React DevTools (módulo 01) te enseña el primero, y al seleccionar un componente ves sus props reales. Es la primera herramienta para responder a “¿qué le está llegando a este componente?”.
Cómo decidir dónde cortar
No hay una regla exacta, pero estas señales ayudan:
- Repetición: el mismo marcado aparece dos o más veces.
- Una sola responsabilidad: si necesitas la palabra “y” para explicar qué hace, quizá sean dos.
- Tamaño: cuando te cuesta ver el JSX completo sin desplazarte.
- Nombre natural: si un trozo tiene un nombre obvio (
FilaEmpleado), probablemente sea un componente.
Tampoco hay que pasarse. Un componente que se usa una vez, tiene tres líneas y obliga a pasarle cinco props complica más de lo que ayuda.
9. Exportaciones e importaciones
Un componente que quieres usar desde otro fichero se exporta. Hay dos formas:
// Tarjeta.tsx
export default function Tarjeta() { /* ... */ } // por defecto: una por fichero
export function TarjetaAviso() { /* ... */ } // con nombre: las que quieras
// App.tsx
import Tarjeta from './Tarjeta'; // sin llaves, con el nombre que elijas
import { TarjetaAviso } from './Tarjeta'; // con llaves, nombre exacto
| Por defecto | Con nombre | |
|---|---|---|
| Cuántas por fichero | Una | Varias |
| Nombre al importar | El que quieras | El exportado (o renombrado con as) |
| Renombrado automático en el editor | Menos fiable | Fiable |
| Usada por | Plantilla de Vite (App), lazy (módulo 15) | Bibliotecas y código compartido |
La libertad de la exportación por defecto tiene un coste. Esto compila sin ningún error, aunque te lleves un componente que no querías:
import TarjetaAviso from './Tarjeta'; // en realidad es Tarjeta
En este curso usamos exportaciones con nombre para los componentes compartidos y la exportación por defecto para App y para las páginas. Lo importante es elegir un criterio y mantenerlo en todo el proyecto.
Errores típicos
| Qué ocurre | Mensaje |
|---|---|
Llaves de más al importar un export default | TS2614: Module '"./Tarjeta"' has no exported member 'Tarjeta'. Did you mean to use 'import Tarjeta from "./Tarjeta"' instead? |
| Mayúsculas distintas en la ruta | TS1149: File name '...tarjeta.tsx' differs from already included file name... |
El componente vale undefined en ejecución | Element type is invalid: expected a string (for built-in components) or a class/function (for composite components) but got: undefined. |
⚠️ Importante: Windows y macOS no distinguen mayúsculas en los nombres de fichero, pero Linux sí. Un
import './tarjeta'que apunta aTarjeta.tsxpuede funcionar en tu equipo y fallar en el servidor de integración continua. TypeScript lo detecta conTS1149: no ignores ese error.
Importar solo tipos
La plantilla activa verbatimModuleSyntax, que obliga a marcar las importaciones que son solo tipos:
import type { ReactNode } from 'react';
import { useState, type ChangeEvent } from 'react';
import type desaparece al compilar. Así, el compilador no tiene que adivinar si un nombre es un tipo o un valor.
Fast Refresh y exportaciones mezcladas
La recarga en caliente de Vite conserva el estado al editar un fichero solo si este exporta únicamente componentes. Si mezclas componentes con funciones, el linter avisa:
react(only-export-components): Fast refresh only works when a file only exports components.
La solución es sacar las funciones a otro fichero (formato.ts). Las constantes simples están permitidas por la opción allowConstantExport de la plantilla.
10. Organización de ficheros
React no impone ninguna estructura. Para un proyecto pequeño o mediano como los de este curso, esta funciona bien:
src/
├── main.tsx
├── App.tsx
├── index.css
├── componentes/ piezas genéricas, sin lógica de negocio
│ ├── Insignia.tsx
│ └── Tarjeta.tsx
└── empleados/ todo lo de una funcionalidad, junto
├── FichaEmpleado.tsx
├── formato.ts
└── tipos.ts
Agrupar por funcionalidad (empleados/, pedidos/) escala mejor que agrupar por tipo de fichero (componentes/, tipos/, utilidades/), porque lo que cambia junto vive junto. Veremos la estructura completa en el módulo 25.
Convenios habituales:
- Un componente principal por fichero, con el fichero llamado igual:
FichaEmpleado.tsx. PascalCasepara componentes;camelCasepara utilidades..tsxsi el fichero contiene JSX;.tssi no.- Un componente auxiliar que solo usa ese fichero puede quedarse dentro, sin exportar.
Este es un ejemplo completo con esa estructura. Crea cada fichero con la ruta indicada:
// src/empleados/tipos.ts
export type Empleado = {
empId: number;
nombre: string;
apellidos: string;
oficio: string;
salario: number;
comision: number | null;
};
// src/empleados/formato.ts
const moneda = new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' });
export function formatearEuros(importe: number): string {
return moneda.format(importe);
}
// src/componentes/Insignia.tsx
import type { ReactNode } from 'react';
type InsigniaProps = {
tono?: 'neutro' | 'exito' | 'aviso' | 'peligro';
children: ReactNode;
};
export function Insignia({ tono = 'neutro', children }: InsigniaProps) {
return <span className={`insignia insignia-${tono}`}>{children}</span>;
}
// src/componentes/Tarjeta.tsx
import type { ReactNode } from 'react';
type TarjetaProps = {
titulo: ReactNode;
children: ReactNode;
};
export function Tarjeta({ titulo, children }: TarjetaProps) {
return (
<section className="tarjeta">
<h2>{titulo}</h2>
{children}
</section>
);
}
// src/empleados/FichaEmpleado.tsx
import { Insignia } from '../componentes/Insignia';
import { Tarjeta } from '../componentes/Tarjeta';
import { formatearEuros } from './formato';
import type { Empleado } from './tipos';
export function FichaEmpleado({ empleado }: { empleado: Empleado }) {
const { nombre, apellidos, oficio, salario, comision } = empleado;
return (
<Tarjeta titulo={`${nombre} ${apellidos}`}>
<p>
<Insignia>{oficio}</Insignia>
</p>
<p>Salario: {formatearEuros(salario)}</p>
<p>Comisión: {comision === null ? 'sin comisión' : formatearEuros(comision)}</p>
</Tarjeta>
);
}
// src/App.tsx
import { FichaEmpleado } from './empleados/FichaEmpleado';
import type { Empleado } from './empleados/tipos';
const marta: Empleado = {
empId: 102,
nombre: 'Marta',
apellidos: 'Sánchez Gil',
oficio: 'DIRECTOR',
salario: 5000,
comision: null,
};
export default function App() {
return <FichaEmpleado empleado={marta} />;
}
Observa que FichaEmpleado no contiene casi nada de HTML: se limita a combinar piezas que ya existen. Es la sensación que debe darte una base de código React bien repartida.
Ficheros índice
Se puede crear un componentes/index.ts que reexporte todo:
export { Insignia } from './Insignia';
export { Tarjeta } from './Tarjeta';
Y entonces escribir import { Insignia, Tarjeta } from '../componentes'. Es cómodo, pero estos ficheros, llamados barrel (barril), pueden crear importaciones circulares y hacer que el servidor de desarrollo cargue más módulos de los necesarios. Úsalos con moderación, en carpetas pequeñas de piezas compartidas.
11. ref como prop 🆕 19.0
🆕 Desde React 19.0. No existe en React 18.3 ni anteriores: allí
refno llega al componente función. Si usas una versión anterior: envuelve el componente enforwardRef, que recibe larefcomo segundo parámetro, tal como se muestra en esta sección.
Una ref es una forma de obtener acceso al nodo real del DOM, por ejemplo para darle el foco. El módulo 10 las trata a fondo; aquí basta con esto: useRef crea una caja cuya propiedad current contendrá el nodo, y ref={caja} le pide a React que lo guarde ahí.
Con una etiqueta HTML es directo. El problema aparece cuando la etiqueta está dentro de tu componente: quien usa <Campo /> quiere dar el foco al <input> que hay dentro. Desde React 19, ref es una prop más, y basta con recibirla y pasarla:
import { useRef, type Ref } from 'react';
type CampoProps = {
etiqueta: string;
ref?: Ref<HTMLInputElement>;
};
function Campo({ etiqueta, ref }: CampoProps) {
return (
<label>
{etiqueta}
<input ref={ref} />
</label>
);
}
export default function App() {
const campoNombre = useRef<HTMLInputElement>(null);
return (
<section className="tarjeta">
<Campo etiqueta="Nombre del empleado" ref={campoNombre} />
<button onClick={() => campoNombre.current?.focus()}>Ir al nombre</button>
</section>
);
}

Figura 10. Tras pulsar el botón, el foco está en el <input> que vive dentro de Campo.
El tipo Ref<HTMLInputElement> indica a qué nodo apuntará. useRef exige un argumento desde React 19: useRef<number>() da TS2554: Expected 1 arguments, but got 0.
Si tu componente envuelve una etiqueta y reenvía todo con ...resto, ni siquiera tienes que nombrar ref, porque ComponentProps<'input'> ya la incluye y viaja con las demás:
import { useRef, type ComponentProps } from 'react';
type CampoProps = ComponentProps<'input'> & { etiqueta: string };
function Campo({ etiqueta, ...resto }: CampoProps) {
return (
<label>
{etiqueta}
<input {...resto} />
</label>
);
}
export default function App() {
const email = useRef<HTMLInputElement>(null);
return (
<section className="tarjeta">
<Campo etiqueta="Correo" type="email" placeholder="nombre@empresa.es" ref={email} />
<button onClick={() => console.log('Valor:', email.current?.value)}>Leer</button>
</section>
);
}
forwardRef: lo que usan las versiones anteriores
En React 18, el mismo componente se escribe así, y sigue funcionando en React 19, también sin avisos:
import { forwardRef, useRef } from 'react';
type CampoProps = { etiqueta: string };
const Campo = forwardRef<HTMLInputElement, CampoProps>(function Campo({ etiqueta }, ref) {
return (
<label>
{etiqueta}
<input ref={ref} />
</label>
);
});
export default function App() {
const campoNombre = useRef<HTMLInputElement>(null);
return (
<section className="tarjeta">
<Campo etiqueta="Nombre del empleado" ref={campoNombre} />
<button onClick={() => campoNombre.current?.focus()}>Ir al nombre</button>
</section>
);
}
Nótese el orden de los genéricos: primero el nodo, después las props, justo al revés que los parámetros de la función.
Hemos ejecutado el mismo código con dos componentes, uno con ref como prop y otro con forwardRef, en tres versiones:
| Versión | ref como prop | forwardRef |
|---|---|---|
| React 18.3.1 | undefined, con avisos en la consola | Funciona |
| React 19.0.9 | Funciona | Funciona |
| React 19.3.1 | Funciona | Funciona |
En 18.3.1 la consola explica exactamente el motivo:
Warning: C: `ref` is not a prop. Trying to access it will result in `undefined` being returned.
Warning: Function components cannot be given refs. Attempts to access this ref will fail. Did you mean to use React.forwardRef()?
Una biblioteca que deba funcionar en React 18 y 19 sigue necesitando forwardRef. En código nuevo que solo use React 19, escribe la prop directamente.
💡 Consejo: Exponer una
refrompe en parte la encapsulación, porque quien la usa accede a tu DOM interno. Resérvala para cosas que no se pueden expresar con props, como el foco, el scroll o las medidas.
12. Caso completo: panel de departamentos
Vamos a juntarlo todo. Queremos un panel con los departamentos de EMPRESA, cada uno con su presupuesto y un aviso cuando no lo tiene. Los datos son los reales de la API. Aún no sabemos pedirlos por red (lo verás en el módulo 09), así que los escribimos en una constante.
El primer paso no es programar, sino dibujar el árbol:
App
└── Pagina título y contenido
├── ResumenPlantilla totales
└── TarjetaDepartamento (una por departamento)
└── Insignia estado del presupuesto
import type { ReactNode } from 'react';
type Departamento = {
deptId: number;
nombre: string;
localidad: string;
presupuesto: number | null;
};
const departamentos: Departamento[] = [
{ deptId: 10, nombre: 'DIRECCIÓN', localidad: 'Madrid', presupuesto: 500000 },
{ deptId: 20, nombre: 'VENTAS', localidad: 'Madrid', presupuesto: 350000 },
{ deptId: 30, nombre: 'INFORMÁTICA', localidad: 'Valencia', presupuesto: 420000 },
{ deptId: 40, nombre: 'CONTABILIDAD', localidad: 'Sevilla', presupuesto: 150000 },
{ deptId: 50, nombre: 'MARKETING', localidad: 'Barcelona', presupuesto: 200000 },
{ deptId: 60, nombre: 'LOGÍSTICA', localidad: 'Zaragoza', presupuesto: 180000 },
{ deptId: 70, nombre: 'INVESTIGACIÓN', localidad: 'Bilbao', presupuesto: null },
];
const euros = new Intl.NumberFormat('es-ES', {
style: 'currency',
currency: 'EUR',
maximumFractionDigits: 0,
useGrouping: 'always',
});
type InsigniaProps = {
tono?: 'neutro' | 'exito' | 'aviso' | 'peligro';
children: ReactNode;
};
function Insignia({ tono = 'neutro', children }: InsigniaProps) {
return <span className={`insignia insignia-${tono}`}>{children}</span>;
}
function TarjetaDepartamento({ departamento }: { departamento: Departamento }) {
const { nombre, localidad, presupuesto } = departamento;
const sinPresupuesto = presupuesto === null;
return (
<article className="tarjeta">
<h2>{nombre}</h2>
<p>{localidad}</p>
<p>
{sinPresupuesto ? (
<Insignia tono="aviso">Sin presupuesto</Insignia>
) : (
<Insignia tono="exito">{euros.format(presupuesto)}</Insignia>
)}
</p>
</article>
);
}
function ResumenPlantilla({ lista }: { lista: Departamento[] }) {
// Valores calculados a partir de las props: el render sigue siendo puro
const total = lista.reduce((suma, d) => suma + (d.presupuesto ?? 0), 0);
const sinAsignar = lista.filter((d) => d.presupuesto === null).length;
return (
<p className="aviso">
{lista.length} departamentos, {euros.format(total)} en total y {sinAsignar} sin presupuesto.
</p>
);
}
function Pagina({ titulo, children }: { titulo: string; children: ReactNode }) {
return (
<main>
<h1>{titulo}</h1>
{children}
</main>
);
}
export default function App() {
return (
<Pagina titulo="Departamentos">
<ResumenPlantilla lista={departamentos} />
<div className="rejilla">
{departamentos.map((d) => (
<TarjetaDepartamento key={d.deptId} departamento={d} />
))}
</div>
</Pagina>
);
}

Figura 11. El panel terminado, con los datos reales de EMPRESA.
Algunos detalles de este código:
mapconvierte cada dato en un componente ykeylo identifica (módulo 06). Observa quekeyva en el componente, no dentro deTarjetaDepartamento.presupuesto ?? 0trata elnullde la API, ysinPresupuestoevita repetir la comparación. TypeScript sigue la pista a esa constante y sabe que en la segunda ramapresupuestoes un número.useGrouping: 'always'hace falta porque, en español,Intl.NumberFormatno pone separador en números de cuatro cifras por defecto. Como todas las cantidades tienen seis cifras, aquí no se notaría, pero con ella el formato es uniforme.eurosse crea fuera de los componentes: crearlo en cada render sería un gasto inútil, y no depende de ninguna prop.- Todos los componentes son puros y solo
Appconoce los datos. Cuando en módulos posteriores vengan de la API, solo cambiaráApp.
13. Depuración y errores frecuentes
| Síntoma | Causa probable | Qué hacer |
|---|---|---|
| El contenido de una etiqueta no sale | Falta {children} en el componente | Añádelo al JSX |
| Pantalla en blanco | Una excepción durante el render | Abre la consola: el error indica el componente |
Objects are not valid as a React child | Pintas un objeto, por ejemplo {empleado} | Pinta un campo: {empleado.nombre} |
Element type is invalid ... got: undefined | Componente sin exportar o importación mezclada | Revisa el export y las llaves del import |
Cannot assign to read only property | Modificas las props | Crea un valor nuevo |
Prop siempre undefined | Errata, key o defaultProps | Míralo en las DevTools; usa desestructuración |
Muestra null en vez del valor por defecto | El defecto no se aplica con null | Usa ?? al leer |
Un número sale pegado a texto, o salario + 100 concatena | Pasaste salario="6500" | Usa llaves: salario={6500} |
| Pierde lo escrito al repintar | Componente definido dentro de otro | Sácalo al nivel superior |
| La salida cambia entre desarrollo y producción | Render impuro | Busca lo que modificas fuera del componente |
Los console.log salen dobles | <StrictMode> en desarrollo | Es normal |
ref llega undefined | React 18 sin forwardRef | Actualiza o usa forwardRef |
💡 Consejo: Para saber qué recibe un componente, abre las React DevTools y selecciónalo: verás sus props y quién lo renderizó. Es más rápido que llenar el código de
console.log.
14. Ejercicios
Ejercicio 1: Insignia de estado de pedido
Crea un componente EstadoPedido que reciba la prop estado, cuyos valores solo puedan ser los reales de la API (PENDIENTE, ENVIADO, ENTREGADO y CANCELADO), y muestre una insignia con el tono adecuado. Usa las clases insignia-aviso, insignia-exito y insignia-peligro.
Ver solución
type Estado = 'PENDIENTE' | 'ENVIADO' | 'ENTREGADO' | 'CANCELADO';
const clases: Record<Estado, string> = {
PENDIENTE: 'insignia-aviso',
ENVIADO: '',
ENTREGADO: 'insignia-exito',
CANCELADO: 'insignia-peligro',
};
function EstadoPedido({ estado }: { estado: Estado }) {
return <span className={`insignia ${clases[estado]}`}>{estado}</span>;
}
export default function App() {
return (
<div className="fila">
<EstadoPedido estado="PENDIENTE" />
<EstadoPedido estado="ENVIADO" />
<EstadoPedido estado="ENTREGADO" />
<EstadoPedido estado="CANCELADO" />
</div>
);
}
Con Record<Estado, string>, si añades un estado a la unión y olvidas su clase, TypeScript da un error. Es más seguro que una cadena de if.
Ejercicio 2: Tarjeta de producto con valores por defecto
Crea TarjetaProducto con nombre y precio obligatorios, descatalogado opcional (por defecto false) y stock opcional (por defecto 0). Debe mostrar Agotado si el stock es 0, Descatalogado si lo está y el número de unidades en otro caso. Pruébalo con Portátil UltraBook 14 (899 €, 25 uds.), Pack teclado y ratón (34,50 €, sin stock) y Altavoz Inteligente (49 €, descatalogado).
Ver solución
type TarjetaProductoProps = {
nombre: string;
precio: number;
stock?: number;
descatalogado?: boolean;
};
const euros = new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' });
function textoDisponibilidad(stock: number, descatalogado: boolean): string {
if (descatalogado) return 'Descatalogado';
if (stock === 0) return 'Agotado';
return `${stock} uds.`;
}
function TarjetaProducto({ nombre, precio, stock = 0, descatalogado = false }: TarjetaProductoProps) {
return (
<article className="tarjeta">
<h2>{nombre}</h2>
<p>{euros.format(precio)}</p>
<p>{textoDisponibilidad(stock, descatalogado)}</p>
</article>
);
}
export default function App() {
return (
<div className="rejilla">
<TarjetaProducto nombre="Portátil UltraBook 14" precio={899} stock={25} />
<TarjetaProducto nombre="Pack teclado y ratón" precio={34.5} />
<TarjetaProducto nombre="Altavoz Inteligente" precio={49} descatalogado />
</div>
);
}
textoDisponibilidad es una función corriente fuera del componente: no necesita nada de React, es pura y se puede probar por separado. El orden de los if importa: un producto descatalogado sin stock debe decir Descatalogado.
Ejercicio 3: Corrige la impureza
Este componente muestra los empleados de mayor a menor salario, pero tras usarlo, otra lista que recibe el mismo array aparece también ordenada. Encuentra el fallo y corrígelo.
function Ranking({ empleados }: { empleados: Empleado[] }) {
const ordenados = empleados.sort((a, b) => b.salario - a.salario);
return <ol>{ordenados.map((e) => <li key={e.empId}>{e.nombre}</li>)}</ol>;
}
Ver solución
sort ordena el mismo array que recibe y además lo devuelve, así que ordenados y empleados son el mismo objeto. El componente está modificando una prop y esa modificación se ve en todas partes. La congelación de React no lo evita, porque solo protege el objeto de props, no lo que hay dentro.
type Empleado = { empId: number; nombre: string; salario: number };
const plantilla: Empleado[] = [
{ empId: 120, nombre: 'Pablo', salario: 3500 },
{ empId: 100, nombre: 'Ana', salario: 6500 },
{ empId: 110, nombre: 'Carmen', salario: 2200 },
];
function Ranking({ empleados }: { empleados: Empleado[] }) {
const ordenados = empleados.toSorted((a, b) => b.salario - a.salario);
return <ol>{ordenados.map((e) => <li key={e.empId}>{e.nombre}</li>)}</ol>;
}
function PorAlta({ empleados }: { empleados: Empleado[] }) {
return <ul>{empleados.map((e) => <li key={e.empId}>{e.nombre}</li>)}</ul>;
}
export default function App() {
return (
<section className="tarjeta">
<h2>Por salario</h2>
<Ranking empleados={plantilla} />
<h2>Orden original</h2>
<PorAlta empleados={plantilla} />
</section>
);
}
toSorted devuelve una copia ordenada y deja el original intacto. La salida muestra Ana, Pablo y Carmen en la primera lista, y Pablo, Ana y Carmen en la segunda. Otra opción válida en entornos antiguos es [...empleados].sort(...).
Ejercicio 4: Panel con huecos
Crea un Panel con titulo, children, y dos huecos opcionales, acciones y pie. Si no llega pie, no debe pintarse la etiqueta <footer>. Úsalo dos veces: con pie y sin él.
Ver solución
import type { ReactNode } from 'react';
type PanelProps = {
titulo: string;
acciones?: ReactNode;
pie?: ReactNode;
children: ReactNode;
};
function Panel({ titulo, acciones, pie, children }: PanelProps) {
return (
<section className="tarjeta">
<div className="fila" style={{ justifyContent: 'space-between' }}>
<h2 style={{ margin: 0 }}>{titulo}</h2>
{acciones}
</div>
{children}
{pie ? <footer>{pie}</footer> : null}
</section>
);
}
export default function App() {
return (
<main>
<Panel titulo="Cliente 1001" acciones={<button>Editar</button>} pie="Alta: 10/02/2018">
<p>Talleres Martín S.L.</p>
</Panel>
<Panel titulo="Cliente 1002">
<p>Construcciones Levante S.A.</p>
</Panel>
</main>
);
}
Con {pie ? ... : null} la etiqueta <footer> solo existe cuando hay contenido. Si la hubieras dejado fija, el borde superior del pie se vería también en el panel que no lo lleva. Fíjate en que pie admite una cadena: ReactNode incluye texto.
Ejercicio 5: Extrae componentes
Este componente lo hace todo. Divídelo en Avatar (iniciales), DatoFila (etiqueta y valor) y FichaCliente, sin cambiar lo que se ve.
export default function App() {
return (
<section className="tarjeta">
<span className="insignia">TM</span>
<h2>Talleres Martín S.L.</h2>
<p><strong>NIF:</strong> B12345678</p>
<p><strong>Ciudad:</strong> Valencia</p>
<p><strong>Teléfono:</strong> 961234567</p>
</section>
);
}
Ver solución
type Cliente = {
nombre: string;
nif: string;
ciudad: string;
telefono: string;
};
function iniciales(nombre: string): string {
return nombre
.split(' ')
.slice(0, 2)
.map((palabra) => palabra[0])
.join('')
.toUpperCase();
}
function Avatar({ nombre }: { nombre: string }) {
return <span className="insignia">{iniciales(nombre)}</span>;
}
function DatoFila({ etiqueta, valor }: { etiqueta: string; valor: string }) {
return (
<p>
<strong>{etiqueta}:</strong> {valor}
</p>
);
}
function FichaCliente({ cliente }: { cliente: Cliente }) {
return (
<section className="tarjeta">
<Avatar nombre={cliente.nombre} />
<h2>{cliente.nombre}</h2>
<DatoFila etiqueta="NIF" valor={cliente.nif} />
<DatoFila etiqueta="Ciudad" valor={cliente.ciudad} />
<DatoFila etiqueta="Teléfono" valor={cliente.telefono} />
</section>
);
}
const cliente: Cliente = {
nombre: 'Talleres Martín S.L.',
nif: 'B12345678',
ciudad: 'Valencia',
telefono: '961234567',
};
export default function App() {
return <FichaCliente cliente={cliente} />;
}
Ahora las iniciales se calculan a partir del nombre en lugar de estar escritas a mano, y DatoFila elimina la repetición. El espacio entre </strong> y {valor} está en la misma línea, así que JSX lo conserva.
Ejercicio 6: Campo reutilizable con ref 🆕 19.0
🆕 Desde React 19.0. No existe en React 18.3 ni anteriores. Si usas una versión anterior: resuelve el ejercicio envolviendo
CampoFormularioenforwardRef, que es justo lo que pide el ejercicio 7 a la inversa.
Crea CampoFormulario con la prop etiqueta, una prop opcional error, y todos los atributos de <input>. La ref debe llegar al <input>. En App, un botón dará el foco al campo, que se mostrará con un error de ejemplo.
Ver solución
import { useRef, type ComponentProps } from 'react';
type CampoFormularioProps = ComponentProps<'input'> & {
etiqueta: string;
error?: string;
};
function CampoFormulario({ etiqueta, error, ...resto }: CampoFormularioProps) {
return (
<label>
{etiqueta}
<input aria-invalid={error ? true : undefined} {...resto} />
{error && <small style={{ color: '#c62828' }}>{error}</small>}
</label>
);
}
export default function App() {
const email = useRef<HTMLInputElement>(null);
return (
<section className="tarjeta">
<CampoFormulario
etiqueta="Correo"
type="email"
defaultValue="ana.garcia@"
error="El correo no es válido"
ref={email}
/>
<button onClick={() => email.current?.focus()}>Corregir</button>
</section>
);
}
No hay que mencionar ref, porque va dentro de resto. aria-invalid avisa a los lectores de pantalla; lo verás en el módulo 24.
Ejercicio 7: Migra desde forwardRef
Convierte este componente de React 18 a la sintaxis de React 19 y comprueba que el foco funciona igual.
const Buscador = forwardRef<HTMLInputElement, { marcador: string }>(function Buscador({ marcador }, ref) {
return <input ref={ref} type="search" placeholder={marcador} />;
});
Ver solución
import { useRef, type Ref } from 'react';
type BuscadorProps = {
marcador: string;
ref?: Ref<HTMLInputElement>;
};
function Buscador({ marcador, ref }: BuscadorProps) {
return <input ref={ref} type="search" placeholder={marcador} />;
}
export default function App() {
const buscador = useRef<HTMLInputElement>(null);
return (
<div className="fila">
<Buscador marcador="Buscar empleado" ref={buscador} />
<button onClick={() => buscador.current?.focus()}>Buscar</button>
</div>
);
}
Los pasos son siempre los mismos: quitar forwardRef, añadir ref?: Ref<...> al tipo de las props, y leerla del objeto de props en lugar del segundo parámetro. Quien usa el componente no cambia nada.
Ejercicio 8: Organigrama recursivo
Un componente puede usarse a sí mismo. Crea Organigrama, que reciba un jefeId y pinte en una lista anidada a quienes dependen de él, cada uno con sus propios subordinados. Empieza con los que tienen jefeId igual a null. Usa estos empleados reales:
empId | Nombre | Oficio | jefeId |
|---|---|---|---|
| 100 | Ana | DIRECTOR GENERAL | null |
| 101 | Luis | DIRECTOR | 100 |
| 102 | Marta | DIRECTOR | 100 |
| 110 | Carmen | VENDEDOR | 101 |
| 111 | David | VENDEDOR | 101 |
| 120 | Pablo | ANALISTA | 102 |
Ver solución
type Persona = {
empId: number;
nombre: string;
oficio: string;
jefeId: number | null;
};
const plantilla: Persona[] = [
{ empId: 100, nombre: 'Ana', oficio: 'DIRECTOR GENERAL', jefeId: null },
{ empId: 101, nombre: 'Luis', oficio: 'DIRECTOR', jefeId: 100 },
{ empId: 102, nombre: 'Marta', oficio: 'DIRECTOR', jefeId: 100 },
{ empId: 110, nombre: 'Carmen', oficio: 'VENDEDOR', jefeId: 101 },
{ empId: 111, nombre: 'David', oficio: 'VENDEDOR', jefeId: 101 },
{ empId: 120, nombre: 'Pablo', oficio: 'ANALISTA', jefeId: 102 },
];
type OrganigramaProps = {
personas: Persona[];
jefeId: number | null;
};
function Organigrama({ personas, jefeId }: OrganigramaProps) {
const equipo = personas.filter((p) => p.jefeId === jefeId);
// Caso base de la recursión: sin subordinados no se pinta nada
if (equipo.length === 0) return null;
return (
<ul className="arbol">
{equipo.map((p) => (
<li key={p.empId}>
{p.nombre} <span className="insignia">{p.oficio}</span>
<Organigrama personas={personas} jefeId={p.empId} />
</li>
))}
</ul>
);
}
export default function App() {
return (
<section className="tarjeta">
<h2>Organigrama</h2>
<Organigrama personas={plantilla} jefeId={null} />
</section>
);
}

Figura 12. Cada Organigrama pinta un nivel y delega el siguiente en otro Organigrama.
Al igual que una función recursiva, necesita un caso base: devolver null cuando no hay subordinados. Sin él, cada empleado seguiría creando niveles vacíos de <ul>.
Ejercicio 9 (propuesto): Reorganiza en ficheros
Toma el panel de departamentos de la sección 12 y repártelo en ficheros siguiendo la sección 10: componentes/Insignia.tsx, componentes/Pagina.tsx, departamentos/tipos.ts, departamentos/datos.ts, departamentos/TarjetaDepartamento.tsx y departamentos/ResumenPlantilla.tsx. Comprueba con npm run build que compila y con npm run lint que no hay avisos.
Ejercicio 10 (propuesto): Línea de pedido
Crea LineaPedido, una fila de tabla con producto, cantidad, precioUnitario y descuento (opcional, por defecto 0, en porcentaje), que calcule el importe. Crea también TablaPedido, que reciba children con las filas y un pie con el total. Datos del pedido 5001: Portátil Gaming 16 (1 × 1499 €), segundo producto (2 × 19,90 €) y tercero (2 × 139 €, 5 % de descuento).
Ejercicio 11 (propuesto): Botón polimórfico simple
Crea BotonEnlace, que acepte todas las props de <a> más variante, y que por defecto añada rel="noreferrer" cuando target sea _blank. Razona por qué el orden entre tus valores y {...resto} decide quién puede sobrescribirlos.
Resumen
| Concepto | API | Idea clave |
|---|---|---|
| Componente | function Nombre() { return ...; } | Función con nombre en mayúscula que describe la interfaz |
| Elemento | <Nombre /> | Objeto que describe qué mostrar; crearlo no ejecuta la función |
| Render | Lo hace React | No llames al componente; no lo definas dentro de otro |
| Valores devueltos | ReactNode | JSX, texto, arrays o null; nunca objetos corrientes ni promesas en cliente |
| Props tipadas | type XProps = { ... } | Obligatorias, ?, uniones y funciones onAlgo |
| Valores por defecto | { a = 1 } | Solo con undefined, no con null; defaultProps se ignora desde 19.0 |
| Reenvío | ComponentProps<'button'>, ...resto | El último en JSX gana |
| Solo lectura | Readonly<P> | Las props no se modifican; congeladas solo en desarrollo |
| Flujo de datos | Props y onAlgo | Los datos bajan y los avisos suben |
| Contenido | children: ReactNode | Pasa interfaz; si no pintas {children}, desaparece |
| Huecos | Props de tipo ReactNode | Composición en lugar de herencia |
| Pureza | Strict Mode, react/purity | Mismas entradas, misma salida, sin tocar nada de fuera |
| Árbol | React DevTools | Muestra qué componente renderiza a cuál y con qué props |
| Exportaciones | export, export default, import type | Con nombre para lo compartido; por defecto para App y páginas |
| Organización | Carpetas por funcionalidad | Lo que cambia junto vive junto |
ref como prop 🆕 19.0 | ref?: Ref<T> | Prop normal; en React 18 hace falta forwardRef |