Props, Children and One-Way Data Flow

React & Next.js: The Complete Developer HandbookLesson 4 of 54

Part 1: React Foundations · 4 of 16

Goal: pass data into components with props, treat props as read-only, wrap content with the children prop, and send events back up to a parent with callback props, so you can explain which component owns each piece of data.

In Lesson 3 the catalogue became a tree of components, but they could only show fixed content or reach for a global array. Real components need inputs: a ToolCard has to know which tool to show, and the page has to hear about it when a member presses Reserve. Props are how React connects components. Learning how data travels through them, down as values and up as function calls, gives you a reliable answer to the question every React developer asks daily: “where should this data live?”

Passing and reading props

You pass props the way you write attributes in JSX, and the component receives them all in one object, its first parameter. Most React code destructures that object straight away, so the parameter list doubles as a summary of what the component needs. Here is the status badge every tool card will use:

const STATUS_LABELS = {
  available: 'Available',
  reserved: 'Reserved',
  'on-loan': 'On loan',
  'in-repair': 'In repair',
};

function StatusBadge({ status, size = 'normal' }) {
  return (
    <span className={`badge badge--${status} badge--${size}`}>
      {STATUS_LABELS[status]}
    </span>
  );
}

function App() {
  return (
    <ul>
      <li>Cordless drill <StatusBadge status="available" /></li>
      <li>Extension ladder <StatusBadge status="on-loan" size="small" /></li>
      <li>Hedge trimmer <StatusBadge status="in-repair" /></li>
    </ul>
  );
}

One component, three results: “Available”, “On loan” and “In repair”. Without destructuring, the same component would read props.status and props.size; both styles are correct. A few details worth knowing:

  • Strings go in quotes; everything else goes in braces. maxLoanDays={7} passes the number 7, while maxLoanDays="7" passes the string “7”. Objects, arrays, booleans and functions all need braces too.
  • Defaults use JavaScript default parameters, like size = 'normal' above. The default applies when the prop is missing or undefined, not when it is null. Older code sometimes sets StatusBadge.defaultProps; React 19 no longer supports that for function components, so convert it to default parameters when you meet it.
  • A prop written without a value is true: <StatusBadge status="reserved" compact /> is the same as compact={true}.
  • key is not a prop. React uses it to track list items (Lesson 7) and doesn’t pass it to the component; reading props.key gives undefined and a warning. If the card needs the id, pass it separately or inside the tool object.

Props are the component’s inputs, so when the parent renders again with different values, the child renders with the new ones. You don’t “update” a child; you render the parent with different data and the child follows. This is one-way data flow: data moves down the tree, from the component that owns it to the components that display it.

Props are read-only

A component must never change its props. They belong to the parent: the same tool object might be shown by a card in the grid, a line in the basket and a details panel at the same time. If the card changed it, the others would show something their owner never decided, and React wouldn’t even know to re-render them. React enforces part of this rule in development by freezing the props object, so this throws a TypeError (“Cannot assign to read only property”):

function ToolCard(props) {
  props.tool = { ...props.tool, name: 'Drill' }; // TypeError in development
  // …
}

The freeze is shallow, though. Objects you receive inside props are not frozen, so this line runs without any error, which makes it the more dangerous mistake:

function ToolCard({ tool }) {
  function handleClick() {
    tool.status = 'reserved'; // Changes the parent's data behind its back.
  }
  // …
}

Nothing on screen changes when the button is clicked, because changing an object doesn’t trigger a render. Later, when something else causes a render, the change suddenly appears, possibly in a different part of the page. Bugs like this are hard to trace precisely because they don’t fail where they are caused.

What to do instead depends on what you need. To display a variation, calculate a new value in a local variable during render: const title = tool.name.toUpperCase();. To change the data, ask its owner to change it, using a callback prop, as the next sections show.

Wrapping content with children

Some components are frames rather than content: a panel with a heading, a card outline, a dialog. They shouldn’t need to know what goes inside them. Whatever you place between a component’s opening and closing tags arrives as a special prop called children:

function Panel({ title, children }) {
  return (
    <section style={{ border: '1px solid #c9d1da', borderRadius: 8, padding: '4px 16px', marginBottom: 12 }}>
      <h2>{title}</h2>
      {children}
    </section>
  );
}

function App() {
  return (
    <>
      <Panel title="Opening hours">
        <p>Saturdays 10:00 to 13:00.</p>
      </Panel>
      <Panel title="How borrowing works">
        <ol>
          <li>Reserve a tool online.</li>
          <li>Collect it on Saturday.</li>
          <li>Bring it back clean.</li>
        </ol>
      </Panel>
    </>
  );
}

Two panels with the same frame and completely different content: a paragraph in one, a numbered list in the other. Panel decides where the content goes (after the heading) but not what it is. children can be text, elements, other components or a mix, and if you pass nothing it is undefined, which renders nothing.

This pattern solves more than layout. Suppose the basket panel sits inside a page layout component, and the basket needs the member’s name. Instead of passing memberName through the layout, which has no use for it, the page can create the basket itself and hand it to the layout as children. The layout only provides the frame, so the data skips the components that don’t care about it. Passing a prop through several layers that don’t use it is called prop drilling; children are often the simplest cure, and Lesson 11 covers Context for the cases they can’t handle. The same pattern returns in Next.js, where it lets server and client components nest (Lesson 26).

Sending events up with callback props

Data flows down, but events start at the bottom. The Reserve button is inside a ToolCard, yet the decision about what reserving means (add to the basket, check the three-tool limit, show a message) belongs to the page, which owns the member’s reservations. The card can’t change the page’s data, so the page gives the card a function to call. That function is just another prop:

import { useState } from 'react';

const TOOLS = [
  { id: 't1', name: 'Cordless drill', status: 'available' },
  { id: 't3', name: 'Orbital sander', status: 'available' },
];

function ToolCard({ tool, onReserve }) {
  return (
    <article>
      <h3>{tool.name}</h3>
      <button onClick={() => onReserve(tool.id)}>Reserve</button>
    </article>
  );
}

function CatalogPage() {
  const [reservedId, setReservedId] = useState(null);
  const reservedTool = TOOLS.find((tool) => tool.id === reservedId);

  function handleReserve(toolId) {
    setReservedId(toolId);
  }

  return (
    <main>
      {TOOLS.map((tool) => (
        <ToolCard key={tool.id} tool={tool} onReserve={handleReserve} />
      ))}
      <p>{reservedTool ? `You reserved the ${reservedTool.name}.` : 'Nothing reserved yet.'}</p>
    </main>
  );
}

It starts with “Nothing reserved yet.” Click the drill’s Reserve and the page says “You reserved the Cordless drill.”; click the sander’s and the message changes to the sander. Follow the round trip: the card calls onReserve('t1'), which is the page’s handleReserve; the page sets its state; React renders the page again; the new message flows down as JSX. The card only reports what happened (“this tool’s button was pressed”) and never knows what the page does about it, so the same card works unchanged in a page with a basket, a waiting list or a confirmation dialog.

Two conventions make this easy to read. Callback props are named on plus the event, from the child’s point of view: onReserve, onRemove. The functions the parent passes in are named handle plus the event: handleReserve. Also note the arrow function in the card: onClick={() => onReserve(tool.id)} passes a function that will run on click. Writing onClick={onReserve(tool.id)} instead calls onReserve immediately, during render, and hands its return value to onClick. Lesson 6 covers event handlers in more depth.

This is often called lifting state up: when two components need the same data, such as the cards and the reservation message here, the state lives in their closest common parent, which passes values down and callbacks down for changes coming back up.

Designing a component’s props

Props are a component’s public interface, so they deserve the same care as a function’s parameters. A few guidelines:

  • Pass what the component is about. A ToolCard is about a tool, so tool={tool} is natural, and adding a field to the card later doesn’t change every caller. A StatusBadge only needs a status, so give it status rather than the whole tool; then it also works for a status that doesn’t come from a tool object, such as a legend explaining the colours.
  • Use specific names. tool, status, onReserve tell the reader what to pass. A generic data or item that receives differently shaped objects from different callers forces everyone to read the component’s code to use it.
  • Be careful with spreading. <ToolCard {...tool} /> passes every field of the tool as a separate prop. It is concise, but it hides which props the card really uses and passes fields it never asked for. Prefer listing props explicitly in application code.
  • Keep the list short. If a component needs a dozen props, it is probably doing several jobs; Lesson 3’s advice about splitting applies.

Lesson 16 adds TypeScript, which turns these interfaces into types your editor checks for you. Until then, the destructured parameter list is your documentation, so keep it honest.

Common mistakes

  • Mutating an object received through props. tool.status = 'reserved' in a child. No error, no re-render, and the change leaks into other components later. In review, any assignment to a property of something that came from props is a red flag.
  • Calling the callback instead of passing it. onClick={onReserve(tool.id)} runs during render. The tell-tale signs: the action happens as soon as the page loads, clicking does nothing new, and, if the call updates the parent’s state, React warns “Cannot update a component while rendering a different component”.
  • Numbers passed as strings. maxLoanDays="7" makes maxLoanDays + 1 equal “71”. Use braces for anything that isn’t text.
  • Drilling props through many layers. If a prop passes through three components that only hand it on, consider passing the finished component as children, or Context (Lesson 11).

Build it: cards, badges and panels

Bring this lesson’s pieces together on the catalogue page. The starter already owns the state and the handleReserve function. Complete it so that:

  • StatusBadge({ status }) shows the label for a status;
  • ToolCard({ tool, onReserve }) shows the name and a StatusBadge, and a Reserve button for available tools only, which reports the tool’s id through onReserve;
  • Panel({ title, children }) frames content under an <h2>;
  • the page shows two panels: “Catalogue” with a card per tool, and “Your reservation” with the message.
import { useState } from 'react';

const TOOLS = [
  { id: 't2', name: 'Extension ladder', status: 'on-loan' },
  { id: 't6', name: 'Tile cutter', status: 'available' },
  { id: 't7', name: 'Stud finder', status: 'available' },
];

const STATUS_LABELS = { available: 'Available', reserved: 'Reserved', 'on-loan': 'On loan', 'in-repair': 'In repair' };

// Write StatusBadge, ToolCard and Panel here.

function CatalogPage() {
  const [reservedId, setReservedId] = useState(null);
  const reservedTool = TOOLS.find((tool) => tool.id === reservedId);

  function handleReserve(toolId) {
    setReservedId(toolId);
  }

  return (
    <main>
      <h1>Tool catalogue</h1>
      {/* Replace with the two panels. */}
      <p>{reservedTool ? `You reserved the ${reservedTool.name}.` : 'Nothing reserved yet.'}</p>
    </main>
  );
}

Hint: CatalogPage passes its handleReserve to each card as onReserve. The card wraps the call in an arrow function so it runs on click, with the card’s own tool.id. Neither Panel nor StatusBadge needs to know anything about reservations.

Show a sample solution
import { useState } from 'react';

const TOOLS = [
  { id: 't2', name: 'Extension ladder', status: 'on-loan' },
  { id: 't6', name: 'Tile cutter', status: 'available' },
  { id: 't7', name: 'Stud finder', status: 'available' },
];

const STATUS_LABELS = { available: 'Available', reserved: 'Reserved', 'on-loan': 'On loan', 'in-repair': 'In repair' };

function StatusBadge({ status }) {
  return <span className={`badge badge--${status}`}>{STATUS_LABELS[status]}</span>;
}

function ToolCard({ tool, onReserve }) {
  return (
    <article>
      <h3>{tool.name} <StatusBadge status={tool.status} /></h3>
      {tool.status === 'available' && (
        <button onClick={() => onReserve(tool.id)}>Reserve</button>
      )}
    </article>
  );
}

function Panel({ title, children }) {
  return (
    <section>
      <h2>{title}</h2>
      {children}
    </section>
  );
}

function CatalogPage() {
  const [reservedId, setReservedId] = useState(null);
  const reservedTool = TOOLS.find((tool) => tool.id === reservedId);

  function handleReserve(toolId) {
    setReservedId(toolId);
  }

  return (
    <main>
      <h1>Tool catalogue</h1>
      <Panel title="Catalogue">
        {TOOLS.map((tool) => (
          <ToolCard key={tool.id} tool={tool} onReserve={handleReserve} />
        ))}
      </Panel>
      <Panel title="Your reservation">
        <p>{reservedTool ? `You reserved the ${reservedTool.name}.` : 'Nothing reserved yet.'}</p>
      </Panel>
    </main>
  );
}

The ladder shows “On loan” and no button; the tile cutter and stud finder each have one. Clicking the tile cutter’s button changes the reservation panel to “You reserved the Tile cutter.” The state lives in CatalogPage, the only component that needs both the cards and the message. Values go down (tool, status, title, children), one event comes up (onReserve), and no component changes anything it was given. A real basket holds more than one tool and enforces the three-tool limit; Lesson 5 builds that with an array in state.

Check your understanding

Answer each question in a sentence or two before opening the answer.

1. Why does ToolCard call onReserve(tool.id) instead of keeping the reservation in its own state?

The reservation is needed outside the card, by the message and later by the basket and the three-tool limit, so it must live in a common parent. The card reports the event and the page, which owns the data, decides what happens. That also keeps the card reusable on pages that handle reserving differently.

2. In a click handler, a card runs tool.status = ‘reserved’, but its badge doesn’t change. What is wrong, and why is it worse than it looks?

It mutates an object that belongs to the parent. Changing an object doesn’t trigger a render, so the badge stays the same; the change only appears when something else causes a render, possibly in other components that share the same object. Call a callback prop such as onReserve(tool.id) and let the owner update its state instead.

3. When the catalogue loads, the reservation message already names a tool, before anyone clicks. What would you check first, and what else confirms it?

Check for onClick={onReserve(tool.id)}, which calls the callback during render instead of passing a function. The console confirms it with React’s “Cannot update a component while rendering a different component” warning. Wrap the call: onClick={() => onReserve(tool.id)}.

Key takeaways

  • Props are a component’s inputs, received as one object; destructure them and use default parameters for defaults.
  • Props are read-only. Never assign to them or mutate objects inside them; calculate local values or ask the owner to change the data.
  • children is whatever sits between a component’s tags; it lets frame components wrap any content and helps avoid prop drilling.
  • Data flows down as props; events flow up through callback props such as onReserve. State lives in the closest common parent of the components that need it.

Priority: should know. You should be able to say, for any piece of data on a page, which component owns it and how changes reach it. See Passing Props to a Component and Sharing State Between Components in the React documentation.

Code examples in this course are free to use under the MIT licence. Lesson text © 2026 Farooq Bin Munir.