Module 1: Thinking In Components
Props flow downward
Overview
In the previous lesson a question stayed open: when ProductList composes a ProductCard, how does the product's data reach the card? The answer is the topic of this lesson, and it's one of the most important principles of React: data flows downward, from parent to child, in a single direction.
Let's break down the idea. An app's data —the products, the cart's contents— enters from above, in the root component (App), and flows down via props through the tree: App passes the product list to ProductList; ProductList passes each product to a ProductCard; ProductCard uses that data to produce its card. The data always travels in the same direction —from the root toward the leaves—, like water down a river: it never flows up. This is called unidirectional data flow, and it's what makes a React app predictable: to know where a piece of data you see on the screen came from, you follow the river upstream to its source.
With this comes a hard rule, the other half of the lesson: props are read-only. A component can read the props it receives, but never mutates them. ProductCard reads props.name to show it, but never does props.name = "something else". Props are data that belongs to the parent, lent to the child to use, not to change. You're going to see this rule, executed, with the error that fires when a component tries to mutate frozen props.
Connection with the module. Unidirectional flow closes this module's component model. UI = f(props) (lesson 3) said that props are the input; this lesson says where they come from (from the parent) and in what direction they travel (downward). Composition (lesson 5) is the act of passing props while composing. And the immutability of props is what makes the flow make sense: if a child could change the props the parent gave it, the river would run in both directions and no one would know who owns what. The opposite direction —sending information back up, from child to parent, with callbacks— exists, but it's module 4 (events); here the data only flows down.
An analogy: the chain of command and the memo
Imagine a company with a clear hierarchy: general management, department managers, teams. Official information flows down the chain of command: management issues a memo with the quarter's goals; each department manager receives it and passes to their team the part that concerns them; each team acts according to what it received. The memo travels downward, level by level. No one on a team edits management's original memo and re-injects it upward; the memo is a single source, and it flows down.
Props are that memo. App (management) has the data and issues it; ProductList (the department manager) receives the list and passes each ProductCard (the team) its product; ProductCard acts —produces its card— with what it received. The data flows down the tree like the memo down the hierarchy.
And here's the "read-only" part. The memo that reaches you is to read, not to cross out and rewrite. If a team took management's memo, changed its figures with a marker, and acted on its adulterated version, the source of truth would break: management thinks the goals are one thing, the team acts on another, and no one understands why. That's why, if you need to work with an adjusted figure, you make your own copy and jot it down there —leaving the original memo intact—. In React it's identical: a component that needs a transformed version of a prop computes a new value (const displayName = props.name.toUpperCase()) without touching the original prop. The prop is someone else's memo; you don't cross it out.
The analogy's turn: the hierarchy works because information flows down a single path and each memo has a clear owner. If memos went up, down, and got edited at any level, it would be chaos impossible to trace. Unidirectional flow and read-only props are, together, what keeps the chain of command orderly. Keep it.
Worked example: the data flows down, and props aren't mutated
We're going to see two things executed. First, how the data enters App and flows down via props to ProductCard, printing what each level receives. Second, what happens when a component tries to mutate a prop.
The components in JSX (the real API). Notice how each level passes down what concerns it: App passes catalog to ProductList as products; ProductList passes each product to 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>
);
}
Read the flow in the JSX: App receives props.catalog and flows it down to ProductList by writing products={props.catalog}. ProductList receives props.products and flows each product down to ProductCard by writing name={product.name} and priceCents={product.priceCents}. The attributes you write on a component (products={...}, name={...}) are, exactly, the props that component will receive. That's how data is passed downward.
Now the executable version in Node. We add a console.log in each component to see what props each level receives, and at the end we try to mutate a frozen prop:
'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);
}
// The data enters at the top, in App, and FLOWS DOWN via props to the leaves.
function ProductCard(props) {
console.log(` ProductCard receives 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 receives props: ${props.products.length} products`);
return h('section', { className: 'product-list' },
props.products.map((product) => ProductCard(product))
);
}
function App(props) {
console.log(`App receives props: catalog with ${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('=== the data flows DOWNWARD: App -> ProductList -> ProductCard ===');
const html = renderToString(App({ catalog }));
console.log('\n=== resulting HTML ===');
console.log(html);
console.log('\n=== props are READ-ONLY ===');
function BadCard(props) {
props.name = props.name.toUpperCase(); // MUTATE the props: forbidden
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}`);
}
What to expect. When you run the file, the output is exactly this:
=== the data flows DOWNWARD: App -> ProductList -> ProductCard ===
App receives props: catalog with 2 products
ProductList receives props: 2 products
ProductCard receives props: Wireless Mouse
ProductCard receives props: USB-C Hub
=== resulting HTML ===
<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>
=== props are READ-ONLY ===
X TypeError: Cannot assign to read only property 'name' of object '#<Object>'
Two lessons in one output. Let's take it in parts.
The first half —the indented console.logs— is the data flow made visible. Notice the order and the indentation, which aren't accidental. First App ran (no indentation): it received the catalog with 2 products. Then, inside App, ProductList ran (one indent): it received those 2 products as props.products. And inside ProductList, ProductCard ran twice (two indents): each one received one product ("Wireless Mouse", then "USB-C Hub"). The data entered through App and flowed down, level by level, to the leaves. Each component received exactly the portion its parent decided to give it: App had the whole catalog, passed the list to ProductList, which passed one product to each ProductCard. The river flows down, and at each fork it's distributed.
The resulting HTML is the consequence of that flow: since each ProductCard received its product, each card shows the correct data. The data you see on the screen is, literally, what flowed down via props. If you want to know where the "$34.99" of the second card came from, you follow the river upstream: it was produced by ProductCard from priceCents: 3499, which was flowed down by ProductList, which took it from the catalog that App received. Traceable end to end, in a single direction.
The last line is the read-only rule, executed. BadCard tried to do props.name = props.name.toUpperCase() —mutate the prop it received—. Since those props were frozen with Object.freeze (and we run in strict mode with 'use strict'), JavaScript threw a TypeError: Cannot assign to read only property 'name'. In real React the props aren't always frozen, but the rule is the same: mutating props is an error, and doing it silently corrupts data that belongs to the parent and that maybe other children share. Here the Object.freeze makes visible —with an exception— what in React is a rule you must respect out of discipline.
Why the flow goes only downward
It might seem an arbitrary limitation that the data only flows down. It's the opposite: it's a guarantee. When the flow is unidirectional, each piece of data has a single source (the component above that holds it) and a single path (downward via props). That lets you always answer the most important question when debugging a UI: "why does the screen show this?". The answer is found by following the props upward to the source. You don't have to wonder "who else could have changed this data from below?", because no one can: below, it's only read.
If the data could flow in any direction —if a ProductCard could modify App's catalog—, you'd lose that guarantee. A piece of data could change from five different places, and tracing "who changed it and when" would be a nightmare (it is, in fact, exactly the nightmare of module 1's imperative code). Unidirectional flow trades that chaos for a simple rule: data flows down; for something to go up, there's an explicit mechanism (the callbacks of module 4), not a silent mutation.
Drawn, this example's flow:
flowchart TD
Data["catalog (the data)"] --> App["App"]
App -->|"products={catalog}"| ProductList["ProductList"]
ProductList -->|"name, priceCents<br/>(one product)"| ProductCard["ProductCard"]
A single direction, top to bottom, with the props labeling each arrow. That's the river.
Common mistakes
Mutating the props you receive. What happens: inside a component you do props.name = ..., props.items.push(...), props.priceCents += tax. Why it happens: you want to adjust a piece of data and you do it on the prop itself, without thinking that it's borrowed. How to spot it: the component modifies the object (or the array) it received via props; in strict mode with frozen props, a TypeError like the example's fires; without freezing, it's worse: a parent's data is silently corrupted. How to fix it: treat props as read-only. If you need a modified version, create a new value without touching the prop: const upper = props.name.toUpperCase(), or const withTax = props.priceCents + tax, or const sorted = [...props.items].sort() (a copy of the array). The original prop stays intact; you work on your copy.
Trying to send data upward with an assignment. What happens: a child component wants to "notify" the parent of something —that a product was added to the cart— and tries it by changing a prop or a variable from above. Why it happens: the habit of "modifying the shared state" from the imperative world. How to spot it: the child assigns to something that "lives" in the parent, or you expect changing a prop in the child to be reflected above. How to fix it: in this module, nothing goes up —everything is static and the data only flows down—. When you need to communicate from child to parent (a click, a change in an input), the correct mechanism is a callback that the parent passes to the child via props, and the child calls —that's module 4 (events)—. It's not achieved by mutating data upward; it's achieved with a function that flows down to be invoked. For now, keep in mind that the flow of data is one-directional.
Looking for data "upward" instead of receiving it via props. What happens: a child component tries to read data that didn't reach it via props, reaching it from a global variable or from "the parent". Why it happens: it seems more comfortable than passing the prop explicitly through each level. How to spot it: the component depends on something that isn't in its props; it can't render alone (it breaks the autonomy of lesson 4). How to fix it: everything a component needs must reach it via props, flowing down from where the data lives. Yes, sometimes that means passing a prop through several levels (they call it prop drilling), and there are tools to relieve it when it hurts —but that's advanced composition (lesson 7) and global state (guide frontend-state-and-data)—. The base rule, the one you learn first, is: data flows down via props, explicit, level by level.
Exercises
Exercise 1 — Trace the flow. For the worked example, write in your own words the path that the piece of data priceCents: 3499 travels from when it enters until it appears as $34.99 on the screen. Name each component it passes through and what it does with the data.
See solution
The path, top to bottom:
- The data enters as part of
catalog, in the second product ({ id: 'p2', name: 'USB-C Hub', priceCents: 3499, ... }), when we callApp({ catalog }). Appreceivescatalogin its props and flows it down whole toProductList, passing it asproducts(ProductList({ products: props.catalog })). It doesn't touch the data; it just passes it.ProductListreceives the list inprops.productsand, with themap, flows each product down to aProductCard. The second product (withpriceCents: 3499) goes to the second call,ProductCard(product).ProductCardreceives that product in its props and transforms the data:formatPrice(props.priceCents)turns3499into"$34.99", and puts it in the<p class="product-price">.renderToStringwrites that<p>$34.99</p>in the final HTML.
All in a single direction, from the root to the leaf. The data flowed down without changing until ProductCard, and only there was it transformed to be shown. To know where the "$34.99" came from, it's enough to follow the river upstream: ProductCard ← ProductList ← App ← catalog.
Exercise 2 — Fix the mutation. This component mutates a prop to show the name in uppercase. Explain why it's wrong and rewrite it respecting that props are read-only:
function ProductCard(props) {
props.name = props.name.toUpperCase();
return h('h3', {}, props.name);
}
See solution
Why it's wrong: props.name = props.name.toUpperCase() mutates the prop the component received. That prop belongs to the parent (it flowed down from App); changing it from the child corrupts the original data, which maybe other components share. If the props are frozen, this throws a TypeError; if not, it's worse, because the data is corrupted silently and hard-to-trace bugs appear (App's catalog now has the name in uppercase without anyone asking for it).
Rewritten, with a new value:
function ProductCard(props) {
const displayName = props.name.toUpperCase();
return h('h3', {}, displayName);
}
Now we compute displayName as a new variable from props.name, without touching the prop. props.name is still "Wireless Mouse" (intact, as the parent left it); displayName is "WIRELESS MOUSE" (our transformed copy, local to this render). We read the prop, we don't mutate it. That's the rule: to transform, create a new value; never write over the prop.
Exercise 3 — Why unidirectional? A colleague asks: "why all the fuss about the data only flowing down? It'd be more practical if a ProductCard could update the cart directly when I click it". Explain to them what's gained with unidirectional flow and why "updating directly upward" would bring problems.
See solution
What's gained with unidirectional flow is predictability and traceability. Since each piece of data has a single source (above) and a single path (downward via props), you can always answer "why does the screen show this?" by following the props to their origin. You don't have to wonder "who else, from any corner below, could have changed this data?", because no one can: below, it's only read.
If a ProductCard could "update the cart directly upward" by mutating data, you'd lose that guarantee. The cart could change from any of the hundred cards, each modifying it on its own, and tracing "who changed it, when, and why" would become chaos —exactly the problem of imperative code that React came to solve—. Besides, the data would stop having a clear owner: is it App's, or the card's that changed it last?
Now, the colleague's need is legitimate: we do want a click on the card to affect the cart. The solution isn't to break the flow, but an explicit mechanism: the parent (App, owner of the cart) passes the child a callback via props —a function onAddToCart—, and the child calls it when clicked. The data still lives above and only the owner changes it; the child mutates nothing, it just notifies by invoking the function it was lent. This way you communicate upward without losing the single source. That mechanism is module 4 (events); here it's enough to understand why the flow of data, by default, goes only downward.
Summary and next step
In this lesson you installed the principle that closes the component model: data flows downward, from parent to child, in a single direction (unidirectional flow). You executed it and saw, with the indentation of the console.logs, how the data enters App and flows down level by level —App → ProductList → ProductCard—, distributing at each fork: App has the whole catalog, flows the list down to ProductList, which flows one product down to each ProductCard. And you verified, with a real TypeError, the hard rule: props are read-only —to transform a piece of data, you create a new value; you never mutate the prop, which belongs to the parent—. You understood why the flow goes only downward (one source, one path, everything traceable) and that communicating upward is an explicit mechanism (callbacks, module 4), not a mutation.
Before moving on you should be able to: trace a piece of data's path from App to the screen; explain unidirectional flow and what it guarantees; detect and fix a props mutation (by creating a new value); and know that communicating upward is another mechanism (events), not part of this module.
Lesson 7 closes the module's circle by answering the underlying question: why go to all the trouble of decomposing, composing, and passing props? You're going to see the three reasons —reuse, isolation, and being able to reason— with executed evidence: the same ProductCard defined once and used in several places, and why a change in CartItem can't break ProductCard. It's the justification of everything you built, before assembling the complete storefront in lesson 8.
Resources
- React, "Passing Props to a Component" — react.dev/learn/passing-props-to-a-component. The official page on how props are passed from parent to child, and why they're read-only. The central reference of this lesson. In English.
- React, "Keeping Components Pure" — react.dev/learn/keeping-components-pure. Its section on not mutating the input explains why props aren't touched. In English.
- React, "Updating Objects in State" — react.dev/learn/updating-objects-in-state. Although it's about state (module 3), its lesson on not mutating and creating new values applies equally to props. In English.
- MDN, "Object.freeze()" — developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/freeze. How the freeze we use to make the read-only rule visible works, and why in strict mode mutating throws an error. In English.