091: Props
Learning objective
Outcomes
You will pass values and nested JSX into function components, use default parameter values, preserve one-way data flow, and recognize prop drilling without reaching for a replacement for props too early.
You should finish this lesson able to design and use a reusable card API while treating props as read-only inputs rather than as state the component is free to change.
Prerequisites
Complete 090 first. You should already be comfortable with function components, module scope, JSX expressions, and the reason a parent owns data that several descendants need to share.
Retrieval practice
Before moving on, retrieve these ideas from the previous lesson:
- What makes a useful component boundary?
- Why should a component be pure during rendering?
- When is duplication better than introducing an abstraction too early?
Content to cover
passing data; children; one-way data flow; prop defaults; prop drilling concept.
Terms and mental model
When a component needs data from its caller, the caller supplies it through JSX attributes. Those attributes become properties on one props object, so props are the component equivalent of function arguments. The parent owns the values and passes a snapshot downward. The child can read that snapshot to decide what to render, but it does not mutate it.
The basic shape looks like this:
function TaskCard({ title, priority = 'normal' }) {
return <article><h2>{title}</h2><p>{priority}</p></article>;
}
<TaskCard title="Read about props" priority="high" />
The useful distinction is ownership. A child may receive an object reference, but receiving the reference does not transfer ownership of the object. If the value needs to change, the component that owns the state should perform that change and pass the next snapshot down on a later render.
- Prop: A read-only value flowing parent → child to configure it. — Source: React: Passing props
- Destructuring: Unpacking prop fields directly in the parameter list. — Source: MDN: Destructuring assignment
- Default parameter value: Fallback used when an argument is undefined. — Source: MDN: Default parameters
children: Special prop holding nested content between a component’s tags. — Source: React: Passing props — children- One-way data flow: Data flows downward through props; children notify upward via callbacks. — Source: React: Thinking in React
- Prop drilling: Thread intermediate components just to pass data deeper. — Source: React: Passing data deeply with context
Use default values in the function parameter rather than the function component defaultProps property:
function Badge({ label, tone = 'neutral' }) { /* ... */ }
There is a small JavaScript detail here that often causes confusion: passing tone={null} does not select the default. The default is used when the argument is omitted or is explicitly undefined; null is an intentional value. Choose defaults that leave the component valid and make its behavior unsurprising.
Beginner complete example: ProductCard
The outline calls for product cards, so start with that domain. We will return to the Task Manager examples afterward, where the same prop rules apply to data and callbacks that are changing over time.
function ProductCard({
name,
price,
currency = 'USD',
inStock = true,
}) {
const formattedPrice = new Intl.NumberFormat(undefined, {
style: 'currency',
currency,
}).format(price);
return (
<article className="product-card">
<h2>{name}</h2>
<p>{formattedPrice}</p>
<p>{inStock ? 'In stock' : 'Out of stock'}</p>
<button type="button" disabled={!inStock}>
Add {name} to cart
</button>
</article>
);
}
export default function App() {
return (
<main>
<h1>Desk supplies</h1>
<div className="product-grid">
<ProductCard name="Notebook" price={8.5} />
<ProductCard name="Timer" price={24} inStock={false} />
</div>
</main>
);
}
.product-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); gap: 1rem; }
.product-card { padding: 1rem; border: 1px solid #aab4bc; border-radius: .5rem; }
button:disabled { cursor: not-allowed; }
The braces in the JSX calls matter. Numbers and booleans are JavaScript values, so they use braces; a quoted JSX attribute is a string. Boolean shorthand, as in <ProductCard inStock />, is equivalent to inStock={true}. Omitting currency selects the USD default, while the second card explicitly supplies false and therefore disables its button.
The button label includes the product name. That is a small API and accessibility decision, but it matters when several cards appear together: users do not have to guess which product an otherwise identical “Add” button controls.
Children and composition
children is another prop, but it has a different role from a fixed data field. It lets the caller provide a subtree while the component owns the surrounding structure:
function Panel({ title, children }) {
return (
<section className="panel">
<h2>{title}</h2>
{children}
</section>
);
}
function App() {
return (
<Panel title="Today">
<p>Two tasks remain.</p>
<button type="button">Review tasks</button>
</Panel>
);
}
The parent decides which content belongs inside the panel; Panel decides how that content is framed. This is usually clearer than adding separate paragraphText, showButton, and buttonText props whenever callers may need different content. On the other hand, if the content always has one well-defined task shape, a focused task prop communicates that domain contract more clearly than an unstructured children tree.
Intermediate: Task Manager data flow
Here is the same parent-to-child flow with a data object and an event callback:
function TaskItem({ task, onToggle }) {
return (
<li>
<label>
<input
type="checkbox"
checked={task.completed}
onChange={() => onToggle(task.id)}
/>
<span>{task.title}</span>
</label>
</li>
);
}
function TaskList({ tasks, onToggleTask }) {
return (
<ul>
{tasks.map((task) => (
<TaskItem key={task.id} task={task} onToggle={onToggleTask} />
))}
</ul>
);
}
A function can be a prop. Here the parent passes a capability downward, and the child invokes that capability after the checkbox event. TaskItem does not change task.completed; it reports which task the user acted on. Later, the parent callback will immutably create the next state. This keeps the direction of the data flow visible even though the event begins in a deeply nested component.
This would violate that ownership model:
function TaskItem({ task }) {
task.completed = true; // Wrong: mutates a prop object.
return <li>{task.title}</li>;
}
JavaScript will not stop this assignment for you. React's model nevertheless depends on treating props as immutable inputs, because mutation makes it unclear which component owns the next value and can produce changes that do not follow the expected state-update path. Ask for a change with a callback and let the state owner produce new data.
Prop drilling
Imagine an App → Workspace → TaskArea → TaskList → TaskItem tree. If onToggleTask is passed through each layer even though only TaskItem calls it, that is prop drilling. The term describes the path; it does not, by itself, identify a bug.
Explicit props are often the easiest design to trace in a modest tree. A reader can follow the value from its owner to its consumer without consulting a provider definition or a separate state system. The extra prop lines become a problem only when they obscure APIs or create a broadly repeated dependency.
Before introducing Context, consider composition or moving the state closer to the components that use it. Context is useful for information needed by many distant consumers, such as a theme or authenticated account, but it also adds an implicit dependency. It is not required merely to avoid passing two intermediate props. State tools cannot substitute for clear ownership.
Optional advanced: component API design
Treat a reusable component's props as a small public contract. Prefer required domain data plus a few meaningful options. Avoid forwarding arbitrary objects unless the component is intentionally wrapping a built-in element and that forwarding is part of its design.
key and ref are special React inputs; they are not ordinary entries in the child’s props object. In particular, key is not available inside child props. If a child needs the identifier for its own logic, pass it separately: <TaskItem key={task.id} taskId={task.id} />.
Object and array props also deserve care because they share references. Read-only means the child must not push, sort in place, or assign nested properties on received data. If a component needs a transformed array, use non-mutating operations such as filter, map, or [...items].sort(...).
Mistakes and debugging
These mistakes are common because JSX looks like markup while its attributes are JavaScript values:
- Mutating a prop or nested object makes ownership unpredictable.
- Using function component
defaultPropsis an outdated pattern; use parameter defaults. - Passing
"false"passes a truthy string; useenabled={false}. - Calling a callback while rendering,
onClick={onDelete(id)}, runs too early. PassonClick={() => onDelete(id)}. - Assuming
keyappears in props fails; pass a normal ID prop too. - Spreading every object with
<Card {...data} />can conceal the component contract. - Copying props into state creates two sources of truth unless editing a deliberate draft.
When a prop is wrong, use React Developer Tools to inspect the actual props at each boundary. Trace the incorrect value upward until you find the component that owns it. If a default does not apply, check whether the caller supplied explicit null. If a click fires immediately, inspect whether JSX received a function or the function's return value. These checks distinguish a bad value from a bad handoff and keep the fix at the correct boundary.
Accessibility and performance
A good prop API makes accessible use straightforward. Require meaningful button labels, pass image alt text when an image conveys content, and preserve heading order when components are composed. Avoid a generic as prop in beginner components because it can make invalid semantics easy to request. When a control is disabled, provide surrounding text if the reason is not already evident from the interface.
Passing a new object or inline function is normally fine. Do not add useMemo or useCallback just to stabilize every prop. Keep APIs small and state local, then measure real interactions before optimizing. Large objects passed through many components are more often a sign of unclear architecture than an immediate performance problem.
Practice
Build reusable ProductCard components. Start with the required data, then make the API flexible only where the exercise asks for that flexibility.
Tiered exercises
Core: Render three products with name, numeric price, and inStock. Default currency to USD; disable unavailable products.
Stretch: Add a Card wrapper using children, and supply product-specific action content from each parent call.
Challenge: Refactor a prop-drilled three-level task tree using composition, but only if it makes the data flow clearer. Explain why Context is not yet necessary.
function Card({ children }) {
return <article className="card">{children}</article>;
}
function ProductCard({ name, price, currency = 'USD', inStock = true }) {
const displayPrice = new Intl.NumberFormat(undefined, {
style: 'currency', currency,
}).format(price);
return (
<Card>
<h2>{name}</h2>
<p>{displayPrice}</p>
<p>{inStock ? 'Ready to ship' : 'Currently unavailable'}</p>
<button type="button" disabled={!inStock}>Add {name} to cart</button>
</Card>
);
}
export default function App() {
return (
<main>
<h1>Products</h1>
<ProductCard name="Notebook" price={8.5} />
<ProductCard name="Timer" price={24} inStock={false} />
<ProductCard name="Lamp" price={32} currency="EUR" />
</main>
);
}
For the challenge, let App pass task content as children to Workspace, rather than making Workspace forward task props that it never interprets. Keep the event callback explicit at the feature boundary. Context would hide a small, traceable dependency, so it is not justified yet. The composition solution is valuable only if it makes that ownership and handoff easier to understand.
Exit questions
Use these questions as a final diagnostic, not as a vocabulary quiz:
- What problem does this concept solve?
- What is one common mistake?
- Can you explain the code without reading it line by line?
Recap
Props configure components and flow downward as read-only snapshots. Use parameter defaults for meaningful optional values, children for flexible composition, and callbacks when a child needs to request a change owned by its parent. Prop drilling can be perfectly acceptable; first improve ownership and composition before adding a global mechanism.
Official references
- React: Passing Props to a Component
- React: Responding to Events
- React: Keeping Components Pure
- MDN: Default parameters
Interview questions
- How does a child request a change without mutating a prop?
- When is prop drilling acceptable, and what should you try before Context?
- Why are
keyandrefnot ordinary props?
Debug drill: inspect the Components panel at each boundary, then trace task.id and the callback upward. If a value is wrong, find the owner rather than patching the child. The goal is to identify where the incorrect snapshot or callback was introduced.
2026 depth expansion: props are API design
Treat every reusable component's props as a public contract. Even an internal component benefits from a contract that says which values are required, which behaviors are optional, and which decisions belong to the caller.
Poor API:
<TaskRow
task={task}
red
compact
editable
deletable
showOwner
specialMode="dashboard"
/>
A long list of unrelated booleans often means the component is representing several different concepts at once. Callers have to understand combinations such as editable plus deletable, and the component has to define what happens when those combinations conflict.
Prefer explicit composition or focused variants:
<TaskRow task={task}>
<TaskRow.Actions>
<EditTaskButton taskId={task.id} />
<DeleteTaskButton taskId={task.id} />
</TaskRow.Actions>
</TaskRow>
The API now makes the row's domain data and its optional actions visible separately. That does not mean composition is always superior; it means the caller can express the variation directly instead of encoding it in a growing matrix of flags.
Props are immutable snapshots
Do not mutate an object received through props:
function TaskRow({ task }) {
task.completed = true; // wrong
}
The component does not own that value. Ask the owner to change it through an event callback or a state transition. On the next render, the child receives the updated snapshot. This remains true even if the object is technically mutable in JavaScript.
Callback props express intent
Name callbacks around what the child is asking to happen:
<TaskForm onTaskCreate={handleTaskCreate} />
That is preferable to an implementation-oriented name such as setTasksFromChild. The child should not need to know whether the parent stores tasks with useState, a reducer, a state library, or a server mutation. A component API that expresses intent lets its implementation change without forcing every caller to understand the change.
Deep dive: prop APIs should encode domain intent
Compare these two ways to describe a task row. The first exposes the parent's implementation details:
Implementation-shaped API:
<TaskRow
setSelectedTaskId={setSelectedTaskId}
setModalOpen={setModalOpen}
setDeleting={setDeleting}
/>
Domain-shaped API:
<TaskRow
task={task}
onOpen={handleOpenTask}
onDelete={handleDeleteTask}
/>
The second component knows what the user can do, not how the parent stores the result. Opening might use a modal, a route, a drawer, or a side panel; the row does not need to change when that architectural choice changes.
This decoupling matters when UI architecture evolves. A callback called onDelete can continue to work even if the parent later adds a confirmation step or changes its state model. A callback that exposes setDeleting forces the child to participate in those implementation decisions.
Callback contract design
Prefer callbacks that communicate intent and provide the useful domain payload:
onTaskToggle(task.id, nextCompleted)
rather than leaking browser events when the parent does not need the event itself:
onChange(event)
Reusable low-level form primitives may appropriately expose DOM-like event APIs, because their job is close to the DOM. Domain components usually benefit from domain values instead. The parent can then test and reuse the behavior without reconstructing meaning from a browser event.
Optional props and defaults
function Badge({
tone = 'neutral',
children,
}) {
...
}
Use defaults for meaningful optional behavior. A caller that omits tone gets a predictable neutral badge, while a caller that supplies a tone makes its intent explicit.
Avoid ambiguous combinations of boolean flags:
<Alert error warning success />
Prefer one value whose shape makes invalid combinations harder to express:
<Alert tone="error" />
Object props and identity
A parent can accidentally create a new object identity on every render:
<TaskList options={{ sort: 'name' }} />
That is not automatically wrong. A new object is often harmless, and memoizing every object can make the code harder to read without solving a measured problem.
Identity becomes relevant in specific situations:
- a child is memoized;
- the object participates in Effect dependencies;
- a library uses reference equality.
Start with a clear component architecture. Optimize the identity only when the relevant behavior or measurement shows that it matters.
Props and ownership
If a child receives:
task
it must treat that value as read-only. To request a change, provide a callback:
function TaskRow({ task, onToggle }) {
return (
<button
onClick={() => onToggle(task.id)}
>
{task.completed ? 'Reopen' : 'Complete'}
</button>
);
}
The parent remains the owner:
function TaskList() {
const [tasks, setTasks] = useState(initialTasks);
function handleToggle(id) {
setTasks((current) =>
current.map((task) =>
task.id === id
? { ...task, completed: !task.completed }
: task,
),
);
}
return tasks.map((task) => (
<TaskRow
key={task.id}
task={task}
onToggle={handleToggle}
/>
));
}
The update creates a new object only for the task that changed and preserves the other task objects. Most importantly for this lesson, ownership remains visible: TaskRow reports intent, while TaskList decides how the data changes.
Prop drilling is not automatically a problem
Passing a value through a few layers is often simpler than introducing Context. This small path:
Page
→ Toolbar
→ DeleteButton
can be perfectly understandable, especially when the intermediate components are part of the feature and the dependency is easy to see.
Context becomes useful when:
- many distant consumers need the same value;
- repeated threading obscures component APIs;
- the value is conceptually ambient.
Do not use Context solely to avoid writing two prop lines. The cure for unclear ownership is not automatically a more implicit data source.
Children as inversion of control
A layout that decides every possible kind of content tends to accumulate flags and type props:
Instead of:
function Layout({ showSidebar, sidebarType, contentType }) {
...
}
use:
function Layout({ sidebar, children }) {
return (
<div className="layout">
<aside>{sidebar}</aside>
<main>{children}</main>
</div>
);
}
The caller decides what to compose:
<Layout sidebar={<TaskFilters />}>
<TaskBoard />
</Layout>
This is inversion of control: Layout controls placement and framing, while the caller supplies the pieces that make the page specific. It often prevents a reusable component from importing business-specific components and keeps the dependency direction easier to maintain.
Runtime validation versus TypeScript
Plain JavaScript React does not enforce prop types at runtime unless you add validation. A component can therefore receive a value with the wrong shape and fail only when it tries to use that value.
TypeScript can describe compile-time contracts:
type TaskRowProps = {
task: Task;
onToggle: (id: string) => void;
};
This course focuses first on runtime JavaScript and React concepts, but production teams often use TypeScript to catch incorrect callers during development.
There is an important boundary: TypeScript types disappear at runtime. Data arriving from an API is still untrusted as far as the running program is concerned, so API responses still require runtime validation wherever the application needs to trust their shape.
API evolution
Component APIs change as requirements become clearer. Suppose version 1 starts with:
<Avatar src={user.avatar} />
Later, the component needs accessible alternative text and fallback initials. A well-designed API might evolve to:
<Avatar
src={user.avatar}
name={user.name}
/>
The component can derive fallback text from the meaningful domain prop. Avoid forcing every caller to implement the same fallback logic; that creates inconsistent behavior and spreads the component's presentation contract across the application.
Failure clinic
These failures all point back to an API or ownership boundary that is doing too much work.
Mutating prop object
task.completed = true;
This breaks ownership. The child changes a value supplied by someone else rather than requesting a new value through the owner.
Copying every prop into state
const [title, setTitle] = useState(props.title);
This is valid only when the component is intentionally creating an independent draft. Otherwise the copied value can go stale when the parent changes props.title, leaving two competing sources of truth.
Callback knows too much
onClick={() => parentSetState({ modal: 'edit', id: task.id })}
The child has learned the parent's state shape and UI implementation. Prefer a domain callback such as onOpen(task.id) and let the parent decide how to represent that request.
Too many optional props
A component that accepts 25 loosely related props may be several components hiding behind one name. Split the concepts, use focused variants, or let callers compose the variable content.
Exercises
- Redesign an implementation-shaped component API into domain intent callbacks.
- Convert a prop-heavy layout to children/slots.
- Demonstrate when copied prop state goes stale.
- Identify three cases where prop drilling is simpler than Context.
- Document a reusable component's public prop contract.
Mastery check
You should be able to explain:
- how props differ from state;
- why prop mutation breaks ownership;
- when callback props should expose events versus domain values;
- when children provide inversion of control;
- why prop drilling is not inherently an anti-pattern.
