Módulo 1: Thinking In Components
Las props fluyen hacia abajo
Descripción
En la lección anterior quedó una pregunta abierta: cuando ProductList compone un ProductCard, ¿cómo le llegan a la tarjeta los datos del producto? La respuesta es el tema de esta lección, y es uno de los principios más importantes de React: los datos fluyen hacia abajo, de padre a hijo, en una sola dirección.
Desglosemos la idea. Los datos de una app —los productos, el contenido del carrito— entran por arriba, en el componente raíz (App), y bajan por props a través del árbol: App le pasa la lista de productos a ProductList; ProductList le pasa cada producto a un ProductCard; ProductCard usa esos datos para producir su tarjeta. Los datos viajan siempre en la misma dirección —de la raíz hacia las hojas—, como el agua por un río: nunca sube. A esto se le llama flujo unidireccional de datos, y es lo que hace que una app de React sea predecible: para saber de dónde salió un dato que ves en la pantalla, sigues el río hacia arriba hasta su fuente.
Con esto viene una regla dura, la otra mitad de la lección: las props son de solo lectura. Un componente puede leer las props que recibe, pero nunca las muta. ProductCard lee props.name para mostrarlo, pero jamás hace props.name = "otra cosa". Las props son datos que pertenecen al padre, prestados al hijo para que los use, no para que los cambie. Vas a ver esta regla, ejecutada, con el error que salta cuando un componente intenta mutar unas props congeladas.
Conexión con el módulo. El flujo unidireccional cierra el modelo de componentes de este módulo. UI = f(props) (lección 3) dijo que las props son la entrada; esta lección dice de dónde vienen (del padre) y en qué dirección viajan (hacia abajo). La composición (lección 5) es el acto de pasar props al componer. Y la inmutabilidad de las props es lo que hace que el flujo tenga sentido: si un hijo pudiera cambiar las props que le dio el padre, el río correría en las dos direcciones y nadie sabría quién es dueño de qué. La dirección contraria —mandar información de vuelta hacia arriba, del hijo al padre, con callbacks— existe, pero es el módulo 4 (eventos); aquí los datos solo bajan.
Una analogía: la cadena de mando y el memo
Imagina una empresa con una jerarquía clara: dirección general, gerencias, equipos. La información official baja por la cadena de mando: la dirección emite un memo con las metas del trimestre; cada gerencia lo recibe y le pasa a su equipo la parte que le corresponde; cada equipo actúa según lo que recibió. El memo viaja hacia abajo, nivel por nivel. Nadie de un equipo edita el memo original de la dirección y lo re-inyecta hacia arriba; el memo es una fuente única, y baja.
Las props son ese memo. App (la dirección) tiene los datos y los emite; ProductList (la gerencia) recibe la lista y le pasa a cada ProductCard (el equipo) su producto; ProductCard actúa —produce su tarjeta— con lo que recibió. Los datos bajan por el árbol como el memo por la jerarquía.
Y aquí está la parte de "solo lectura". El memo que te llega es para leerlo, no para tacharlo y reescribirlo. Si un equipo tomara el memo de la dirección, le cambiara las cifras con un plumón, y actuara sobre su versión adulterada, se rompería la fuente de verdad: la dirección cree que las metas son unas, el equipo actúa sobre otras, y nadie entiende por qué. Por eso, si necesitas trabajar con una cifra ajustada, sacas tu propia copia y anotas ahí —dejando el memo original intacto—. En React es idéntico: un componente que necesita una versión transformada de una prop calcula un valor nuevo (const displayName = props.name.toUpperCase()) sin tocar la prop original. La prop es el memo de otro; no se tacha.
El giro de la analogía: la jerarquía funciona porque la información baja por un solo camino y cada memo tiene un dueño claro. Si los memos subieran, bajaran y se editaran en cualquier nivel, sería un caos imposible de rastrear. El flujo unidireccional y las props de solo lectura son, juntos, lo que mantiene la cadena de mando ordenada. Guárdala.
Ejemplo trabajado: los datos bajan, y las props no se mutan
Vamos a ver dos cosas ejecutadas. Primero, cómo los datos entran en App y bajan por props hasta ProductCard, imprimiendo qué recibe cada nivel. Segundo, qué pasa cuando un componente intenta mutar una prop.
Los componentes en JSX (la API real). Fíjate en cómo cada nivel pasa hacia abajo lo que le toca: App le pasa catalog a ProductList como products; ProductList le pasa cada product a ProductCard:
function ProductCard(props) {
return (
<article className="product-card">
<h3 className="product-name">{props.name}</h3>
<p className="product-price">{formatPrice(props.priceCents)}</p>
</article>
);
}
function ProductList(props) {
return (
<section className="product-list">
{props.products.map((product) => (
<ProductCard key={product.id} name={product.name} priceCents={product.priceCents} />
))}
</section>
);
}
function App(props) {
return (
<main className="storefront">
<ProductList products={props.catalog} />
</main>
);
}
Lee el flujo en el JSX: App recibe props.catalog y lo baja a ProductList escribiendo products={props.catalog}. ProductList recibe props.products y baja cada producto a ProductCard escribiendo name={product.name} y priceCents={product.priceCents}. Los atributos que escribes en un componente (products={...}, name={...}) son, exactamente, las props que ese componente recibirá. Así se pasan los datos hacia abajo.
Ahora la versión ejecutable en Node. Agregamos un console.log en cada componente para ver qué props recibe cada nivel, y al final intentamos mutar una prop congelada:
'use strict';
function h(tag, props, ...children) {
return { tag, props: props || {}, children: children.flat() };
}
const VOID = new Set(['input', 'img', 'br', 'hr']);
function renderToString(node, indent = 0) {
const pad = ' '.repeat(indent);
if (node == null || typeof node === 'boolean') return '';
if (typeof node !== 'object') return pad + node;
const attrs = Object.entries(node.props)
.map(([k, v]) => ` ${k === 'className' ? 'class' : k}="${v}"`).join('');
if (VOID.has(node.tag)) return `${pad}<${node.tag}${attrs} />`;
const kids = node.children.filter((c) => c != null && c !== false);
if (kids.length <= 1 && kids.every((c) => typeof c !== 'object'))
return `${pad}<${node.tag}${attrs}>${kids[0] ?? ''}</${node.tag}>`;
const inner = kids.map((c) => renderToString(c, indent + 1)).join('\n');
return `${pad}<${node.tag}${attrs}>\n${inner}\n${pad}</${node.tag}>`;
}
function formatPrice(cents) {
return '$' + (cents / 100).toFixed(2);
}
// Los datos entran arriba, en App, y BAJAN por props hasta las hojas.
function ProductCard(props) {
console.log(` ProductCard recibe props: ${props.name}`);
return h('article', { className: 'product-card' },
h('h3', { className: 'product-name' }, props.name),
h('p', { className: 'product-price' }, formatPrice(props.priceCents))
);
}
function ProductList(props) {
console.log(` ProductList recibe props: ${props.products.length} products`);
return h('section', { className: 'product-list' },
props.products.map((product) => ProductCard(product))
);
}
function App(props) {
console.log(`App recibe props: catalog con ${props.catalog.length} products`);
return h('main', { className: 'storefront' },
ProductList({ products: props.catalog })
);
}
const catalog = [
{ id: 'p1', name: 'Wireless Mouse', priceCents: 2599, inStock: true },
{ id: 'p2', name: 'USB-C Hub', priceCents: 3499, inStock: true },
];
console.log('=== los datos fluyen HACIA ABAJO: App -> ProductList -> ProductCard ===');
const html = renderToString(App({ catalog }));
console.log('\n=== HTML resultante ===');
console.log(html);
console.log('\n=== las props son de SOLO LECTURA ===');
function BadCard(props) {
props.name = props.name.toUpperCase(); // MUTAR las props: prohibido
return h('h3', {}, props.name);
}
const frozenProps = Object.freeze({ name: 'Wireless Mouse', priceCents: 2599 });
try {
BadCard(frozenProps);
} catch (e) {
console.log(` X ${e.constructor.name}: ${e.message}`);
}
Qué esperar. Al correr el archivo, la salida es exactamente esta:
=== los datos fluyen HACIA ABAJO: App -> ProductList -> ProductCard ===
App recibe props: catalog con 2 products
ProductList recibe props: 2 products
ProductCard recibe props: Wireless Mouse
ProductCard recibe props: USB-C Hub
=== HTML resultante ===
<main class="storefront">
<section class="product-list">
<article class="product-card">
<h3 class="product-name">Wireless Mouse</h3>
<p class="product-price">$25.99</p>
</article>
<article class="product-card">
<h3 class="product-name">USB-C Hub</h3>
<p class="product-price">$34.99</p>
</article>
</section>
</main>
=== las props son de SOLO LECTURA ===
X TypeError: Cannot assign to read only property 'name' of object '#<Object>'
Dos lecciones en una salida. Vamos por partes.
La primera mitad —los console.log con sangría— es el flujo de datos hecho visible. Fíjate en el orden y la sangría, que no son casuales. Primero corrió App (sin sangría): recibió el catalog con 2 productos. Luego, dentro de App, corrió ProductList (una sangría): recibió esos 2 productos como props.products. Y dentro de ProductList, corrió ProductCard dos veces (dos sangrías): cada una recibió un producto ("Wireless Mouse", luego "USB-C Hub"). Los datos entraron por App y bajaron, nivel por nivel, hasta las hojas. Cada componente recibió exactamente la porción que su padre decidió darle: App tenía todo el catálogo, le pasó la lista a ProductList, que le pasó un producto a cada ProductCard. El río baja, y en cada bifurcación se reparte.
El HTML resultante es la consecuencia de ese flujo: como cada ProductCard recibió su producto, cada tarjeta muestra los datos correctos. Los datos que ves en la pantalla son, literalmente, los que bajaron por props. Si quieres saber de dónde salió el "$34.99" de la segunda tarjeta, sigues el río hacia arriba: lo produjo ProductCard a partir de priceCents: 3499, que le bajó ProductList, que lo sacó del catalog que recibió App. Trazable de punta a punta, en una sola dirección.
La última línea es la regla de solo lectura, ejecutada. BadCard intentó hacer props.name = props.name.toUpperCase() —mutar la prop que recibió—. Como esas props estaban congeladas con Object.freeze (y corremos en modo estricto con 'use strict'), JavaScript lanzó un TypeError: Cannot assign to read only property 'name'. En React de verdad las props no siempre están congeladas, pero la regla es la misma: mutar props es un error, y hacerlo silenciosamente corrompe datos que pertenecen al padre y que quizás otros hijos comparten. Aquí el Object.freeze hace visible —con una excepción— lo que en React es una regla que debes respetar por disciplina.
Por qué el flujo va solo hacia abajo
Podría parecer una limitación arbitraria que los datos solo bajen. Es todo lo contrario: es una garantía. Cuando el flujo es unidireccional, cada dato tiene una sola fuente (el componente de arriba que lo tiene) y un solo camino (hacia abajo por props). Eso hace que puedas responder siempre la pregunta más importante al depurar una UI: "¿por qué la pantalla muestra esto?". La respuesta se encuentra siguiendo las props hacia arriba hasta la fuente. No hay que preguntarse "¿quién más pudo haber cambiado este dato desde abajo?", porque nadie puede: abajo solo se lee.
Si los datos pudieran fluir en cualquier dirección —si un ProductCard pudiera modificar el catálogo de App—, perderías esa garantía. Un dato podría cambiar desde cinco lugares distintos, y rastrear "quién lo cambió y cuándo" sería una pesadilla (es, de hecho, exactamente la pesadilla del código imperativo del módulo 1). El flujo unidireccional cambia ese caos por una regla simple: los datos bajan; para que algo suba, hay un mecanismo explícito (los callbacks del módulo 4), no una mutación silenciosa.
Dibujado, el flujo de este ejemplo:
flowchart TD
Data["catalog (los datos)"] --> App["App"]
App -->|"products={catalog}"| ProductList["ProductList"]
ProductList -->|"name, priceCents<br/>(un producto)"| ProductCard["ProductCard"]
Una sola dirección, de arriba hacia abajo, con las props etiquetando cada flecha. Ese es el río.
Errores comunes
Mutar las props que recibes. Qué pasa: dentro de un componente se hace props.name = ..., props.items.push(...), props.priceCents += tax. Por qué pasa: uno quiere ajustar un dato y lo hace sobre la prop misma, sin pensar que es prestada. Cómo detectarlo: el componente modifica el objeto (o el array) que recibió por props; en modo estricto con props congeladas, salta un TypeError como el del ejemplo; sin congelar, es peor: se corrompe silenciosamente un dato del padre. Cómo corregirlo: trata las props como solo lectura. Si necesitas una versión modificada, crea un valor nuevo sin tocar la prop: const upper = props.name.toUpperCase(), o const withTax = props.priceCents + tax, o const sorted = [...props.items].sort() (una copia del array). La prop original queda intacta; tú trabajas sobre tu copia.
Intentar mandar datos hacia arriba con una asignación. Qué pasa: un componente hijo quiere "avisarle" algo al padre —que se agregó un producto al carrito— y lo intenta cambiando una prop o una variable de arriba. Por qué pasa: la costumbre de "modificar el estado compartido" del mundo imperativo. Cómo detectarlo: el hijo asigna a algo que "vive" en el padre, o esperas que cambiar una prop en el hijo se refleje arriba. Cómo corregirlo: en este módulo, nada sube —todo es estático y los datos solo bajan—. Cuando necesites comunicar del hijo al padre (un clic, un cambio en un input), el mecanismo correcto es un callback que el padre le pasa al hijo por props, y el hijo llama —eso es el módulo 4 (eventos)—. No se logra mutando datos hacia arriba; se logra con una función que baja para ser invocada. Por ahora, quédate con que el flujo de datos es de una sola dirección.
Buscar datos "hacia arriba" en vez de recibirlos por props. Qué pasa: un componente hijo intenta leer datos que no le llegaron por props, alcanzándolos desde una variable global o desde "el padre". Por qué pasa: parece más cómodo que pasar la prop explícitamente por cada nivel. Cómo detectarlo: el componente depende de algo que no está en sus props; no se puede renderizar solo (rompe la autonomía de la lección 4). Cómo corregirlo: todo lo que un componente necesita debe llegarle por props, bajando desde donde vive el dato. Sí, a veces eso significa pasar una prop por varios niveles (lo llaman prop drilling), y hay herramientas para aliviarlo cuando duele —pero eso es la composición avanzada (lección 7) y el estado global (guía frontend-state-and-data)—. La regla base, la que se aprende primero, es: los datos bajan por props, explícitos, nivel por nivel.
Ejercicios
Ejercicio 1 — Traza el flujo. Para el ejemplo trabajado, escribe con tus palabras el camino que recorre el dato priceCents: 3499 desde que entra hasta que aparece como $34.99 en la pantalla. Nombra cada componente por el que pasa y qué hace con el dato.
Ver solución
El camino, de arriba hacia abajo:
- El dato entra como parte de
catalog, en el segundo producto ({ id: 'p2', name: 'USB-C Hub', priceCents: 3499, ... }), cuando llamamosApp({ catalog }). Apprecibecatalogen sus props y lo baja entero aProductList, pasándolo comoproducts(ProductList({ products: props.catalog })). No toca el dato; solo lo pasa.ProductListrecibe la lista enprops.productsy, con elmap, baja cada producto a unProductCard. El segundo producto (conpriceCents: 3499) va a la segunda llamada,ProductCard(product).ProductCardrecibe ese producto en sus props y transforma el dato:formatPrice(props.priceCents)convierte3499en"$34.99", y lo pone en el<p class="product-price">.renderToStringescribe ese<p>$34.99</p>en el HTML final.
Todo en una sola dirección, de la raíz a la hoja. El dato bajó sin cambiar hasta ProductCard, y solo ahí se transformó para mostrarse. Para saber de dónde salió el "$34.99", basta seguir el río hacia arriba: ProductCard ← ProductList ← App ← catalog.
Ejercicio 2 — Corrige la mutación. Este componente muta una prop para mostrar el nombre en mayúsculas. Explica por qué está mal y reescríbelo respetando que las props son de solo lectura:
function ProductCard(props) {
props.name = props.name.toUpperCase();
return h('h3', {}, props.name);
}
Ver solución
Por qué está mal: props.name = props.name.toUpperCase() muta la prop que el componente recibió. Esa prop pertenece al padre (bajó desde App); cambiarla desde el hijo corrompe el dato original, que quizás otros componentes comparten. Si las props están congeladas, esto lanza un TypeError; si no, es peor, porque el dato se corrompe en silencio y aparecen bugs difíciles de rastrear (el catálogo de App ahora tiene el nombre en mayúsculas sin que nadie lo pidiera).
Reescrito, con un valor nuevo:
function ProductCard(props) {
const displayName = props.name.toUpperCase();
return h('h3', {}, displayName);
}
Ahora calculamos displayName como una variable nueva a partir de props.name, sin tocar la prop. props.name sigue siendo "Wireless Mouse" (intacto, como lo dejó el padre); displayName es "WIRELESS MOUSE" (nuestra copia transformada, local a este render). Leemos la prop, no la mutamos. Esa es la regla: para transformar, crea un valor nuevo; nunca escribas sobre la prop.
Ejercicio 3 — ¿Por qué unidireccional? Un compañero pregunta: "¿por qué tanto rollo con que los datos solo bajen? Sería más práctico que un ProductCard pudiera actualizar el carrito directamente cuando le doy clic". Explícale qué se gana con el flujo unidireccional y por qué "actualizar directamente hacia arriba" traería problemas.
Ver solución
Lo que se gana con el flujo unidireccional es predecibilidad y trazabilidad. Como cada dato tiene una sola fuente (arriba) y un solo camino (hacia abajo por props), siempre puedes responder "¿por qué la pantalla muestra esto?" siguiendo las props hacia su origen. No tienes que preguntarte "¿quién más, desde cualquier rincón de abajo, pudo haber cambiado este dato?", porque nadie puede: abajo solo se lee.
Si un ProductCard pudiera "actualizar el carrito directamente hacia arriba" mutando datos, perderías esa garantía. El carrito podría cambiar desde cualquiera de las cien tarjetas, cada una modificándolo por su cuenta, y rastrear "quién lo cambió, cuándo y por qué" se volvería un caos —justo el problema del código imperativo que React vino a resolver—. Además, el dato dejaría de tener un dueño claro: ¿es de App, o de la tarjeta que lo cambió de último?
Ahora bien, la necesidad del compañero es legítima: sí queremos que un clic en la tarjeta afecte el carrito. La solución no es romper el flujo, sino un mecanismo explícito: el padre (App, dueño del carrito) le pasa al hijo un callback por props —una función onAddToCart—, y el hijo la llama cuando le dan clic. El dato sigue viviendo arriba y solo el dueño lo cambia; el hijo no muta nada, solo avisa invocando la función que le prestaron. Así se comunica hacia arriba sin perder la fuente única. Ese mecanismo es el módulo 4 (eventos); aquí basta con entender por qué el flujo de datos, por defecto, va solo hacia abajo.
Resumen y siguiente paso
En esta lección instalaste el principio que cierra el modelo de componentes: los datos fluyen hacia abajo, de padre a hijo, en una sola dirección (flujo unidireccional). Lo ejecutaste y viste, con la sangría de los console.log, cómo los datos entran en App y bajan nivel por nivel —App → ProductList → ProductCard—, repartiéndose en cada bifurcación: App tiene todo el catálogo, baja la lista a ProductList, que baja un producto a cada ProductCard. Y comprobaste, con un TypeError real, la regla dura: las props son de solo lectura —para transformar un dato, se crea un valor nuevo; nunca se muta la prop, que pertenece al padre—. Entendiste por qué el flujo va solo hacia abajo (una fuente, un camino, todo trazable) y que comunicar hacia arriba es un mecanismo explícito (callbacks, módulo 4), no una mutación.
Antes de avanzar deberías poder: trazar el camino de un dato desde App hasta la pantalla; explicar el flujo unidireccional y qué garantiza; detectar y corregir una mutación de props (creando un valor nuevo); y saber que la comunicación hacia arriba es otro mecanismo (eventos), no parte de este módulo.
La lección 7 cierra el círculo del módulo respondiendo la pregunta de fondo: ¿por qué tomarse todo el trabajo de descomponer, componer y pasar props? Vas a ver las tres razones —reuso, aislamiento y poder razonar— con evidencia ejecutada: el mismo ProductCard definido una vez y usado en varios lugares, y por qué un cambio en CartItem no puede romper ProductCard. Es la justificación de todo lo que construiste, antes de armar el storefront completo en la lección 8.
Recursos
- React, "Passing Props to a Component" — react.dev/learn/passing-props-to-a-component. La página oficial sobre cómo se pasan props de padre a hijo, y por qué son de solo lectura. La referencia central de esta lección. En inglés.
- React, "Keeping Components Pure" — react.dev/learn/keeping-components-pure. Su sección sobre no mutar la entrada explica por qué las props no se tocan. En inglés.
- React, "Updating Objects in State" — react.dev/learn/updating-objects-in-state. Aunque es de estado (módulo 3), su lección sobre no mutar y crear valores nuevos aplica igual a las props. En inglés.
- MDN, "Object.freeze()" — developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/freeze. Cómo funciona el congelado que usamos para hacer visible la regla de solo lectura, y por qué en modo estricto mutar lanza un error. En inglés.