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 prematurely replacing props.
I can design and use a reusable card API while treating props as read-only inputs.
Prerequisites
Complete 090. You should understand function components, module scope, JSX expressions, and why a parent owns shared data.
Retrieval practice
- What makes a useful component boundary?
- Why should a component be pure during rendering?
- When is duplication better than a premature abstraction?
Content to cover
passing data; children; one-way data flow; prop defaults; prop drilling concept.
Terms and mental model
Props are the arguments to a component. JSX attributes become properties of one object. The parent owns those values and passes a snapshot downward; the child reads them but does not mutate them.
function TaskCard({ title, priority = 'normal' }) {
return <article><h2>{title}</h2><p>{priority}</p></article>;
}
<TaskCard title="Read about props" priority="high" />
- 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 function parameters, not function component defaultProps:
function Badge({ label, tone = 'neutral' }) { /* ... */ }
Passing tone={null} does not select the default; only omission or undefined does. Choose defaults that make the component valid and unsurprising.
Beginner complete example: ProductCard
The outline asks for product cards, so this first example uses that domain before returning to Task Manager continuity.
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; }
Numbers and booleans use braces. A quoted JSX attribute is a string. Boolean shorthand <ProductCard inStock /> means inStock={true}. The button label includes the product name, so adjacent cards do not produce ambiguous “Add” buttons.
Children and composition
children is another prop, but it supports flexible composition:
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 content; Panel decides framing. Prefer this to paragraphText, showButton, and buttonText props when callers need varied content. A focused task prop is clearer when content always has one domain shape.
Intermediate: Task Manager data flow
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. The parent passes a capability down; the child calls it after an event. The child does not mutate task.completed. Later, the parent's callback will immutably create next state.
function TaskItem({ task }) {
task.completed = true; // Wrong: mutates a prop object.
return <li>{task.title}</li>;
}
JavaScript does not enforce prop immutability, but React's model depends on it. Request a change with a callback and let the state owner produce new data.
Prop drilling
Suppose App → Workspace → TaskArea → TaskList → TaskItem passes onToggleTask, while only TaskItem calls it. That is prop drilling. It is a description, not automatically a defect. Explicit props are easy to trace and remain the first choice for a modest tree.
First consider composition or moving state closer. Context suits broadly needed information such as theme or authenticated account, but adds an implicit dependency and is not required just to avoid two intermediate props. State tools are not substitutes for clear ownership.
Optional advanced: component API design
Prefer required domain data plus a few meaningful options. Avoid forwarding arbitrary objects unless intentionally wrapping a built-in element. key and ref are special React inputs; key is not available inside child props. If a child needs an ID, pass it separately: <TaskItem key={task.id} taskId={task.id} />.
Object and array props share references. Read-only means the child must not push, sort in place, or assign nested properties. If a component needs a transformed array, use non-mutating operations such as filter, map, or [...items].sort(...).
Mistakes and debugging
- 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.
Use React Developer Tools to inspect the actual props. Trace an incorrect value upward until finding the owner. If a default does not apply, check for explicit null. If a click fires immediately, inspect whether JSX received a function or the function's return value.
Accessibility and performance
Props should make accessible use easy. Require meaningful button labels, pass image alt text when images convey content, and preserve heading order in composed components. Avoid a generic as prop in beginner components because it can make invalid semantics easy. A disabled control needs surrounding text if the reason is not evident.
Passing a new object or inline function is normally fine. Do not add useMemo or useCallback just to stabilize every prop. Keep APIs small, state local, and measure real interactions before optimizing. Large objects passed everywhere are more often an architecture clarity problem than an immediate performance problem.
Practice
Build reusable ProductCard components.
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 it never interprets. Keep the event callback explicit at the feature boundary. Context would hide a small, traceable dependency and is not justified yet.
Exit questions
- 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, children for flexible composition, and callbacks to let children request parent-owned changes. Prop drilling can be acceptable; improve ownership before adding global mechanisms.
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.
2026 depth expansion: props are API design
Treat every reusable component's props as a public contract.
Poor API:
<TaskRow
task={task}
red
compact
editable
deletable
showOwner
specialMode="dashboard"
/>
A large collection of unrelated booleans often signals that the component is trying to represent several different concepts.
Prefer explicit composition or focused variants:
<TaskRow task={task}>
<TaskRow.Actions>
<EditTaskButton taskId={task.id} />
<DeleteTaskButton taskId={task.id} />
</TaskRow.Actions>
</TaskRow>
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 state transition.
Callback props express intent
Prefer intent-oriented names:
<TaskForm onTaskCreate={handleTaskCreate} />
over implementation-oriented names such as setTasksFromChild. The child should not need to know how the parent stores its data.
A good component API lets the implementation change without forcing all callers to understand the change.
Deep dive: prop APIs should encode domain intent
Consider two versions of a row.
Implementation-shaped API:
<TaskRow
setSelectedTaskId={setSelectedTaskId}
setModalOpen={setModalOpen}
setDeleting={setDeleting}
/>
Domain-shaped API:
<TaskRow
task={task}
onOpen={handleOpenTask}
onDelete={handleDeleteTask}
/>
The second component does not know how its parent stores state or whether opening a task uses a modal, route, drawer, or side panel.
This decoupling matters when UI architecture changes.
Callback contract design
Prefer callbacks that communicate intent and useful payloads:
onTaskToggle(task.id, nextCompleted)
rather than leaking browser events unless the parent genuinely needs the event:
onChange(event)
Reusable low-level form primitives may appropriately expose DOM-like event APIs. Domain components usually benefit from domain values.
Optional props and defaults
function Badge({
tone = 'neutral',
children,
}) {
...
}
Use defaults for meaningful optional behavior.
Avoid ambiguous combinations:
<Alert error warning success />
Prefer:
<Alert tone="error" />
Object props and identity
A parent can accidentally create new object identities on every render:
<TaskList options={{ sort: 'name' }} />
This is not automatically wrong. Do not memoize merely because the object is new.
It becomes relevant when:
- child is memoized;
- object participates in Effect dependencies;
- a library uses reference equality.
Prefer clear architecture before optimization.
Props and ownership
If the child receives:
task
it must treat it as read-only.
To request change:
function TaskRow({ task, onToggle }) {
return (
<button
onClick={() => onToggle(task.id)}
>
{task.completed ? 'Reopen' : 'Complete'}
</button>
);
}
Parent:
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}
/>
));
}
Ownership remains visible.
Prop drilling is not automatically a problem
Passing a value through a few layers is often simpler than Context.
Page → Toolbar → DeleteButton
can be perfectly understandable.
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.
Children as inversion of control
Instead of:
function Layout({ showSidebar, sidebarType, contentType }) {
...
}
use:
function Layout({ sidebar, children }) {
return (
<div className="layout">
<aside>{sidebar}</aside>
<main>{children}</main>
</div>
);
}
Caller decides what content to compose:
<Layout sidebar={<TaskFilters />}>
<TaskBoard />
</Layout>
This often prevents a reusable component from importing business-specific components.
Runtime validation versus TypeScript
Plain JavaScript React does not enforce prop types at runtime unless you add validation.
TypeScript can provide compile-time contracts:
type TaskRowProps = {
task: Task;
onToggle: (id: string) => void;
};
This course focuses first on runtime JavaScript/React concepts, but production teams often use TypeScript.
Important:
TypeScript types disappear at runtime. API responses still require runtime validation where trust matters.
API evolution
Suppose version 1:
<Avatar src={user.avatar} />
Later you need accessible alt 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 a meaningful domain prop.
Avoid forcing every caller to implement the same logic.
Failure clinic
Mutating prop object
task.completed = true;
Breaks ownership.
Copying every prop into state
const [title, setTitle] = useState(props.title);
Only valid when intentionally creating an independent draft. Otherwise the copy can go stale.
Callback knows too much
onClick={() => parentSetState({ modal: 'edit', id: task.id })}
Child has learned parent internals.
Too many optional props
A component that accepts 25 loosely related props may be several components hiding behind one name.
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.
