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é defaultProps ya 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 children y 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 ref como una prop normal (React 19.0) y conocer forwardRef, 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.

Título Plantilla y dos tarjetas iguales con el nombre Ana García López y el oficio DIRECTOR GENERAL

Figura 1. El mismo componente usado dos veces. Falta decirle a cada uso qué datos debe mostrar.

Las tres reglas básicas

  1. El nombre empieza por mayúscula. Es lo único que distingue <FichaEmpleado /> de una etiqueta HTML.
  2. Devuelve una sola raíz. Si necesitas varias, usa un fragmento <>...</>, como viste en el módulo 02.
  3. 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 Saludo se ejecute. Crear un elemento es barato: es un objeto.
  • React ejecuta de arriba abajo: primero App, que devuelve un Marco; luego Marco, que devuelve un Saludo entre sus hijos; y por último Saludo.
  • 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.

Tres cajas en fila: la función componente, el elemento como objeto y el DOM resultante, unidas por las flechas JSX y render

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 escribes async function Listado(), React lanza An 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í admiten async, 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>
  );
}

Dos tarjetas distintas: Ana García López, directora general, y Pablo Moreno Herrera, analista, cada una con su salario

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 escribes salario="6500", estás pasando una cadena.
  • { nombre, apellidos } en la cabecera es desestructuración de JavaScript, no una sintaxis de React. Equivale a recibir props y leer props.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:

  • destacado se usa sin valor. En JSX, <Fila destacado /> equivale a destacado={true}.
  • densidad es una unión de literales. Es mucho mejor que string: el editor te sugiere los valores y una errata no compila.
  • onSeleccionar es una función. Así es como un hijo avisa a su padre de que ha pasado algo. Por convenio, la prop empieza por on y 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>
  );
}

Cuatro tarjetas de producto; las de stock sin indicar o undefined muestran 0 y la de stock null muestra null

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. null no lo activa, porque es un valor que alguien ha pasado a propósito. Te afectará con los datos de la API EMPRESA, donde comision o presupuesto pueden ser null. 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, defaultProps aú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 nombre sea de solo lectura. Si quieres el aviso al compilar, usa Readonly: function Ficha(props: Readonly<{ nombre: string }>), y obtendrás TS2540: 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.

Un componente padre pasa datos hacia abajo a dos hijos mediante props, y un hijo llama a una función recibida para avisar hacia arriba

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>
  );
}

Tres tarjetas con el mismo marco y título pero contenidos diferentes

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>
  );
}

Panel con el título Pedido 5001, un botón Imprimir arriba a la derecha, el contenido y un pie con la fecha de envío

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:

Tarjeta con los textos Invitado número 2, Invitado número 4 e Invitado número 6

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.

Árbol con App en la raíz, Cabecera y Panel como hijos, y debajo Tarjeta e Insignia; una flecha indica que las props bajan

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 a Tarjeta.tsx puede funcionar en tu equipo y fallar en el servidor de integración continua. TypeScript lo detecta con TS1149: 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.
  • PascalCase para componentes; camelCase para utilidades.
  • .tsx si el fichero contiene JSX; .ts si 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í ref no llega al componente función. Si usas una versión anterior: envuelve el componente en forwardRef, que recibe la ref como 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>
  );
}

Tarjeta con el campo Nombre del empleado enfocado y el botón Ir al nombre

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 ref rompe 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>
  );
}

Panel Departamentos con un aviso resumen y una rejilla de siete tarjetas; INVESTIGACIÓN muestra la insignia Sin presupuesto

Figura 11. El panel terminado, con los datos reales de EMPRESA.

Algunos detalles de este código:

  • map convierte cada dato en un componente y key lo identifica (módulo 06). Observa que key va en el componente, no dentro de TarjetaDepartamento.
  • presupuesto ?? 0 trata el null de la API, y sinPresupuesto evita repetir la comparación. TypeScript sigue la pista a esa constante y sabe que en la segunda rama presupuesto es un número.
  • useGrouping: 'always' hace falta porque, en español, Intl.NumberFormat no 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.
  • euros se 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 App conoce 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 CampoFormulario en forwardRef, 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>
  );
}

Lista anidada con Ana arriba, Luis y Marta debajo, y sus equipos un nivel más adentro

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