Components, props and how React renders
Every React screen is a tree of small functions that turn data into a description of the page. In this lesson you cut Mangrove Market's back-office stock screen into components, and you learn exactly when React calls them again and what it changes on screen afterwards.
By the end you can
- Turn a screen sketch into a component tree with clear, typed props
- Explain what JSX becomes, and why a component must be pure while it renders
- Walk through trigger, render, reconcile and commit, and say which components run after a state change
- Choose list keys that keep each row's state with the right item
- Build the static stock screen in TypeScript and watch it re-render in the console
The everyday picture
Think of the shelf labels in a supermarket. Every night head office sends each store a fresh plan of what should sit on every shelf, with prices. The night staff do not tear down the aisles and build them again. They walk along the shelves with the new plan, compare it with what is already there, and swap only the labels that differ. A price went from $2.40 to $2.20: one label changes. Nothing changed on a shelf: they leave it alone.
They also match products by barcode, never by "third slot from the left". If the flour is removed from a shelf, the oil and the sugar keep their labels, and the sticky note a colleague left on the oil ("count again, 12 found") stays on the oil. If they matched by slot number, the note would slide onto the sugar.
React works the same way. Your components write a fresh plan of the screen. React compares it with the last plan and changes only what differs on the real page. The barcode is the key you give each row. Keep this picture in mind: plan, compare, swap.
From a sketch to a component tree
Store managers like Ama open the back-office stock screen every morning before the store opens. Leila, the designer, has sketched it: a search box at the top, a table of items with their stock and reorder levels, a panel on the side to adjust a count, and a small card with today's app orders. The data behind each row is a StockItem: articleId, name, category, onHand, reorderLevel, priceCents and updatedAt. An item is low when onHand <= reorderLevel, and money is in integer cents (Working with data: arrays, Maps, JSON, dates and money explains why).
A component is a function that takes data and returns a description of a piece of the screen. To cut a sketch into components, draw a box around every part and use three tests:
- One job each. The search box collects a query. The table lays out rows. If you need "and" to describe a box ("shows the table and saves adjustments"), split it.
- Anything that repeats is a component. One
StockRowper article, whether the store shows 40 low items or about 2,000 items. - Name it after what it shows, so a teammate can find it:
StockScreen,SearchBar,StockTable,StockRow,AdjustPanel,OrderSummary.
Components talk through props. A parent writes <StockTable items={visible} /> and the child receives one object, { items: visible }. Props are read-only for the child. When a child needs something to change, such as the search text, the parent passes a function as a prop (onQueryChange) and the child calls it. So data flows down the tree as props, and events flow up as calls to those functions. That rule keeps every value with one clear owner.
One prop is special: children. Whatever you write between a component's tags arrives as props.children. A small Panel with a title and a border can wrap the adjust form and the order card without knowing what is inside. This is composition: you build bigger parts by nesting smaller ones, instead of adding a new prop for every variation. With TypeScript, each component declares its props as a type, so a missing or misspelled prop fails in your editor before the page ever loads.
Quick check
When Ama clicks "Adjust" on a row, the screen should open the adjust panel for that item. How does StockRow tell its parents?
Events go up through functions the parent passed down. Changing props.item mutates an object the parent owns, and React has no idea anything happened, so nothing re-renders. Calling StockScreen yourself only runs a function and returns a description; React would never see it.
StockScreen owns the data and passes each child only the props it needs (right). StockTable renders one StockRow per visible item, 42 of them in this store's low-stock view. Typing goes back up through the onQueryChange function that SearchBar received.JSX is a function call, and rendering must be pure
JSX looks like HTML, but the browser never sees it. Your build tool (Vite, Next.js, or TypeScript with "jsx": "react-jsx") turns every tag into a function call. With the modern JSX transform that React 19 requires, the compiler adds an import from react/jsx-runtime for you:
<StockRow key={item.articleId} item={item} />import { jsx as _jsx } from 'react/jsx-runtime'; // added by the build, never by you
_jsx(StockRow, { item: item }, item.articleId); // type, props, keyThe call does not run StockRow and does not create a table row in the page. It returns an element, a plain object that describes what you want. Here is what React 19 returns, printed in Node with the production build:
{
'$$typeof': Symbol(react.transitional.element),
type: [Function: StockRow],
key: 'A2040',
ref: null,
props: { item: { articleId: 'A2040', name: 'Cooking oil 2 L', onHand: 6 } }
}The type is either a tag name such as 'td' or your component function. The key sits next to props, which is why a component cannot read its own key. A component returns a tree of these objects, and React decides later what to do with them. Creating elements is cheap: thousands of small objects per render is normal.
Because React may call your component at any time, as often as it likes, the function must be pure while it renders. That means two things. Same props and state in, same elements out. And it changes nothing that existed before it was called: no edits to props, no writes to outside variables, no requests to the server, no timers. Those side effects belong in event handlers, such as the click on "Save", or, as a last resort, in an effect (State, effects and where data should live).
A classic impure bug hides in one innocent line. StockTable wants rows in name order and writes items.sort(byName). But sort() changes an array in place, and items belongs to StockScreen. Now the parent's own list is reordered as a side effect of drawing a child, and anything else that reads that array sees an order nobody asked for. Copy first: [...items].sort(byName), or items.toSorted(byName) in browsers from 2023 onwards. Objects, arrays and references covers which array methods mutate.
React helps you catch these bugs. Inside <StrictMode>, in development only, React calls each component function twice, along with the functions you pass to useState, useMemo and useReducer, and keeps one result. A pure component gives the same answer both times, so you see no difference. An impure one shows itself: a row pushed into an array appears twice, a counter jumps by two. StrictMode also runs every effect's setup and cleanup one extra time when a component first appears. None of this happens in the production build. In React 19 the second call reuses the results of useMemo and useCallback from the first. A new Vite React project wraps the app in <StrictMode>, and the Next.js App Router turns it on by default (since Next.js 13.5.1).
Quick check
What does the expression <StockRow item={item} /> give you when it runs?
JSX compiles to _jsx(StockRow, { item }), which only builds a description. React decides when to call StockRow during rendering, and only the commit step touches the page. Thinking that JSX calls your component right away is the most common reason people misjudge when code inside it runs.
Trigger, render, reconcile, commit
Every change on a React screen goes through the same four steps. Learn them once and most "why did this run?" questions answer themselves.
- Trigger. Something asks for a render. The first time, that is
createRoot(...).render(<App />). After that, it is a state update: Ama types "o", the input's change handler callssetQuery('o'), and React queues a render ofStockScreen, the component that owns that state. - Render. React calls
StockScreenagain. It reads the new query, filters the items and returns new elements. Then React calls every component in those elements, and their children, all the way down. Rendering here means calling your functions. Nothing on the page has changed yet. - Reconcile. While it renders, React compares the new tree with the previous one, child by child. Same type in the same place (and the same key in a list) means "this is the same component": React keeps its state and its page nodes and only notes changed attributes or text. A different type, or a key that is gone, means "destroy the old one and its state". This comparison is called reconciliation.
- Commit. React applies the changes it found to the DOM in one go: remove two rows, update the input's value. Then the browser paints. Commit is the only step that touches the page (JavaScript in the browser covers the DOM itself).
What makes a component render again
There are three triggers, and only three, to keep in your head:
- Its own state changed (a
setfunction fromuseStateoruseReducer, which State, effects and where data should live covers properly). - Its parent rendered. By default, when a component renders, React renders all of its children and their children too, whether their props changed or not.
- A context it reads changed. A context passes a value to a whole subtree without props, such as the current store or the signed-in user. Every component that reads it renders when the value changes.
Notice what is missing from the list: "its props changed". Props are not a trigger of their own. A child only ever gets new props because its parent rendered, and that render already includes the child. So when Ama types one letter, React calls StockScreen, SearchBar, StockTable, every visible StockRow, AdjustPanel and OrderSummary, even though the order counts did not change. Components above StockScreen do not run at all.
That sounds expensive, but a re-render is not a page change. If a StockRow returns exactly the same elements as last time, reconciliation finds no difference and the commit touches nothing in that row. Calling a small function is fast. It only becomes a problem with heavy components or long lists on slow devices: 2,000 rows on a cheap Android phone at the edge of town, with a render on every keystroke. Accessible, fast pages measures and fixes that case.
When one component is expensive and usually gets the same props, you can wrap it in memo: const OrderSummary = memo(function OrderSummary(props) {…}). Before calling it again, React compares each new prop with the old one using Object.is, and if all are equal it reuses the last result and skips the whole subtree. Two warnings. An object, array or function written inline in the parent (counts={{ now: 18, later: 41 }}) is a new value on every render, so it never compares equal. And a memoized component still renders when its own state or a context it reads changes. React Compiler, stable since version 1.0 in October 2025, can add this kind of memoization for you at build time. Treat memo as a performance tool you reach for after measuring.
Quick check
The search query is state in StockScreen. OrderSummary (not wrapped in memo) receives the same counts object on every render. Ama types "o". Does the OrderSummary function run?
A parent's render includes all of its children. Equal props only let React skip a child when the child is wrapped in memo. Running the function is cheap here, and since it returns the same elements, the commit changes nothing in the card.
Keys: how React knows which row is which
React keeps a component's state for as long as the same component type is rendered at the same position in the tree. Swap a StockTable for a LoadingTable in that place and the old table and everything under it are destroyed, state included. Keep the same type and the state survives, even if every prop changed.
Lists need more than position. When the low-only filter hides one row, every row after it moves up one place. A key tells React which item each element stands for, so it matches rows by key instead of by place. Same type and same key: React keeps that row's state and DOM nodes, even if it moved. A key that disappeared: React removes that row. A new key: React creates a row.
If you give no key, React falls back to the position (the array index) and logs a warning in the console. Writing key={index} yourself only hides the warning; the behaviour is the same. Index keys work for a list that never reorders, filters or inserts. A stock table does all three, so the rule for this screen is simple: key={item.articleId}, the stable id that comes from the data. Item names are a poor key, because two products can share a name and names get corrected. Never make up a key during render with Math.random() or crypto.randomUUID(): every render then has all-new keys, so React destroys and rebuilds every row, which is slow and wipes what the user was typing.
Keys only need to be unique among siblings, and they can also reset state on purpose: <AdjustPanel key={selected.articleId} /> gives each item a fresh panel. State, effects and where data should live uses that trick.
Quick check
The stock API returns rows with articleId, name and updatedAt. Which key should StockRow get?
The article id is stable and unique among the rows. Index keys break as soon as the list is filtered or sorted on the screen, whatever order the API used. Names can repeat and can be edited, which would give two rows one key or move state when a typo is fixed.
articleId keys, React sees that A1180 is gone and the other two rows are unchanged, so the draft stays with Oil.Build it
Build the static stock screen in React 19 and TypeScript with typed props, then add one piece of state and watch exactly which components render on each keystroke.
-
Create a React + TypeScript project
Vite needs Node.js 20.19+ or 22.12+. Follow any prompts the first command shows, then start the dev server and open
http://localhost:5173. Delete the demo content insrc/App.tsx.Terminalnpm create vite@latest stock-screen -- --template react-ts cd stock-screen npm install npm run devOpen
src/main.tsx: the template already wraps<App />in<StrictMode>insidecreateRoot(...).render(...). That is the first trigger of every React app. The same components work unchanged in a Next.js app, which Loading server data uses. -
Type the data and add sample rows
TypeScript// src/types.ts: what the stock API returns for one article (money in integer cents) export type StockItem = { articleId: string; name: string; category: string; onHand: number; reorderLevel: number; priceCents: number; updatedAt: string; // ISO date-time, e.g. '2026-10-20T06:10:00Z' }; export type OrderCounts = { now: number; later: number }; export const isLow = (item: StockItem) => item.onHand <= item.reorderLevel; export const formatCents = (cents: number) => `$${(cents / 100).toFixed(2)}`;TypeScript// src/data.ts: sample rows until the screen loads real ones from the API import type { StockItem } from './types'; export const sampleItems: StockItem[] = [ { articleId: 'A1180', name: 'Flour 2 kg', category: 'Staples', onHand: 30, reorderLevel: 10, priceCents: 240, updatedAt: '2026-10-20T06:10:00Z' }, { articleId: 'A2040', name: 'Cooking oil 2 L', category: 'Staples', onHand: 6, reorderLevel: 12, priceCents: 899, updatedAt: '2026-10-20T06:10:00Z' }, { articleId: 'A4512', name: 'Milk 1 L', category: 'Dairy', onHand: 9, reorderLevel: 9, priceCents: 120, updatedAt: '2026-10-20T06:12:00Z' }, { articleId: 'A3300', name: 'Sugar 1 kg', category: 'Staples', onHand: 8, reorderLevel: 15, priceCents: 180, updatedAt: '2026-10-20T06:12:00Z' }, ]; -
Write the table and its rows
Each component declares its props type. The
console.loglines are temporary, so you can see renders in step 6.TypeScript// src/StockRow.tsx: one row, nothing but props in, elements out import { formatCents, isLow, type StockItem } from './types'; type StockRowProps = { item: StockItem }; export function StockRow({ item }: StockRowProps) { console.log('render StockRow', item.articleId); const low = isLow(item); return ( <tr className={low ? 'low' : undefined}> <td>{item.name}</td> <td>{item.onHand}</td> <td>{item.reorderLevel}</td> <td>{formatCents(item.priceCents)}</td> <td>{low ? 'Low' : 'OK'}</td> </tr> ); }TypeScript// src/StockTable.tsx: the table and its rows, one key per article import { StockRow } from './StockRow'; import type { StockItem } from './types'; type StockTableProps = { items: StockItem[] }; export function StockTable({ items }: StockTableProps) { console.log('render StockTable'); // Copy first: sort() changes an array in place, and items belongs to the parent const sorted = [...items].sort((a, b) => a.name.localeCompare(b.name)); return ( <table> <thead> <tr><th>Item</th><th>On hand</th><th>Reorder at</th><th>Price</th><th>Status</th></tr> </thead> <tbody> {sorted.map((item) => ( <StockRow key={item.articleId} item={item} /> ))} </tbody> </table> ); } -
Add the other parts, using children for the card
Paneltypes itschildrenasReactNode, which accepts anything React can show: elements, text, numbers, or nothing.TypeScript// src/Panel.tsx: a card that wraps whatever you put between its tags import type { ReactNode } from 'react'; type PanelProps = { title: string; children: ReactNode }; export function Panel({ title, children }: PanelProps) { return ( <section className="panel"> <h2>{title}</h2> {children} </section> ); }TypeScript// src/SearchBar.tsx: shows the query it is given and reports typing upwards type SearchBarProps = { query: string; onQueryChange: (query: string) => void; }; export function SearchBar({ query, onQueryChange }: SearchBarProps) { console.log('render SearchBar'); return ( <label> Search items{' '} <input type="search" value={query} onChange={(e) => onQueryChange(e.target.value)} /> </label> ); }TypeScript// src/AdjustPanel.tsx: a placeholder until the adjustment form exists import { Panel } from './Panel'; import type { StockItem } from './types'; type AdjustPanelProps = { item: StockItem | null }; export function AdjustPanel({ item }: AdjustPanelProps) { console.log('render AdjustPanel'); return ( <Panel title="Adjust stock"> {item ? <p>{item.name}: {item.onHand} on hand</p> : <p>Pick a row to adjust its count.</p>} </Panel> ); }TypeScript// src/OrderSummary.tsx: today's app orders for this store import { Panel } from './Panel'; import type { OrderCounts } from './types'; type OrderSummaryProps = { counts: OrderCounts }; export function OrderSummary({ counts }: OrderSummaryProps) { console.log('render OrderSummary'); return ( <Panel title="Today's app orders"> <p>{counts.now} Now orders, {counts.later} Later orders</p> </Panel> ); } -
Assemble the screen
TypeScript// src/StockScreen.tsx: composes the parts and passes each one its props import { AdjustPanel } from './AdjustPanel'; import { OrderSummary } from './OrderSummary'; import { SearchBar } from './SearchBar'; import { StockTable } from './StockTable'; import type { OrderCounts, StockItem } from './types'; const counts: OrderCounts = { now: 18, later: 41 }; // module level: the same object every render type StockScreenProps = { items: StockItem[] }; export function StockScreen({ items }: StockScreenProps) { console.log('render StockScreen'); const query = ''; // static for now return ( <main className="stock-screen"> <h1>Morning stock check</h1> <SearchBar query={query} onQueryChange={() => {}} /> <StockTable items={items} /> <AdjustPanel item={null} /> <OrderSummary counts={counts} /> </main> ); }TypeScript// src/App.tsx import { sampleItems } from './data'; import { StockScreen } from './StockScreen'; export default function App() { return <StockScreen items={sampleItems} />; }The page shows four rows in name order, with oil, milk and sugar marked Low (milk is exactly at its reorder level). Now type in the search box. Nothing appears, and the console shows no new logs. Nobody changed any state, so nothing triggered a render, and React keeps the input showing its
value, which is still an empty string. -
Add one piece of state and watch the renders
useStategets a full lesson next (State, effects and where data should live). Here it is only the trigger.Diff+import { useState } from 'react'; import { AdjustPanel } from './AdjustPanel'; @@ export function StockScreen({ items }: StockScreenProps) { + const [query, setQuery] = useState(''); console.log('render StockScreen'); - const query = ''; // static for now + const q = query.trim().toLowerCase(); + const visible = items.filter((item) => item.name.toLowerCase().includes(q)); return ( <main className="stock-screen"> <h1>Morning stock check</h1> - <SearchBar query={query} onQueryChange={() => {}} /> - <StockTable items={items} /> + <SearchBar query={query} onQueryChange={setQuery} /> + <StockTable items={visible} />Open the browser console, clear it, and type o. Only "Cooking oil 2 L" and "Flour 2 kg" contain an "o". In development you will usually see each line twice, because StrictMode calls each component twice; one copy per render is shown here:
Outputrender StockScreen render SearchBar render StockTable render StockRow A2040 render StockRow A1180 render AdjustPanel render OrderSummaryRead it against the rules.
StockScreenowns the state, so it renders. Every child renders because its parent did, includingAdjustPanelandOrderSummary, whose props did not change. The milk and sugar rows do not log: their keys are gone, so React removes their DOM rows in the commit. The oil and flour rows run, but their output is identical, so the commit leaves their cells alone. -
Let OrderSummary skip renders with memo
Diff+import { memo } from 'react'; import { Panel } from './Panel'; @@ -export function OrderSummary({ counts }: OrderSummaryProps) { +export const OrderSummary = memo(function OrderSummary({ counts }: OrderSummaryProps) { console.log('render OrderSummary'); @@ ); -} +});Type another letter:
render OrderSummaryno longer appears, becausecountsis the same module-level object every time. Now movecountsinsideStockScreenas an inline object and type again: the log comes back, because each render creates a new object. Put it back afterwards. Finally, runnpm run build, which type-checks the code withtscbefore bundling: remove theitemprop from one<StockRow />and the build fails with a missing-property error. Remove theconsole.loglines when you are done.
Bring it to your team
Ask these in design reviews and pull requests for any React screen.
- Can you draw the component tree for this screen, and say which component owns each piece of state?
- Does any component change its props, sort an array it was given, or write to a variable outside itself while rendering?
- What key does each list use? Is it a stable id from the data, and what happens to the rows when we filter or sort?
- Is the app wrapped in StrictMode in development, and do we read its double-render warnings instead of switching it off?
- Where we added
memo, did we measure a slow render first, and are the props it compares stable?
Red flags
key={index}orkey={Math.random()}on a list that can be filtered, sorted or edited.- A component defined inside another component's body.
- Fetching data, starting timers or writing to storage in the body of a component.
- StrictMode removed "because things ran twice".
memoon every component, with inline objects passed to them anyway.
Practice
Match rows by key, the way React does
Write reconcile(prevKeys, nextKeys). It gets the row keys of the last render and of the new render, and returns { kept, added, removed }: kept holds the keys in both lists (in the new order), added the keys only in the new list (in the new order), and removed the keys only in the old list (in the old order). The starter matches rows by position, which is how index keys behave. Make it match by key.
function reconcile(prevKeys, nextKeys) {
// Matches by position, like key={index}. Make it match by key instead.
const kept = [];
const added = [];
const removed = [];
const length = Math.max(prevKeys.length, nextKeys.length);
for (let i = 0; i < length; i++) {
if (prevKeys[i] === nextKeys[i]) {
kept.push(nextKeys[i]);
} else {
if (nextKeys[i] !== undefined) added.push(nextKeys[i]);
if (prevKeys[i] !== undefined) removed.push(prevKeys[i]);
}
}
return { kept, added, removed };
}
test('no change: every row is kept', () => {
expect(reconcile(['A1180', 'A2040'], ['A1180', 'A2040'])).toEqual({ kept: ['A1180', 'A2040'], added: [], removed: [] });
});
test('the low-only filter hides A1180: only A1180 is removed, the rows below it are kept, not re-created', () => {
expect(reconcile(['A1180', 'A2040', 'A3300'], ['A2040', 'A3300'])).toEqual({ kept: ['A2040', 'A3300'], added: [], removed: ['A1180'] });
});
test('sorting by name only moves rows: nothing is added or removed', () => {
expect(reconcile(['A3300', 'A1180', 'A2040'], ['A1180', 'A2040', 'A3300'])).toEqual({ kept: ['A1180', 'A2040', 'A3300'], added: [], removed: [] });
});
test('a new item at the top is added and the others are kept', () => {
expect(reconcile(['A1180', 'A2040'], ['A4512', 'A1180', 'A2040'])).toEqual({ kept: ['A1180', 'A2040'], added: ['A4512'], removed: [] });
});
Show a solution
function reconcile(prevKeys, nextKeys) {
const before = new Set(prevKeys); // Set lookups are fast even for 2,000 rows
const after = new Set(nextKeys);
return {
kept: nextKeys.filter((key) => before.has(key)),
added: nextKeys.filter((key) => !before.has(key)),
removed: prevKeys.filter((key) => !after.has(key)),
};
}Matching by position turned a one-row filter into "three rows changed", which is exactly why index keys move state to the wrong item. React's real algorithm also tracks moves and component types, but the idea is the same: identity comes from the key.
Predict which components render
Write whoRenders(tree, changed). A tree node is { name, memo, sameProps, children }: memo is true when the component is wrapped in memo, and sameProps is true when its props are equal to last time. The component named changed had a state update. Return the names that render, parent before children (depth first), using React's default rules: the changed component renders; a child renders whenever its parent renders, except a memo child with the same props, which is skipped together with everything under it. The starter believes "a child renders only when its props change".
function findNode(node, name) {
if (node.name === name) return node;
for (const child of node.children || []) {
const found = findNode(child, name);
if (found) return found;
}
return null;
}
function whoRenders(tree, changed) {
const out = [];
function visit(node) {
out.push(node.name);
for (const child of node.children || []) {
if (!child.sameProps) visit(child); // "children render only when their props change"
}
}
visit(findNode(tree, changed));
return out;
}
const screen = (summaryMemo) => ({ name: 'StockScreen', children: [
{ name: 'SearchBar', sameProps: false, children: [] },
{ name: 'StockTable', sameProps: false, children: [
{ name: 'StockRow A1180', sameProps: true, children: [] },
{ name: 'StockRow A2040', sameProps: true, children: [] },
] },
{ name: 'AdjustPanel', sameProps: true, children: [{ name: 'Panel', sameProps: true, children: [] }] },
{ name: 'OrderSummary', memo: summaryMemo, sameProps: true, children: [{ name: 'Panel', sameProps: true, children: [] }] },
] });
test('a state change in StockScreen renders every child, even the ones with the same props', () => {
expect(whoRenders(screen(false), 'StockScreen')).toEqual(['StockScreen', 'SearchBar', 'StockTable', 'StockRow A1180', 'StockRow A2040', 'AdjustPanel', 'Panel', 'OrderSummary', 'Panel']);
});
test('a memo child with the same props is skipped, and so is everything inside it', () => {
expect(whoRenders(screen(true), 'StockScreen')).toEqual(['StockScreen', 'SearchBar', 'StockTable', 'StockRow A1180', 'StockRow A2040', 'AdjustPanel', 'Panel']);
});
test('a memo component whose props changed still renders', () => {
const tree = { name: 'StockScreen', children: [{ name: 'OrderSummary', memo: true, sameProps: false, children: [] }] };
expect(whoRenders(tree, 'StockScreen')).toEqual(['StockScreen', 'OrderSummary']);
});
test('only the changed component and its subtree render, never its parent', () => {
expect(whoRenders(screen(false), 'StockTable')).toEqual(['StockTable', 'StockRow A1180', 'StockRow A2040']);
});
Show a solution
function findNode(node, name) {
if (node.name === name) return node;
for (const child of node.children || []) {
const found = findNode(child, name);
if (found) return found;
}
return null;
}
function whoRenders(tree, changed) {
const out = [];
function visit(node) {
out.push(node.name);
for (const child of node.children || []) {
if (child.memo && child.sameProps) continue; // memo bails out, subtree included
visit(child); // otherwise: parent rendered, so child renders
}
}
visit(findNode(tree, changed));
return out;
}Equal props matter only for a memo component. Without memo, a parent's render always includes its children. Real React adds one more case this model leaves out: a skipped component still renders if its own state or a context it reads changes.
Check yourself
Every time StockTable re-renders, the draft counts Ama typed into rows disappear, even though each row has key={item.articleId}. The code defines function StockRow() {…} inside the body of StockTable. What is going on?
React keeps state only for the same type at the same position with the same key. A function created inside another component is a new type on every render, so the key match does not help. Moving StockRow to the top level of the module fixes it. Parents rendering does not reset child state by itself, and StrictMode's extra call does not drop state either.
A developer wraps OrderSummary in memo and renders it as <OrderSummary counts={{ now: orders.now, later: orders.later }} /> inside StockScreen. The numbers have not changed. Does OrderSummary still render on every keystroke in the search box?
memo compares each prop with Object.is, which checks whether both are the very same object. Two separate objects with equal numbers inside still count as different. Pass orders itself if it is stable, or pass now and later as two number props. memo works in development too.
To silence the missing-key warning, someone writes key={crypto.randomUUID()} on each StockRow. What will Ama notice?
Keys must be stable as well as unique. New keys on every render tell React that every old row is gone and every row is new, so it unmounts them all, loses their state and recreates their DOM. Generate an id when the item is created, or use the one from the data, never during render.
Flashcards
Key takeaways
- Cut a screen into components with one job each; data goes down as typed props, events come up through function props, and
childrenlets you compose. - JSX builds plain element objects. Components must be pure while rendering: no mutated props, no side effects.
- Each update is trigger, render, reconcile, commit. A parent's render renders its children; only the commit changes the page, and only where something differs.
- Use stable keys from the data, such as
articleId. Index keys and random keys move or wipe row state. - StrictMode's double call in development is a purity test. Reach for
memoonly after measuring, with stable props.
Sources
- Keeping Components Pure, React documentation
- Render and Commit, React documentation
- Preserving and Resetting State, React documentation
- Rendering Lists, React documentation
- StrictMode and memo, React API reference
- React 19 Upgrade Guide, React blog, 2024
- React Compiler v1.0, React blog, 2025
- Getting Started, Vite documentation