Skip to content
This page has been auto-translated and may contain errors.View in English

Componentes compuestos

Un componente de menú puede escribirse como una caja negra: le pasas buttonText y un array items, y él renderiza todo. Esa versión funciona hasta que deja de funcionar. El usuario final no puede cambiar cómo se renderiza un elemento, no puede reordenar las piezas, no puede reutilizar el botón en otro lado, y cada prop tiene que viajar a través de Menu aunque Menu nunca la use. Los componentes compuestos toman el camino opuesto: varios componentes pequeños diseñados para trabajar juntos, donde el usuario final los compone como si fuera markup.

HTML funciona así desde el principio. Un <select> no es nada sin sus <option>s, un <ul> da estilo a los <li>s que contiene, un <form> rastrea los inputs que envuelve, y la familia de elementos de tabla es todo un ecosistema que solo tiene sentido junto. Los componentes compuestos traen ese contrato padre-e-hijos a tus propios componentes de React.

El problema del prop drilling

El menú monolítico hace prop drilling en miniatura: Menu recibe buttonText e items solo para pasarlos a MenuButton y MenuDropdown. No le importan ninguno de los dos. Ese relé es prop drilling, y levantar estado cubre cuándo una o dos capas de esto es normal y cuándo se estira lo suficiente como para necesitar una herramienta diferente. A través de muchas capas, convierte componentes en mensajeros, y refactorizar cualquier capa intermedia significa reordenar toda la cadena.

A veces la solución correcta es no hacer nada. Hacer drilling uno o dos niveles es más barato que una abstracción prematura, y las abstracciones apresuradas cuestan más que la repetición que eliminan. Los patrones de abajo valen la pena cuando el drilling es real.

Aplanar la estructura

La versión compuesta convierte Menu en un envoltorio que renderiza sus hijos, y promueve las piezas internas al nivel del usuario final:

jsx
const sports = ['Tenis', 'Pádel', 'Raquetbol', 'Squash']

<Menu>
  <MenuButton>Deportes</MenuButton>
  <MenuDropdown>
    {sports.map(sport => (
      <MenuItem key={sport}>{sport}</MenuItem>
    ))}
  </MenuDropdown>
</Menu>

Cada pieza se mantiene pequeña y hace un solo trabajo, y el código detrás de ese markup es prácticamente de una línea:

jsx
function Menu({ children }) {
  return <div className="menu">{children}</div>
}

function MenuButton({ children }) {
  return <Button>{children}</Button>
}

function MenuDropdown({ children }) {
  return <div className="menu-dropdown">{children}</div>
}

function MenuItem({ children }) {
  return <div className="menu-item">{children}</div>
}

MenuButton reutiliza el Button del capítulo de composición. La estructura que antes estaba escondida dentro de Menu ahora es visible en el sitio donde se llama, y los datos van directamente al componente que los usa: el array se mapea justo donde se necesita, sin relé a través de Menu. ¿Quieres que cada elemento sea un link en lugar de texto? Pon un <a> dentro de MenuItem. El autor del componente nunca tiene que anticipar esa solicitud.

El trade-off es transparencia por verbosidad. El usuario final escribe más JSX y obtiene control fino sobre cada capa; la caja negra desaparece en ambos sentidos.

Las piezas son componentes separados, así que el usuario final necesita un import para cada una. Las librerías suelen suavizar eso adjuntando los subcomponentes al padre antes de exportarlo, que es JavaScript puro: las funciones son objetos, así que pueden llevar propiedades.

jsx
Menu.Button = MenuButton
Menu.Dropdown = MenuDropdown
Menu.Item = MenuItem
export default Menu

Un import trae la familia completa, y el sitio de llamada, <Menu.Button>, anuncia que las piezas pertenecen juntas. La sintaxis de punto es nombrado y nada más. Lo que sea que haga que las piezas trabajen juntas tiene que venir de algún lado, que es la brecha que la siguiente sección abre.

La pieza faltante: estado implícito

El menú aplanado tiene un agujero: el botón ya no abre el dropdown. El booleano abierto/cerrado y su función toggle viven en Menu, y Menu renderiza solo su div envolvente y {children}. No hay ninguna prop siendo pasada a MenuButton, así que no hay lugar donde colgar el toggle. Las piezas necesitan compartir estado sin que el usuario final lo cableé a través de cada etiqueta, lo que la comunidad llama estado implícito: las partes compuestas se coordinan detrás de escenas mientras el usuario final las compone.

React envía una API que puede simular esto. Children.map hace un loop sobre los hijos directos de un componente (props.children no tiene forma garantizada: varios hijos te dan un array, un solo hijo te da el elemento mismo, y ninguno te da undefined, así que llamar .map() en él funciona perfectamente hasta que no funciona), y cloneElement copia un elemento mientras inyecta props extra:

jsx
import { Children, cloneElement, useState } from 'react'

function Menu({ children }) {
  const [open, setOpen] = useState(false)
  const toggle = () => setOpen(prevOpen => !prevOpen)

  return (
    <div className="menu">
      {Children.map(children, child =>
        cloneElement(child, { open, toggle })
      )}
    </div>
  )
}

Ahora cada hijo directo de Menu recibe silenciosamente open y toggle, y el botón vuelve a funcionar. También se rompe en el momento en que alguien lo toca, que es la lección real.

Dónde Children.map se queda corto

Hay dos limitaciones, y ambas son estructurales. Primero, es frágil: el mapeo solo toca hijos directos, así que si un usuario final envuelve MenuDropdown en un <div> de estilo, el div recibe toggle y open en lugar de eso, React se queja en la consola de que una función no es un valor válido para un atributo DOM, y el menú deja de funcionar. Un componente compuesto que prohíbe envolver sus piezas ha renunciado a la flexibilidad que la justificó.

Segundo, es limitado en profundidad: MenuItem es un nieto, así que no recibe nada. Hacer que el estado llegue a él significa repetir el baile de Children.map-más-cloneElement dentro de MenuDropdown también, un relé por capa, que es prop drilling disfrazado.

Lo que el patrón realmente necesita es una forma de transportar estado desde Menu a cualquier descendiente, sin importar cuán profundo y sin importar cuántos envoltorios haya. Eso es exactamente lo que context hace, y el capítulo de context toma este mismo menú y lo termina: Menu proporciona { open, toggle } a todo lo que envuelve, y MenuButton lee el toggle con useContext sin importar qué se interponga entre ellos.

Nota de versión

La documentación de React ahora clasifica Children y cloneElement bajo APIs heredadas y recomienda alternativas, context entre ellas, para código nuevo. Todavía funcionan y todavía aparecen en muchas bases de código existentes, por eso vale la pena reconocerlas a primera vista. Las lecciones del curso y la mayoría del código más antiguo las escriben con el import por defecto como React.Children y React.cloneElement.

Decidir si un componente debe ser compuesto en primer lugar se reduce a quién necesita control del medio. Un componente cuya disposición interna es fija e improbable que cambie es mejor que sea monolítico con un par de props; el patrón compuesto se paga por sí solo cuando los usuarios finales siguen pidiendo una opción de renderizado más y la lista de props se está convirtiendo en un lenguaje de configuración. Los widgets de divulgación, menús, pestañas y acordeones viven en esa zona, por eso toda librería de UI headless importante los envía en forma compuesta.

Enviar una familia tiene dos costos que vale la pena calcular. Adjuntar las piezas al padre las ata juntas para el bundler, así que importar Menu trae todos los subcomponentes aunque la página nunca los renderice, y una pieza poco usada viaja en el chunk de todas formas.

El segundo costo es que una API compuesta puede describir su estructura esperada sin nunca hacerla cumplir: nada impide que un usuario final renderice MenuDropdown sin ningún MenuButton arriba, y vigilar eso comparando el type de un hijo contra la identidad del componente se rompe en el momento en que un envoltorio se interpone o dos copias del módulo cae en una build. La versión que se mantiene documenta la forma pretendida y hace que cada pieza se comporte sensatamente por su cuenta cuando la forma se rompe.

JunoPiezas pequeñas que funcionan juntas En lugar de un componente Menu grande que toma un montón de props y renderiza todo, los componentes compuestos te dan varias etiquetas pequeñas, un Menu, un MenuButton, un MenuDropdown, un MenuItem, que tú arreglas tú mismo de la manera que arreglas un select y sus options en HTML. Ves la estructura completa justo donde la escribes, y cada pieza obtiene su contenido directamente.

El truco es que las piezas todavía necesitan compartir cosas como "¿está abierto el menú?", y el capítulo de context muestra la forma limpia en que lo hacen.

JunoPiezas pequeñas que funcionan juntas Recurre a componentes compuestos cuando los props de un solo componente se están convirtiendo en un lenguaje de configuración: expón las piezas, deja que el usuario final las componga, y mapea los datos en el sitio de llamada en lugar de retransmitirlos a través de un envoltorio.

Children.map con cloneElement puede inyectar estado compartido en hijos directos, pero se rompe bajo un div envolvente y nunca llega a nietos, así que trátalo como un peldaño y usa context para la coordinación real.

JunoPiezas pequeñas que funcionan juntas Los componentes compuestos intercambian una API opaca de componente único por una transparente y componible; haz el intercambio cuando los usuarios finales necesitan control de las capas del medio, y omítelo cuando la disposición es fija.

Coordina las piezas a través de un proveedor de context con alcance en lugar de Children.map y cloneElement, que son APIs heredadas, frágiles bajo envoltura, y limitadas en profundidad por diseño.

Enviar las piezas como una familia tiene un precio: adjuntarlas al padre impide que el bundler elimine las que una página nunca renderiza, y la API puede documentar su estructura esperada sin nunca hacerla cumplir.

Siguiente: Context, la herramienta que permite que esas piezas compartan estado sin importar cuán profundas estén.