learning-front

Nivel 6 · React de cero a héroe (con TypeScript)

Patrones de componentes

Componentes compuestos y render props: diseñar APIs de componente flexibles donde el consumidor compone las piezas en vez de configurar veinte props.

Hasta ahora has escrito componentes que reciben datos por props y los pintan. Este capítulo va de un escalón más: diseñar la API de un componente para que sea flexible y agradable de usar. Cuando un componente lo van a reutilizar en muchos sitios —tuyo o de un compañero—, la forma de su API decide si es un placer o un suplicio.

El componente que crece a base de props#

La primera tentación, cuando un componente necesita variantes, es añadir props. Una tarjeta de héroe que a veces muestra el rol, a veces el winrate, a veces va destacada, a veces lleva una insignia… acaba así:

Funciona, pero fíjate en el coste. Cada variante visual nueva es otra prop booleana: mostrarRol, mostrarWinrate, destacada, conInsignia… Esto tiene nombre: la explosión de props (o boolean trap). ¿Y qué tiene de malo? Tres consecuencias concretas:

  • El consumidor no controla el orden. La insignia sale donde decidió quien escribió la tarjeta, no quien la usa. Si la quieres arriba, necesitas… otra prop.
  • Cada necesidad nueva toca el componente. ¿Quieres meter un botón propio dentro de la tarjeta? No puedes: tienes que añadir conBoton, textoBoton, onBoton… y así sin fin.
  • La API se vuelve ilegible. <TarjetaHeroe mostrarRol mostrarWinrate destacada conInsignia textoInsignia="MVP" /> es una sopa de banderas donde no se entiende qué pinta cada una.

El problema de fondo: quien escribió el componente intenta anticipar todos los usos. Y nunca acierta del todo. La solución es darle la vuelta: que sea el consumidor quien componga. Eso es la inversión de control, y los dos patrones de este capítulo la aplican.

Componentes compuestos#

Un componente compuesto no es un componente, son varios que trabajan juntos: un contenedor que guarda el estado compartido y unas piezas que el consumidor coloca a su gusto. El ejemplo canónico son unas pestañas:

Mira cómo se usa al final del código, en App:

tsx
<Tabs inicial="Daño">
  <TabList>
    <Tab id="Daño">Daño</Tab>
    <Tab id="Apoyo">Apoyo</Tab>
    <Tab id="Tanque">Tanque</Tab>
  </TabList>
  <TabPanel id="Daño">...</TabPanel>
</Tabs>

El consumidor compone las piezas. Decide cuántas pestañas hay, qué texto llevan y qué hay dentro de cada panel. Y sin embargo, Tab y TabPanel se entienden entre ellos: al pulsar una pestaña, su panel aparece. ¿Cómo, si nadie les pasa props con el estado?

El estado compartido vive en un context#

La pieza que lo hace posible es el context (el del capítulo de custom hooks). El contenedor Tabs guarda la pestaña activa en un useState y la mete en un Provider:

tsx
// El contenedor guarda el estado y lo comparte por context.
function Tabs({ inicial, children }: { inicial: string; children: ReactNode }) {
  const [activa, setActiva] = useState(inicial);
  return (
    <TabsContext.Provider value={{ activa, setActiva }}>
      <div className="tabs">{children}</div>
    </TabsContext.Provider>
  );
}

Cada pieza lee ese estado con useContext, sin recibirlo por props:

tsx
// Tab lee el estado compartido directamente del context.
function Tab({ id, children }: { id: string; children: ReactNode }) {
  const ctx = useTabs();
  return (
    <button role="tab" aria-selected={ctx.activa === id} onClick={() => ctx.setActiva(id)}>
      {children}
    </button>
  );
}

Por eso el consumidor no cablea nada entre las piezas: se comunican por debajo, a través del context. La API pública es minúscula —cuatro piezas— pero la flexibilidad es total. Esa es la ventaja: API pequeña, composición libre. Para añadir una pestaña nueva, el consumidor escribe un <Tab> y un <TabPanel> más; no toca el componente.

El hook guardián: el «¿y qué?» del context sin Provider#

Fíjate en que las piezas no usan useContext(TabsContext) directamente, sino un hook propio, useTabs():

tsx
// Hook guardián: lee el context y FALLA con un error claro si no hay <Tabs> encima.
function useTabs(): TabsContexto {
  const ctx = useContext(TabsContext);
  if (ctx === null) {
    throw new Error("<Tab> y <TabPanel> deben ir dentro de <Tabs>");
  }
  return ctx;
}

¿Por qué molestarse? Aquí está el «¿y qué?». Si alguien usa un <Tab> fuera de <Tabs>, no hay Provider encima, así que useContext devuelve el valor por defecto que le diste a createContext: null. Si la pieza hiciera ctx.activa directamente, el componente reventaría con un Cannot read properties of null (reading 'activa'): un error críptico, que no menciona <Tabs> por ningún lado y que aparece en una línea cualquiera de la pieza, lejos de la causa real. El hook guardián comprueba el null una vez y lanza un mensaje que dice exactamente qué hiciste mal. Es la diferencia entre un error que te orienta y uno que te hace perder media hora.

Render props#

El segundo patrón ataca un problema parecido desde otro ángulo: compartir lógica dejando que el consumidor controle el render. Un render prop es una prop que es una función: el componente la llama con su estado interno, y la función devuelve el JSX.

Seleccionables es dueño de la lógica de selección (qué héroes están elegidos, cómo se alternan), pero no decide cómo se pinta. En vez de children siendo JSX, children es una función:

tsx
// children NO es JSX: es una función que recibe el estado y devuelve JSX.
interface SeleccionablesProps {
  children: (estado: EstadoSeleccion) => ReactNode;
}

function Seleccionables({ children }: SeleccionablesProps) {
  const [seleccionados, setSeleccionados] = useState<string[]>([]);
  function alternar(nombre: string) { /* ... */ }
  // Llamamos a la función-hijo con el estado: ELLA decide la presentación.
  return children({ seleccionados, alternar });
}

El consumidor recibe el estado y pinta lo que quiera con él. La lógica se reutiliza; el markup es libre.

Render props vs custom hooks: sé honesto#

Aquí toca una verdad incómoda: para compartir lógica, los custom hooks reemplazaron a los render props. La misma selección, como hook, sería:

tsx
// El mismo comportamiento, empaquetado en un custom hook (capítulo de custom hooks).
function useSeleccion() {
  const [seleccionados, setSeleccionados] = useState<string[]>([]);
  function alternar(nombre: string) { /* ... */ }
  return { seleccionados, alternar };
}

// Y se usa sin anidar funciones:
function MiLista() {
  const { seleccionados, alternar } = useSeleccion();
  return /* ... el JSX, sin envoltorios ... */;
}

Más directo, sin funciones anidadas. El abuso de render props lleva al wrapper hell: funciones dentro de funciones dentro de funciones, cada librería con su <X>{(a) => <Y>{(b) => ...}</Y>}</X>. Los hooks lo aplanan.

Entonces, ¿están muertos los render props? No. Siguen siendo la herramienta cuando lo que comparte el componente es el control del render, no solo lógica: una lista virtualizada que decide qué filas pintar según el scroll, o una librería headless que te da el comportamiento (un menú, un combobox) y te deja a ti el markup entero. En esos casos el componente necesita envolver tu render, y un hook no llega. La regla práctica: para compartir lógica, primero un hook; render prop cuando de verdad va de controlar el render.

Comprueba lo que sabes#

Pregunta 1 de 4

¿Qué problema resuelven los componentes compuestos frente a un componente con muchas props de configuración?

Tu turno#

Ejercicio · en esta página

Acordeón del roster (componente compuesto)

Construye un <Acordeon> compuesto que muestre el roster agrupado por rol. Cada rol es una sección con una cabecera que la despliega o la pliega. El estado (qué sección está abierta) lo gestiona el contenedor y lo comparte con sus piezas por context, igual que el <Tabs> del capítulo.

Paso 1: Acordeón compuesto funcional

  • Un context que comparte { abierta, alternar } desde <Acordeon> con sus piezas
  • alternar(id): abre la sección si está cerrada, la cierra si ya estaba abierta
  • <Cabecera id>: al pulsar llama a alternar(id) y lleva aria-expanded con el estado
  • <Panel id>: renderiza su contenido solo si su sección es la abierta (si no, null)
  • El consumidor compone una Cabecera + un Panel por rol, con el roster filtrado
Ver soluciones
import { createContext, useContext, useState, Fragment } from "react";
import type { ReactNode } from "react";

// El tipo de un héroe del roster.
interface Heroe {
  id: number;
  nombre: string;
  rol: "Daño" | "Apoyo" | "Tanque";
  partidas: number;
  victorias: number;
}

// Datos de partida: el roster del equipo.
const ROSTER: Heroe[] = [
  { id: 1, nombre: "Tracer", rol: "Daño", partidas: 120, victorias: 78 },
  { id: 2, nombre: "Genji", rol: "Daño", partidas: 140, victorias: 79 },
  { id: 3, nombre: "Mercy", rol: "Apoyo", partidas: 200, victorias: 150 },
  { id: 4, nombre: "Ana", rol: "Apoyo", partidas: 88, victorias: 61 },
  { id: 5, nombre: "Reinhardt", rol: "Tanque", partidas: 95, victorias: 52 },
];

// Los tres roles, en orden. Cada uno es una sección del acordeón.
const ROLES = ["Daño", "Apoyo", "Tanque"] as const;

// El valor que <Acordeon> comparte con sus piezas internas.
interface AcordeonContexto {
  abierta: string | null;
  alternar: (id: string) => void;
}

// createContext con valor por defecto null (lo afinamos en el tier "mejor").
const AcordeonContext = createContext<AcordeonContexto | null>(null);

// <Acordeon>: el contenedor. Guarda qué sección está abierta y lo comparte por context.
function Acordeon({ children }: { children: ReactNode }) {
  // El estado vive en el contenedor: una sola fuente de verdad para todas las piezas.
  const [abierta, setAbierta] = useState<string | null>(null);

  // alternar: si la sección id ya está abierta, la cierra (null); si no, la abre.
  function alternar(id: string) {
    setAbierta((actual) => (actual === id ? null : id));
  }

  // El Provider mete { abierta, alternar } en el context para todos los descendientes.
  return (
    <AcordeonContext.Provider value={{ abierta, alternar }}>
      <div className="acordeon">{children}</div>
    </AcordeonContext.Provider>
  );
}

// <Cabecera id>: el botón que abre o cierra su sección.
function Cabecera({ id, children }: { id: string; children: ReactNode }) {
  // useContext lee el valor que comparte <Acordeon>. El "!" afirma que hay provider encima.
  const ctx = useContext(AcordeonContext)!;
  // estaAbierta: si la sección de esta cabecera es la que está abierta.
  const estaAbierta = ctx.abierta === id;

  return (
    <button
      className="acordeon__cabecera"
      type="button"
      // aria-expanded informa a la tecnología asistiva de si la sección está desplegada.
      aria-expanded={estaAbierta}
      // Al hacer clic, alternamos esta sección.
      onClick={() => ctx.alternar(id)}
    >
      <span>{children}</span>
      {/* La flecha gira con CSS cuando aria-expanded es "true". */}
      <span className="acordeon__flecha" aria-hidden="true"></span>
    </button>
  );
}

// <Panel id>: muestra su contenido solo si su sección está abierta.
function Panel({ id, children }: { id: string; children: ReactNode }) {
  const ctx = useContext(AcordeonContext)!;

  // Si esta sección no es la abierta, no renderizamos nada.
  if (ctx.abierta !== id) {
    return null;
  }

  return <div className="acordeon__panel">{children}</div>;
}

export default function App() {
  return (
    <section>
      <h1 className="titulo">Roster por rol</h1>
      {/* El consumidor compone las piezas; el estado lo gestiona <Acordeon> por dentro. */}
      <Acordeon>
        {ROLES.map((rol) => {
          // Para cada rol, filtramos sus héroes del roster.
          const heroes = ROSTER.filter((h) => h.rol === rol);
          return (
            // Fragment agrupa la Cabecera y el Panel de cada rol sin añadir un nodo extra.
            <Fragment key={rol}>
              <Cabecera id={rol}>
                {rol} ({heroes.length})
              </Cabecera>
              <Panel id={rol}>
                <div className="lista">
                  {heroes.map((h) => {
                    // El winrate es un dato derivado: se calcula en el render.
                    const winrate = Math.round((h.victorias / h.partidas) * 100);
                    return (
                      <article key={h.id} className="tarjeta">
                        <h2 className="tarjeta__nombre">{h.nombre}</h2>
                        <p className="tarjeta__stats">{winrate}% winrate</p>
                      </article>
                    );
                  })}
                </div>
              </Panel>
            </Fragment>
          );
        })}
      </Acordeon>
    </section>
  );
}

Por qué este nivel

  • El estado vive en el contenedor (Acordeon), no en las piezas. Esa es la idea central del patrón: una sola fuente de verdad para qué sección está abierta, compartida por context. Cabecera y Panel no reciben ese estado por props; lo leen con useContext. Así el consumidor compone <Cabecera>/<Panel> libremente sin tener que cablear props entre ellos.
  • El Panel devuelve null cuando su sección no es la abierta. Devolver null es la forma idiomática en React de 'no renderizar nada': el componente sigue existiendo en el árbol pero no produce DOM. Es lo mismo que hace el TabPanel del capítulo.