FullStack Course LogoFullStack Course
Module: React and Ecosystem
React and Ecosystem·091·14 MIN READ

091: Props

TOPICS COVERED: 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:

  1. What makes a useful component boundary?
  2. Why should a component be pure during rendering?
  3. 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:

jsx
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.

Use default values in the function parameter rather than the function component defaultProps property:

jsx
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.

jsx
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>
  );
}
css
.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:

jsx
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:

jsx
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:

jsx
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 defaultProps is an outdated pattern; use parameter defaults.
  • Passing "false" passes a truthy string; use enabled={false}.
  • Calling a callback while rendering, onClick={onDelete(id)}, runs too early. Pass onClick={() => onDelete(id)}.
  • Assuming key appears 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.

jsx
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:

  1. What problem does this concept solve?
  2. What is one common mistake?
  3. 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

Interview questions

  1. How does a child request a change without mutating a prop?
  2. When is prop drilling acceptable, and what should you try before Context?
  3. Why are key and ref not 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:

jsx
<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:

jsx
<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:

jsx
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:

jsx
<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:

jsx
<TaskRow
  setSelectedTaskId={setSelectedTaskId}
  setModalOpen={setModalOpen}
  setDeleting={setDeleting}
/>

Domain-shaped API:

jsx
<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:

jsx
onTaskToggle(task.id, nextCompleted)

rather than leaking browser events when the parent does not need the event itself:

jsx
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

jsx
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:

jsx
<Alert error warning success />

Prefer one value whose shape makes invalid combinations harder to express:

jsx
<Alert tone="error" />

Object props and identity

A parent can accidentally create a new object identity on every render:

jsx
<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:

jsx
task

it must treat that value as read-only. To request a change, provide a callback:

jsx
function TaskRow({ task, onToggle }) {
  return (
    <button
      onClick={() => onToggle(task.id)}
    >
      {task.completed ? 'Reopen' : 'Complete'}
    </button>
  );
}

The parent remains the owner:

jsx
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:

text
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:

jsx
function Layout({ showSidebar, sidebarType, contentType }) {
  ...
}

use:

jsx
function Layout({ sidebar, children }) {
  return (
    <div className="layout">
      <aside>{sidebar}</aside>
      <main>{children}</main>
    </div>
  );
}

The caller decides what to compose:

jsx
<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:

tsx
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:

jsx
<Avatar src={user.avatar} />

Later, the component needs accessible alternative text and fallback initials. A well-designed API might evolve to:

jsx
<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

jsx
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

jsx
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

jsx
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

  1. Redesign an implementation-shaped component API into domain intent callbacks.
  2. Convert a prop-heavy layout to children/slots.
  3. Demonstrate when copied prop state goes stale.
  4. Identify three cases where prop drilling is simpler than Context.
  5. 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.
Reader page: /react/lesson/091/props