Módulo 7: Lifting State And Composition

Composición sobre *prop drilling*

Descripción

Levantar el estado (lecciones 2-3) resolvió dónde vive el carrito, y el reducer (lecciones 4-5) resolvió cómo cambia. Pero levantar deja una consecuencia incómoda: el estado ahora vive arriba, en el App, y muchas veces el componente que lo necesita está muy abajo en el árbol. Para que baje, la prop tiene que cruzar todos los componentes intermedios —y aquí está el problema— aunque esos intermedios no la usen para nada, solo la reciben y la reenvían. A eso se le llama prop drilling ("perforar" con la prop nivel tras nivel), y ensucia el código: componentes que cargan props ajenas, firmas infladas, y un cambio en la prop que obliga a tocar toda la cadena. Esta lección instala la herramienta de React para evitarlo: la composición —pasar children y componentes como props—, que deja que el dato salte los niveles intermedios en vez de encadenarse por ellos. Lo vas a ver contado: cuántos niveles reenvía una prop en prop drilling (3) frente a composición (0). Y verás dónde termina la composición y empieza Context: cuando el dato lo necesita medio árbol, ni componer basta, y ahí entra la guía frontend-state-and-data.

Conexión con el módulo. Es la tercera pata del módulo. Levantar puso el estado arriba; componer resuelve cómo llega abajo sin ensuciar el camino. Prepara la lección 7 (container vs presentational), que es la forma natural de organizar componentes una vez que sabes componer. Y marca la frontera del módulo: cuando el prop drilling es a gran escala, la respuesta ya no es de esta guía sino de frontend-state-and-data (Context, estado global).

Una analogía: el marco de cuadro

Piensa en un marco de cuadro vacío, de esos con un hueco donde va la foto. El marco no sabe qué imagen va a sostener: puede ir un paisaje, un retrato, un diploma. Su trabajo es puramente estructural —tiene un borde, un vidrio, un soporte— y deja un hueco para que metas la imagen que quieras. El marco no necesita conocer la foto; solo la sostiene.

Ahora contrasta dos formas de hacerle llegar una foto a una repisa que está al fondo de la casa. Forma prop drilling: le das la foto a la persona de la entrada, que se la pasa a la de la sala, que se la pasa a la del pasillo, que se la pasa a la del cuarto, que por fin la pone en la repisa. Cuatro personas cargaron una foto que no era suya solo para hacerla llegar. Si cambias la foto, tienes que avisarle a los cuatro. Forma composición: pones la foto en el marco desde la entrada, y pasas el marco ya armado de mano en mano. Nadie en el camino tiene que saber qué foto lleva —solo pasan un marco—; la foto y el marco viajan juntos como una caja cerrada, y se colocan al fondo. Los intermedios movieron un marco, no tu foto.

En React, la foto es el dato (el cart). El marco es children: un hueco que un componente deja para que le metas lo que sea (<Layout>{ aqui va cualquier cosa }</Layout>). Cuando el App arma el <Cart cart={cart} /> y lo mete como children de un Layout, el Layout (y todos los intermedios) solo ven "algo que pintar en su hueco" —una caja opaca—, sin conocer el cart. El dato viajó dentro del elemento hijo, saltándose a los intermedios. Ese es el corazón de componer: en vez de pasar el dato a través de cada nivel, armas el componente que lo usa donde el dato vive y lo pasas como contenido.

Ejemplo trabajado: contar cuántos niveles cruza una prop

Vamos a medir el problema y la solución con un número. Imagina que el Cart está al fondo de una jerarquía de layout: AppPageLayoutSidebarCart. El cart vive en el App; el Cart lo necesita; y Page, Layout, Sidebar no lo usan —son puro andamiaje visual—. Contemos cuántos de esos intermedios tienen que reenviar el cart.

Primero, cómo se ve el prop drilling en React de verdad. Cada intermedio recibe cart en sus props solo para pasarlo hacia abajo:

function App() {
  const [cart] = useReducer(cartReducer, []);
  return <Page cart={cart} />;              // empieza a bajar
}
function Page({ cart })    { return <Layout cart={cart} />; }   // no lo usa, lo reenvia
function Layout({ cart })  { return <Sidebar cart={cart} />; }  // no lo usa, lo reenvia
function Sidebar({ cart }) { return <Cart items={cart} />; }    // no lo usa, lo reenvia
function Cart({ items })   { return <p>{items.length} items</p>; } // POR FIN lo usa

Y la composición, donde el App arma el <Cart> y lo pasa como children, para que los intermedios no toquen el cart:

function App() {
  const [cart] = useReducer(cartReducer, []);
  // El App arma el Cart CON su dato, y lo mete como children del Layout.
  return (
    <Page>
      <Layout>
        <Sidebar>
          <Cart items={cart} />
        </Sidebar>
      </Layout>
    </Page>
  );
}
// Los intermedios reciben `children` (una caja opaca) y lo pintan. NO conocen cart.
function Page({ children })    { return <div className="page">{children}</div>; }
function Layout({ children })  { return <div className="layout">{children}</div>; }
function Sidebar({ children }) { return <aside>{children}</aside>; }
function Cart({ items })       { return <p>{items.length} items</p>; }

Nota la diferencia clave: en composición, Page/Layout/Sidebar reciben children, no cart. El Cart ya trae su items porque se armó en el App, donde vive el dato. Ahora ejecutemos los dos, contando cuántos intermedios reenvían el cart:

const cart = ['Wireless Mouse', 'Mechanical Keyboard'];

console.log('=== PROP DRILLING: la prop cruza niveles que no la usan ===\n');
let forwards = 0; // componentes intermedios que REENVIAN cart sin usarlo
function Page(props)    { forwards++; console.log('  Page     recibe cart y lo reenvia (no lo usa)');   return Layout(props); }
function Layout(props)  { forwards++; console.log('  Layout   recibe cart y lo reenvia (no lo usa)');   return Sidebar(props); }
function Sidebar(props) { forwards++; console.log('  Sidebar  recibe cart y lo reenvia (no lo usa)');   return Cart(props); }
function Cart(props)    { console.log(`  Cart     USA cart -> ${props.cart.length} items`);              return props.cart.length; }

Page({ cart }); // App pasa cart al tope de la cadena
console.log(`\nNiveles intermedios que reenviaron cart sin usarlo: ${forwards}`);

console.log('\n=== COMPOSICION: App arma el Cart y lo pasa como children ===\n');
let forwards2 = 0; // intermedios que nombran/reenvian cart
function Cart2()         { console.log(`  Cart     USA cart -> ${cart.length} items`);           return cart.length; }
function Sidebar2(props) { console.log('  Sidebar  coloca {children} (no conoce cart)');         return props.children(); }
function Layout2(props)  { console.log('  Layout   pasa {children} (no conoce cart)');           return Sidebar2(props); }
function Page2(props)    { console.log('  Page     pasa {children} (no conoce cart)');           return Layout2(props); }

// App compone: <Page><Layout><Sidebar><Cart/></Sidebar></Layout></Page>
// El Cart ya trae su dato; viaja como children (una caja opaca) hasta el fondo.
Page2({ children: Cart2 });
console.log(`\nNiveles intermedios que nombraron/reenviaron cart: ${forwards2}`);

Qué esperar. Al correr el archivo con Node, la salida es exactamente esta:

=== PROP DRILLING: la prop cruza niveles que no la usan ===

  Page     recibe cart y lo reenvia (no lo usa)
  Layout   recibe cart y lo reenvia (no lo usa)
  Sidebar  recibe cart y lo reenvia (no lo usa)
  Cart     USA cart -> 2 items

Niveles intermedios que reenviaron cart sin usarlo: 3

=== COMPOSICION: App arma el Cart y lo pasa como children ===

  Page     pasa {children} (no conoce cart)
  Layout   pasa {children} (no conoce cart)
  Sidebar  coloca {children} (no conoce cart)
  Cart     USA cart -> 2 items

Niveles intermedios que nombraron/reenviaron cart: 0

Lee el contraste, que es todo un número. En prop drilling, Page, Layout y Sidebar imprimen "recibe cart y lo reenvia (no lo usa)": tres componentes cargaron una prop ajena solo para pasarla. El contador final: 3. Cada uno tuvo que nombrar cart en su firma y reenviarlo, aunque su trabajo (ser andamiaje visual) no tenga nada que ver con el carrito. Si mañana el Cart necesitara también onRemoveFromCart, tendrías que agregar esa prop a los tres intermedios otra vez. Es frágil y ruidoso.

En composición, los mismos tres intermedios imprimen "pasa/coloca {children} (no conoce cart)": ninguno menciona cart. El contador final: 0. El Cart se armó en el App (donde vive el dato) y viajó dentro de children —una caja opaca que los intermedios solo colocan en su hueco—. El dato saltó los tres niveles. Si el Cart necesitara onRemoveFromCart, lo agregas donde lo armas (en el App), y los intermedios ni se enteran: siguen pasando children.

La moraleja es cuantitativa: componer llevó el "niveles que reenvían la prop" de 3 a 0. No es que la composición "esconda" el prop drilling; es que lo elimina, armando el componente que usa el dato en el mismo lugar donde el dato vive, y dejando que los intermedios sean lo que deben ser: cajas con un hueco, indiferentes a su contenido.

Profundización: children, componentes como props, y el teaser de Context

children es la prop especial del contenido entre etiquetas. Cuando escribes <Layout><Cart /></Layout>, lo que pusiste entre <Layout> y </Layout> —el <Cart />— le llega al Layout como la prop children. El Layout lo pinta con {children} donde quiera, sin saber qué es. Eso convierte al Layout en un contenedor genérico: sirve para envolver cualquier cosa, no solo un Cart. Es el marco de cuadro: un hueco para lo que sea.

flowchart TD
    subgraph drilling["Prop drilling: cart cruza 3 niveles"]
      A1[App] -- cart --> P1[Page] -- cart --> L1[Layout] -- cart --> S1[Sidebar] -- cart --> C1[Cart]
    end
    subgraph comp["Composicion: cart viaja DENTRO de children"]
      A2["App (arma Cart con cart)"] -- "children (Cart ya armado)" --> P2[Page] --> L2[Layout] --> S2[Sidebar] --> C2[Cart]
    end

Pasar componentes como props (no solo children). children es el hueco "por defecto", pero puedes tener varios huecos con nombre pasando componentes como props normales: <Layout sidebar={<Cart items={cart} />} header={<SearchBar />} />. El Layout los coloca donde toque ({props.sidebar}, {props.header}). Es la misma idea —armar el elemento donde vive el dato y pasarlo ya hecho—, con más de un slot. Útil cuando un layout tiene varias zonas.

Composición ≠ solo evitar drilling: también reutilizar. Un Layout que recibe children es reutilizable con cualquier contenido; un Layout que recibe cart solo sirve para envolver carritos. Componer no solo limpia el paso del dato: hace a los componentes intermedios genéricos y reutilizables, porque no se atan a lo que envuelven. Ese es el beneficio doble.

Dónde termina la composición: el teaser de Context. La composición brilla cuando el dato baja por una rama del árbol y hay unos pocos intermedios. Pero hay datos que necesita medio árbol, en muchas ramas y muchos niveles: el usuario logueado, el tema claro/oscuro, el idioma. Armar todo eso con children se vuelve retorcido (tendrías que componer desde arriba absolutamente todo). Para ese caso, React tiene Context: una forma de "publicar" un valor en un ancestro y que cualquier descendiente, por profundo que esté, lo lea directo sin pasarlo por props. Es como poner el dato en el aire de la casa en vez de dárselo de mano en mano. Pero eso no es de esta guía: Context (y el estado global tipo Zustand/Redux, que resuelve problemas parecidos a mayor escala) se enseña en frontend-state-and-data. Aquí la regla es: para pocos niveles y una rama, compón; cuando sea medio árbol, ese es el problema —y la herramienta— de la otra guía.

El orden correcto de herramientas. No saltes a Context apenas veas dos niveles de props: eso es sobre-ingeniería, como levantar de más (lección 3). El orden sano es: props (lo normal) → si un intermedio carga props ajenas, compón con children → si el dato lo necesita medio árbol en muchas ramas, ahí sí, Context (otra guía). Cada herramienta para su escala.

Errores comunes

"Perforar" una prop por muchos niveles evitable. Qué pasa: el App pasa onAddToCart (o cart, o el usuario) por cinco componentes que no lo usan, hasta el que sí. Cada firma intermedia se infla, y cambiar el dato obliga a tocar los cinco. Por qué pasa: es el camino "obvio" —si el dato está arriba y se necesita abajo, se baja nivel por nivel—. Cómo detectarlo: tienes componentes cuya única relación con una prop es reenviarla. Cómo corregirlo: si los intermedios son andamiaje (layout), compón: arma el componente que usa el dato en el App y pásalo como children. La prop deja de cruzarlos.

Componer cuando un intermedio necesita el dato. Qué pasa: intentas evitar pasar una prop a un intermedio que, resulta, sí la usa (por ejemplo, ProductList necesita products para hacer el map). Ahí la composición no aplica: no es prop drilling, es un dato que ese nivel de verdad ocupa. Por qué pasa: se confunde "prop que cruza sin usarse" con "prop que el nivel usa". Cómo detectarlo: el intermedio lee o transforma la prop, no solo la reenvía. Cómo corregirlo: pásasela normal, por props. Componer es para los intermedios que no usan el dato; los que sí lo usan lo reciben como siempre.

Saltar a Context (o Redux) por dos niveles de props. Qué pasa: al ver bajar una prop dos o tres niveles, instalas Context o una librería de estado global "para no encadenar props". Terminas con más maquinaria que problema. Por qué pasa: se oye "prop drilling" y se corre a la herramienta grande. Cómo detectarlo: usas Context para un dato que baja por una sola rama y pocos niveles. Cómo corregirlo: para eso alcanza componer (o simplemente pasar la prop). Reserva Context para cuando el dato lo necesite medio árbol en muchas ramas —y recuerda que eso se enseña en frontend-state-and-data—. Empieza simple; sube la herramienta solo cuando la escala lo pida.

Ejercicios

Ejercicio 1 — ¿Drilling o dato legítimo? Para cada prop que baja, di si es prop drilling evitable (los intermedios no la usan → componer) o un dato legítimo que el nivel necesita (pasarlo normal): (a) App pasa cart por PageLayoutSidebar hasta Cart; (b) App pasa products a ProductList, que hace products.map(...); (c) App pasa onAddToCart por MainProductList hasta cada ProductCard, y ProductList solo lo reenvía.

Ver solución
  • (a) Prop drilling evitable. Page, Layout, Sidebar son andamiaje visual que no usa cart; solo lo reenvían. Compón: arma <Cart items={cart} /> en el App y pásalo como children. (Es el ejemplo trabajado, 3 → 0.)
  • (b) Dato legítimo. ProductList usa products (hace el map). No es drilling; pásaselo normal por props.
  • (c) Mixto, tirando a drilling en un tramo. ProductList usa onAddToCart (se lo pasa a cada ProductCard que renderiza dentro de su map), así que ese tramo es reenvío legítimo dentro de su trabajo. Pero si Main solo lo reenvía sin usarlo, ese nivel sí es drilling evitable: podrías componer para que Main no cargue onAddToCart. La distinción fina: reenviar a hijos que tú renderizas (como ProductList → sus ProductCard) es parte de tu trabajo; reenviar a través de andamiaje que solo te envuelve es lo evitable.

Ejercicio 2 — Compón el layout. Este App hace prop drilling del user por dos niveles de layout que no lo usan. Reescríbelo con composición (children) para que Shell y Main no reciban user.

function App() {
  const user = useCurrentUser();
  return <Shell user={user} />;
}
function Shell({ user }) { return <div className="shell"><Main user={user} /></div>; }
function Main({ user })  { return <main><Profile user={user} /></main>; }
function Profile({ user }) { return <h1>{user.name}</h1>; }
Ver solución

Armamos el <Profile user={user} /> en el App (donde vive user) y lo pasamos como children. Shell y Main pasan a recibir children, no user:

function App() {
  const user = useCurrentUser();
  return (
    <Shell>
      <Main>
        <Profile user={user} />
      </Main>
    </Shell>
  );
}
function Shell({ children }) { return <div className="shell">{children}</div>; }
function Main({ children })  { return <main>{children}</main>; }
function Profile({ user })   { return <h1>{user.name}</h1>; }

Ahora Shell y Main son contenedores genéricos (sirven para envolver cualquier cosa, no solo perfiles), y user ya no cruza niveles que no lo usan: viaja dentro del <Profile> armado en el App. Bajó de 2 intermedios que reenviaban user a 0.

Ejercicio 3 — ¿Componer o Context? Para cada dato, di si lo resuelves con composición (de este módulo) o con Context (de frontend-state-and-data), y por qué: (a) el cart que baja por una rama de layout hasta un Cart al fondo; (b) el tema claro/oscuro que leen botones, tarjetas, encabezados y modales por todo el árbol; (c) el idioma de la app que necesitan casi todos los textos en cualquier pantalla.

Ver solución
  • (a) Composición. Baja por una rama y pocos intermedios de layout que no usan el dato → arma el Cart en el ancestro y pásalo como children. No hace falta Context.
  • (b) Context. El tema lo necesita medio árbol, en muchas ramas y niveles (botones, tarjetas, encabezados, modales). Componerlo desde arriba para todos sería retorcido. Es el caso de Context → frontend-state-and-data.
  • (c) Context. El idioma lo necesitan casi todos los componentes, en cualquier pantalla. Igual que el tema: dato transversal a todo el árbol → Context, en la otra guía.

La regla: una rama, pocos niveles → componer; medio árbol, muchas ramas → Context (otra guía). La escala decide la herramienta.

Resumen y siguiente paso

En esta lección atacaste la consecuencia incómoda de levantar el estado: el dato vive arriba y el que lo usa está abajo, así que la prop tiende a perforar niveles intermedios que no la usan —el prop drilling—. La herramienta de React para evitarlo es la composición: pasar children (y componentes como props) para armar el componente que usa el dato donde el dato vive y hacerlo viajar dentro de una caja opaca, saltándose los intermedios. Lo anclaste con el marco de cuadro: los intermedios pasan un marco, sin saber qué foto lleva. Y lo mediste: componer bajó los "niveles que reenvían la prop" de 3 a 0. Viste que componer también hace a los intermedios genéricos y reutilizables, y dónde termina: cuando el dato lo necesita medio árbol, ni componer basta, y ahí entra Context —que es de la guía frontend-state-and-data—.

Antes de avanzar deberías poder: reconocer el prop drilling (una prop que cruza niveles que no la usan); reescribirlo con children para que el dato salte los intermedios; distinguir un dato de andamiaje (componer) de uno que el nivel sí usa (pasar normal); y ubicar la frontera con Context.

La lección 7 recoge todo el módulo en un patrón de organización: container vs presentational. Componentes container que tienen el estado y la lógica (el App con su useReducer, que decide), y componentes presentational que solo muestran sus props —el CartView que recibe items y onRemove y no tiene estado propio—. Verás, ejecutado, por qué un presentational es una función pura de sus props (mismas props → misma salida), y por eso testeable y reutilizable, cerrando el módulo justo donde empezó: con el reducer puro que ahora sabes dónde poner.

Recursos