Module: React and Ecosystem
React and Ecosystem·091·8 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 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

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

jsx
function TaskCard({ title, priority = 'normal' }) {
  return <article><h2>{title}</h2><p>{priority}</p></article>;
}

<TaskCard title="Read about props" priority="high" />

Use default values in function parameters, not function component defaultProps:

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

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; }

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:

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

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

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

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.

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

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

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.


2026 depth expansion: props are API design

Treat every reusable component's props as a public contract.

Poor API:

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

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

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 state transition.

Callback props express intent

Prefer intent-oriented names:

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

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

Domain-shaped API:

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

jsx
onTaskToggle(task.id, nextCompleted)

rather than leaking browser events unless the parent genuinely needs the event:

jsx
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

jsx
function Badge({
  tone = 'neutral',
  children,
}) {
  ...
}

Use defaults for meaningful optional behavior.

Avoid ambiguous combinations:

jsx
<Alert error warning success />

Prefer:

jsx
<Alert tone="error" />

Object props and identity

A parent can accidentally create new object identities on every render:

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

jsx
task

it must treat it as read-only.

To request change:

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

Parent:

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}
    />
  ));
}

Ownership remains visible.

Prop drilling is not automatically a problem

Passing a value through a few layers is often simpler than Context.

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

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

use:

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

Caller decides what content to compose:

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

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

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

Later you need accessible alt 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 a meaningful domain prop.

Avoid forcing every caller to implement the same logic.

Failure clinic

Mutating prop object

jsx
task.completed = true;

Breaks ownership.

Copying every prop into state

jsx
const [title, setTitle] = useState(props.title);

Only valid when intentionally creating an independent draft. Otherwise the copy can go stale.

Callback knows too much

jsx
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

  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.