Lesson 2

Props & composition

Home › Courses › React › Lesson 2
⚛️ Lesson 2 · 20 minutes

Props & composition.
How components talk to each other.

A React app is a tree of components. Props are how data flows through that tree — from parent to child, always one direction. Composition is how small components combine into bigger ones. Together, they're the mechanism that makes React apps modular and predictable. Once you understand props, you understand 80% of how React apps are structured.

~20 minutes Beginner React 19
React path ← Lesson 1 Passing props Destructuring Children Defaults Callback props Prop drilling Challenge

Props are how data flows from parent to child in React. Pass them with JSX attributes: <Greeting name="Alice" age={30} />. The child receives them as an object: function Greeting(props) { ... }. Props are read-only from the child's perspective — a child can display them but cannot change them. When the parent passes new props, the child re-renders. This one-way data flow is what makes React apps predictable.

Before this lesson: complete Lesson 1. You need to be comfortable with JSX and function components.

📤 Passing props — parent to child

JSX attributes. String values in quotes. Everything else in curly braces.

Props are passed using JSX attributes, the same way you pass attributes to HTML elements. String values use quotes (name="Alice"). Numbers, variables, functions, objects, and arrays use curly braces (age={30}, onClick={handleClick}). Everything inside the angle brackets becomes one props object that the child receives.

src/App.jsx
// The child component receives props as its first argument
function Greeting(props) {
  return (
    <div>
      <h1>Hello, {props.name}!</h1>
      <p>You are {props.age} years old.</p>
    </div>
  );
}

// The parent passes props as JSX attributes
function App() {
  return (
    <div>
      <Greeting name="Alice" age={28} />
      <Greeting name="Bob" age={34} />
      <Greeting name="Priya" age={31} />
    </div>
  );
}

export default App;

String values

Use quotes: name="Alice". The value is passed as a string. This is what JSX treats as literal text.

Everything else

Use curly braces: age={28}, items={[1, 2, 3]}, onClick={handleClick}. The value is a JavaScript expression evaluated at render.

Props are read-only

The child cannot modify props.name. Attempting it fails or is ignored. Data flows one way: down. This is by design.

Reusable components

One Greeting component renders three different outputs because of props. This is the entire point — write once, use many times with different data.

Props are function arguments. Think of a component as a function and props as its arguments. <Greeting name="Alice" /> is exactly like calling Greeting({ name: "Alice" }). Same mechanics. React just uses a special syntax for it, and enforces that the function (component) is always called the same way (via JSX). Once you see the function equivalent, props feel less magical.

📦 Destructuring props — the modern pattern

Extract props in the parameter list. Cleaner than props.name everywhere.

Before and after
// BEFORE — verbose
function Greeting(props) {
  return (
    <div>
      <h1>Hello, {props.name}!</h1>
      <p>You are {props.age} years old.</p>
      <p>Your email is {props.email}.</p>
    </div>
  );
}

// AFTER — destructured in the parameter list
function Greeting({ name, age, email }) {
  return (
    <div>
      <h1>Hello, {name}!</h1>
      <p>You are {age} years old.</p>
      <p>Your email is {email}.</p>
    </div>
  );
}

// Both versions work identically.
// Destructuring is just cleaner — no props. prefix everywhere.
// Same props passed: <Greeting name="Alice" age={28} email="a@b.com" />

Object destructuring

{ name, age, email } in the parameter list extracts those properties from the props object. It's the same destructuring you learned in JavaScript — applied to function arguments.

Rename with :

{ name: userName } destructures but renames to userName. Useful when a prop name conflicts with something in scope.

Nested destructuring

{ user: { name, age } } extracts from a nested object. If user might be undefined, use defaults or optional chaining.

Rest for the remaining

{ name, ...rest } — puts remaining props in rest. Useful for forwarding unknown props: <div {...rest} />.

📥 The children prop — wrap anything

Whatever is between the tags becomes props.children. The key to layout components.

src/App.jsx
// A Card component that wraps whatever is between its tags
function Card({ children }) {
  return (
    <div style={{
      border: '1px solid #334155',
      borderRadius: '8px',
      padding: '1rem',
      background: '#1e293b',
    }}>
      {children}
    </div>
  );
}

// Use it — the children can be anything
function App() {
  return (
    <div>
      <Card>
        <h2>User Profile</h2>
        <p>Alice Chen</p>
        <p>alice@example.com</p>
      </Card>

      <Card>
        <img src="photo.jpg" alt="Profile" />
        <button>Edit</button>
      </Card>

      <Card>
        Just some text
      </Card>
    </div>
  );
}

Anything can be children

Text, elements, other components, arrays, fragments. Whatever is between <Card> and </Card> becomes props.children.

Layout components

Card, Modal, Layout, Panel, Container — every "wrapper" component uses children. That's how they wrap arbitrary content without knowing what it is.

Composition over props

Instead of <Card title="X" subtitle="Y" body="Z" />, use <Card><h2>X</h2><p>Y</p></Card>. The reader sees structure; the Card doesn't need to know.

Multiple children

children is a value — it can be a single element, an array of elements, or text. React handles all cases. Use React.Children.map if you need to iterate.

Children is React's most powerful composition primitive. Instead of building a Card with 15 props for every possible content variation, build one Card that accepts children. The parent decides what goes inside. This pattern — "wrap, don't configure" — is what makes React components composable. It's the difference between a component that handles 5 cases and one that handles infinite cases.

🎛️ Default values — when a prop is missing

Destructure with = to give props a fallback. Cleaner than ternaries everywhere.

src/App.jsx
// Default values via destructuring
function Greeting({
  name = 'World',
  age = 0,
  role = 'user',
}) {
  return (
    <div>
      <h1>Hello, {name}!</h1>
      <p>Age: {age}, Role: {role}</p>
    </div>
  );
}

// All of these work:
<Greeting /> // "Hello, World!" age 0, role user
<Greeting name="Alice" /> // "Hello, Alice!" age 0, role user
<Greeting name="Bob" age={30} /> // "Hello, Bob!" age 30
<Greeting name="Carol" age={25} role="admin" /> // everything overridden

// If a prop is required — no default — passing it is the parent's job.
// If the parent forgets, the child gets undefined and renders "Hello, !"
// which is a bug. TypeScript or PropType checks catch this.

Only fires on undefined

Default applies when the prop is undefined (not passed). Passing null, 0, or "" does NOT trigger the default — those are real values.

Required vs optional

If a prop has no default, it's effectively required. If the parent forgets, the child gets undefined. TypeScript or PropTypes catches this; plain JavaScript doesn't.

Computed defaults

The default can be any expression: createdAt = new Date(). It's evaluated on every render, not cached. For expensive values, compute inside the component.

defaultProps (legacy)

Greeting.defaultProps = { name: 'World' } — the old way. Deprecated in React 19 in favor of destructuring defaults. Don't use it in new code.

🔔 Callback props — child talks to parent

Pass a function as a prop. The child calls it. This is how events flow up.

Props flow down (parent → child). But what if a child needs to trigger something in the parent — like deleting an item or submitting a form? The parent passes a function as a prop; the child calls it when something happens. That's the entire mechanism for upward data flow.

src/App.jsx
// Parent passes a function as a prop
function App() {
  function handleDelete(name) {
    alert(`Deleting ${name}`);
  }

  return (
    <div>
      <UserCard name="Alice" onDelete={handleDelete} />
      <UserCard name="Bob" onDelete={handleDelete} />
    </div>
  );
}

// Child receives the function and calls it
function UserCard({ name, onDelete }) {
  return (
    <div>
      <span>{name}</span>
      <button onClick={() => onDelete(name)}>
        Delete
      </button>
    </div>
  );
}

// Data flow:
// 1. Parent owns the delete logic (handleDelete)
// 2. Parent passes it to child (onDelete={handleDelete})
// 3. Child calls it on button click (onDelete(name))
// 4. Parent's function runs, does the actual deletion
// This is "state down, events up" in action.

onSomething naming

Callback props start with "on": onClick, onChange, onDelete, onSubmit. This tells the reader "this prop is a function to call when X happens."

Pass the function, don't call it

onClick={handleClick} — passes the function. onClick={handleClick()} — calls it immediately during render, not on click. This is the #1 mistake.

Arrow functions for arguments

If the child needs to pass arguments to the callback: onClick={() => onDelete(name)}. The arrow wraps the call so it happens on click, not on render.

One direction

Data flows down (props). Events flow up (callbacks). Never the other way. This makes every React app predictable — you always know where data comes from and where changes originate.

"Data down, events up" is the entire React data model. Every app you build follows this pattern: state lives in a parent, passed down via props to children. When something happens (click, form submit), the child calls a callback prop, which the parent handles. That's it. Every React app — from a to-do list to Facebook — uses exactly this pattern at every level. Once you internalize it, React's "complexity" disappears.

🕳️ Prop drilling — when to worry

Passing props through components that don't use them. Fine at 2-3 levels, painful beyond.

Prop drilling example
// Prop drilling — theme travels through 3 components
function App() {
  const theme = 'dark';
  return <Layout theme={theme} />;
}

function Layout({ theme }) {
  // Layout doesn't use theme — just passes it down
  return <Sidebar theme={theme} />;
}

function Sidebar({ theme }) {
  // Sidebar doesn't use theme — just passes it down
  return <Button theme={theme} />;
}

function Button({ theme }) {
  // Finally — Button actually uses it
  return <button className={theme}>Click</button>;
}

// The problem: adding a new prop means touching Layout AND Sidebar,
// even though neither of them uses it.

// ─────────────────────────────────────────

// Fix 1: Context — theme accessible anywhere
const ThemeContext = createContext('light');

function App() {
  return (
    <ThemeContext.Provider value="dark">
      <Layout />
    </ThemeContext.Provider>
  );
}

function Button() {
  const theme = useContext(ThemeContext);
  return <button className={theme}>Click</button>;
}
// Layout and Sidebar don't need to know about theme at all.

// Fix 2: Composition — pass children instead of drilling
function App() {
  const theme = 'dark';
  return (
    <Layout>
      <Sidebar>
        <Button theme={theme} />
      </Sidebar>
    </Layout>
  );
}

2-3 levels is fine

Drilling through 2-3 components is normal and readable. Don't add Context just because a prop travels two levels.

4+ levels — use Context

When a prop travels through 4+ components that don't use it, use Context. The reader sees clearly where data comes from.

Composition first

Before Context, try composition. Pass children instead of drilling — <Layout><Button theme={theme} /></Layout> — Layout doesn't need to know about theme.

Global state (Zustand, Redux)

For app-wide state (user session, cart) used across many components, use a state library. Context works but re-renders everything on change; Zustand is more selective.

🏆 Challenge

Three tasks. Each exercises a different props pattern.

Task 1 — Build a reusable Card

Create a Card component that:

1. Accepts a title prop (string, required)
2. Accepts a variant prop with default "default" (options: "default", "warning", "success")
3. Accepts children for the body content
4. Renders with a border color matching the variant
5. Renders the title in a heading, then the children below

Use it to render three cards in App: one default, one warning, one success. Each should have different content. This teaches props, defaults, and children in one component.

Task 2 — Build a UserList

Create two components:

UserCard — accepts name, email, and onDelete (function). Renders the name, email, and a "Delete" button. Button click calls onDelete(name).

UserList — accepts a users array and onDelete function. Maps over the users, renders a UserCard for each. Pass the same onDelete to every card.

In App, define the users array and the delete handler, pass them to UserList. Test that clicking "Delete" on any card calls the parent's handler with the correct name.

Task 3 — Spot the props bugs

These pieces of code have bugs. What's wrong with each?

1. <Greeting name="Alice" age="28" /> — child does age + 10, gets "2810" instead of 38
2. onClick={handleDelete} vs onClick={() => handleDelete(name)} — why does the second one work?
3. function Greeting(props) { props.name = 'Bob'; return <p>{props.name}</p> } — what happens?
4. Child receives children but renders nothing — what went wrong?
5. Passing a prop that the child doesn't destructure — does the child see it?

Rewrite each fix. If you can't figure out the answer, run the code and read the error message — that's the exercise.

⚠️ Common mistakes

The props bugs that catch every beginner.

1. Calling a function instead of passing it

onClick={handleClick()} calls handleClick on every render, not on click. Fix: onClick={handleClick} (no parens) or onClick={() => handleClick()}.

2. Forgetting curly braces for non-strings

age="28" passes the string "28", not the number 28. Fix: age={28}. Same for booleans: disabled="true" is truthy but wrong; use disabled={true} or just disabled.

3. Trying to mutate props

props.name = 'Bob' has no effect (or throws in strict mode). Fix: if the child needs to change a value, the parent must own it and pass a callback.

4. Forgetting to render children

function Card({ children }) { return <div></div> } — the children prop is received but never rendered. Fix: <div>{children}</div>.

5. Naming a component lowercase

<button /> is a native HTML button. <Button /> is your component. Capitalization matters — lowercase names are treated as HTML tags.

6. Deep prop drilling

Passing the same prop through 5 layers is a maintenance burden — every layer must know about it. Fix: Context, composition, or state management library.

7. Missing keys when mapping

{users.map(u => <UserCard {...u} />)} warns about missing keys. Fix: {users.map(u => <UserCard key={u.id} {...u} />)}.

8. Passing objects inline

<Child config={{ a: 1 }} /> creates a new object every render, causing Child to re-render every time. Fix: define the object outside the render or use useMemo.

➡️ What's next

You can pass data. Next: make components interactive.

❓ Frequently asked questions

What are props in React?

Props (short for 'properties') are how data flows from a parent component to a child component. They're read-only from the child's perspective — a child can display a prop but cannot modify it. When a parent passes new props, the child re-renders.

How do I pass props to a component?

Use JSX attributes: <Greeting name='Alice' age={30} />. Everything inside the angle brackets is passed as a single props object. The child receives it as the first argument. String values use quotes; everything else uses curly braces.

What is the difference between props and state?

Props are data passed from parent to child — immutable from the child's side. State is data a component owns and can change over time. When props change, the component re-renders. When state changes, the component re-renders.

What is the children prop in React?

The children prop is a special prop that contains whatever you write between a component's opening and closing tags. <Card>Hello</Card> — 'Hello' is available inside Card as props.children.

How do I give a prop a default value?

Use destructuring with a default: function Greeting({ name = 'World' }) { ... }. If name isn't passed, it's 'World'. The default only fires on undefined — not on null, 0, or empty string.

What is prop drilling and how do I avoid it?

Prop drilling is when you pass props through many component layers that don't use them. Fixes: useContext (put the value in context and read it anywhere), component composition (pass children instead of drilling), or a state management library for complex apps.

Can I mutate props inside a child component?

No. Props are read-only from the child's perspective. This is by design: React wants data to flow one way (down) and events to flow the other (up). If a child needs to change data, it calls a callback prop provided by the parent.

What is the difference between props and attributes in HTML?

They're the same idea applied to different things. HTML attributes configure HTML elements. React props configure React components. The syntax is identical; the difference is what they configure and that props can be any JavaScript value.

🌐 External resources

← Back to React track
You're in the React track · Lesson 2