Episode 2 — React Frontend Architecture NextJS / 2.2 — React Components and Props
2.2.b — Understanding Props
In one sentence: Props are the mechanism React uses to pass read-only data from parent components to child components, enabling dynamic and reusable UI.
Navigation: ← 2.2.a — Functional Components · Next → 2.2.c — Dynamic Rendering
Table of Contents
- What Are Props?
- How Props Flow — The Water Analogy
- Passing Props to Components
- The Props Object
- Destructuring Props
- Default Props
- The Children Prop
- Spreading Props
- Props Are Read-Only (Immutability)
- Prop Types and Runtime Validation
- Props with TypeScript
- Functions as Props (Callbacks)
- Common Prop Patterns in Real Applications
- Prop Drilling — The Problem
- Anti-Patterns and Mistakes
- Key Takeaways
- Explain-It Challenge
1. What Are Props?
Props (short for "properties") are the inputs to a React component. They are how data flows from a parent component down to a child component. If components are functions, props are the arguments you pass to those functions.
┌──────────────────────────────────────────────────────────┐
│ FUNCTION ANALOGY │
│ │
│ Regular function: │
│ greet("Alice", 25) │
│ ↑ ↑ │
│ arg1 arg2 │
│ │
│ React component: │
│ <UserCard name="Alice" age={25} /> │
│ ↑ ↑ │
│ prop1 prop2 │
│ │
│ Both receive inputs and produce output. │
│ Functions return values. Components return JSX. │
└──────────────────────────────────────────────────────────┘
Why Props Matter
Without props, every component would be static — hardcoded content with no way to customize behavior. Props enable:
| Capability | Without Props | With Props |
|---|---|---|
| Reusability | Every card is identical | Each card shows different data |
| Customization | One fixed button style | Primary, secondary, danger variants |
| Composition | Rigid page layouts | Flexible, composable sections |
| Data flow | No parent-child communication | Parent controls child behavior |
| Testing | Can't test variations | Pass different props to test edge cases |
The Simplest Example
// Parent component passes props
function App() {
return <Greeting name="Alice" />;
}
// Child component receives props
function Greeting(props) {
return <h1>Hello, {props.name}!</h1>;
}
// Renders: <h1>Hello, Alice!</h1>
Notice three things:
- Props look like HTML attributes —
name="Alice"resemblesclass="container" - Strings use quotes, everything else uses curly braces —
age={25},active={true} - Props are accessed as object properties —
props.name,props.age
2. How Props Flow — The Water Analogy
React enforces one-way data flow (also called "unidirectional data flow"). Props can only flow downward — from parent to child, never the other way around.
┌─────────────────────────────────────────────────────┐
│ ONE-WAY DATA FLOW │
│ │
│ ┌──────────┐ │
│ │ App │ ← Data originates here │
│ │ (Parent) │ │
│ └────┬──────┘ │
│ │ │
│ props flow DOWN (like gravity) │
│ │ │
│ ┌───────┴────────┐ │
│ ▼ ▼ │
│ ┌──────┐ ┌──────────┐ │
│ │Header│ │ Main │ │
│ │(child)│ │ (child) │ │
│ └──────┘ └────┬─────┘ │
│ │ │
│ props flow DOWN │
│ │ │
│ ┌──────┴──────┐ │
│ ▼ ▼ │
│ ┌────────┐ ┌────────┐ │
│ │ Card │ │ Card │ │
│ │(grand- │ │(grand- │ │
│ │ child) │ │ child) │ │
│ └────────┘ └────────┘ │
│ │
│ ✗ Children CANNOT send props UP to parents │
│ ✗ Siblings CANNOT send props to each other │
│ ✓ Parents CAN pass callbacks (functions) as props │
│ so children can "communicate back" │
└─────────────────────────────────────────────────────┘
Why One-Way?
| Aspect | One-Way (React) | Two-Way (e.g., Angular v1) |
|---|---|---|
| Debugging | Easy — trace data source upward | Hard — changes come from anywhere |
| Predictability | High — parent controls child | Low — child can mutate parent state |
| Performance | Efficient — clear render paths | Complex — digest cycles needed |
| Mental model | Simple — follow the tree | Complex — circular dependencies |
Think of it like a water system:
- Parent = Water tower (source)
- Props = Pipes (transport)
- Children = Faucets (consumers)
Water flows down through pipes. Faucets can't push water back up to the tower. But faucets can signal the tower (via callback props) to change the water pressure.
3. Passing Props to Components
Passing Different Data Types
function App() {
const user = { name: "Alice", role: "admin" };
const hobbies = ["reading", "coding", "hiking"];
return (
<UserProfile
{/* String */}
name="Alice"
{/* Number */}
age={28}
{/* Boolean */}
isActive={true}
{/* Boolean shorthand — just the name means true */}
verified
{/* Array */}
hobbies={hobbies}
{/* Object */}
user={user}
{/* Function */}
onEdit={() => console.log("Edit clicked")}
{/* JSX / React element */}
icon={<StarIcon />}
{/* Null / Undefined (won't render) */}
subtitle={null}
{/* Expression */}
displayName={user.name.toUpperCase()}
{/* Template literal */}
greeting={`Hello, ${user.name}!`}
/>
);
}
The Boolean Shorthand
When a prop is true, you can omit the value:
// These are identical:
<Button disabled={true} />
<Button disabled />
// To pass false, you MUST be explicit:
<Button disabled={false} />
// Or simply don't include it (undefined is falsy):
<Button />
Dynamic Props from Variables
function ProductPage() {
const product = {
id: 1,
name: "Wireless Headphones",
price: 79.99,
inStock: true,
rating: 4.5,
reviews: 128,
};
return (
<div className="product-page">
<ProductImage src={product.image} alt={product.name} />
<ProductInfo
name={product.name}
price={product.price}
rating={product.rating}
reviewCount={product.reviews}
/>
<AddToCartButton
productId={product.id}
available={product.inStock}
/>
</div>
);
}
Conditional Props
function App() {
const isLoggedIn = true;
const userName = "Alice";
return (
<Navbar
// Conditional prop — only pass if truthy
userName={isLoggedIn ? userName : undefined}
// Conditional object spread
{...(isLoggedIn && { dashboardLink: "/dashboard" })}
/>
);
}
4. The Props Object
Every functional component receives exactly one argument: the props object. This is true whether you pass 0 props or 50.
// React internally calls your component like this:
// Greeting({ name: "Alice", age: 28 })
function Greeting(props) {
console.log(props);
// Output: { name: "Alice", age: 28 }
console.log(typeof props);
// Output: "object"
return <h1>Hello, {props.name}! You are {props.age}.</h1>;
}
// Usage
<Greeting name="Alice" age={28} />
What the Props Object Contains
function DebugComponent(props) {
// See ALL props
console.log("All props:", props);
// Check what keys exist
console.log("Keys:", Object.keys(props));
// Check if a specific prop was passed
console.log("Has name:", "name" in props);
console.log("Has age:", props.age !== undefined);
return <pre>{JSON.stringify(props, null, 2)}</pre>;
}
// Usage
<DebugComponent
name="Alice"
age={28}
isActive
tags={["a", "b"]}
/>
// props object:
// {
// name: "Alice",
// age: 28,
// isActive: true,
// tags: ["a", "b"]
// }
Props You Didn't Pass Are undefined
function UserCard(props) {
console.log(props.name); // "Alice"
console.log(props.email); // undefined (not passed)
console.log(props.avatar); // undefined (not passed)
return (
<div>
<h2>{props.name}</h2>
{/* undefined renders as nothing — no error */}
<p>{props.email}</p>
{/* But accessing deep properties on undefined WILL crash */}
{/* <p>{props.address.street}</p> 💥 TypeError! */}
</div>
);
}
<UserCard name="Alice" />
Props vs State — Critical Distinction
┌──────────────────────────────────────────────────────┐
│ PROPS vs STATE │
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ PROPS │ │ STATE │ │
│ ├──────────────┤ ├──────────────┤ │
│ │ Passed IN │ │ Created IN │ │
│ │ from parent │ │ the component│ │
│ │ │ │ itself │ │
│ │ Read-only │ │ Mutable │ │
│ │ (immutable) │ │ (via setter) │ │
│ │ │ │ │ │
│ │ Controlled │ │ Controlled │ │
│ │ by PARENT │ │ by SELF │ │
│ │ │ │ │ │
│ │ Like function│ │ Like local │ │
│ │ arguments │ │ variables │ │
│ └──────────────┘ └──────────────┘ │
│ │
│ A component re-renders when EITHER changes. │
└──────────────────────────────────────────────────────┘
| Feature | Props | State |
|---|---|---|
| Who owns it? | Parent component | The component itself |
| Can component change it? | No (read-only) | Yes (via setState) |
| Triggers re-render? | Yes, when parent passes new values | Yes, when updated |
| Initial source | Parent's render output | useState() initial value |
| Used for | Configuration, data passing | Interactive data, UI toggles |
5. Destructuring Props
The most common pattern in production React is destructuring — extracting values from the props object directly in the function signature.
Basic Destructuring
// WITHOUT destructuring (verbose)
function UserCard(props) {
return (
<div>
<h2>{props.name}</h2>
<p>{props.email}</p>
<span>{props.role}</span>
</div>
);
}
// WITH destructuring (clean, preferred)
function UserCard({ name, email, role }) {
return (
<div>
<h2>{name}</h2>
<p>{email}</p>
<span>{role}</span>
</div>
);
}
Destructuring with Defaults
function Button({
label,
variant = "primary", // default: "primary"
size = "medium", // default: "medium"
disabled = false, // default: false
onClick
}) {
return (
<button
className={`btn btn-${variant} btn-${size}`}
disabled={disabled}
onClick={onClick}
>
{label}
</button>
);
}
// All defaults applied:
<Button label="Click me" onClick={handleClick} />
// variant="primary", size="medium", disabled=false
// Override specific defaults:
<Button label="Delete" variant="danger" size="large" onClick={handleDelete} />
Destructuring with Rest Operator
// Capture specific props, forward everything else
function Input({ label, error, ...restProps }) {
return (
<div className="form-field">
<label>{label}</label>
{/* Spread remaining props onto the native input */}
<input {...restProps} />
{error && <span className="error">{error}</span>}
</div>
);
}
// Usage — type, placeholder, onChange are forwarded to <input>
<Input
label="Email"
error="Invalid email"
type="email"
placeholder="you@example.com"
onChange={handleChange}
autoComplete="email"
/>
Renaming During Destructuring
function UserProfile({
name: userName, // rename "name" to "userName"
avatar: profileImage, // rename "avatar" to "profileImage"
isActive: online // rename "isActive" to "online"
}) {
return (
<div>
<img src={profileImage} alt={userName} />
<h2>{userName}</h2>
{online && <span className="badge">Online</span>}
</div>
);
}
Nested Destructuring
// When props contain objects
function OrderSummary({
order: { id, total, items }, // destructure nested object
customer: { name, address: { city } } // deep nesting
}) {
return (
<div>
<h2>Order #{id}</h2>
<p>Customer: {name} from {city}</p>
<p>Items: {items.length}</p>
<p>Total: ${total.toFixed(2)}</p>
</div>
);
}
// Usage
<OrderSummary
order={{ id: 1001, total: 59.99, items: ["shirt", "pants"] }}
customer={{ name: "Alice", address: { city: "NYC", zip: "10001" } }}
/>
Warning: Deep nested destructuring can make code hard to read. If you're going more than 2 levels deep, consider destructuring in the function body instead.
6. Default Props
There are three ways to provide default values for props:
Method 1: Default Parameters (Recommended)
function Avatar({
src = "/images/default-avatar.png",
size = 48,
alt = "User avatar"
}) {
return (
<img
src={src}
alt={alt}
width={size}
height={size}
style={{ borderRadius: "50%" }}
/>
);
}
// No props — all defaults applied
<Avatar />
// Renders with src="/images/default-avatar.png", size=48
// Partial override
<Avatar src="/alice.jpg" size={64} />
// Renders with src="/alice.jpg", size=64, alt="User avatar"
Method 2: defaultProps (Legacy — Avoid in New Code)
function Avatar({ src, size, alt }) {
return (
<img src={src} alt={alt} width={size} height={size} />
);
}
Avatar.defaultProps = {
src: "/images/default-avatar.png",
size: 48,
alt: "User avatar",
};
Why avoid defaultProps? React team has deprecated
defaultPropsfor function components. It's only still supported for class components. Default parameters are the standard approach.
Method 3: Logical OR / Nullish Coalescing in the Body
function Avatar(props) {
// OR operator (treats "", 0, false as falsy — careful!)
const src = props.src || "/images/default-avatar.png";
// Nullish coalescing (only null/undefined trigger default — safer)
const size = props.size ?? 48;
const alt = props.alt ?? "User avatar";
return <img src={src} alt={alt} width={size} height={size} />;
}
Comparison Table
| Method | Handles undefined | Handles null | Handles "" | Handles 0 | Recommended? |
|---|---|---|---|---|---|
| Default params | Yes → default | No → null | No → "" | No → 0 | Yes |
defaultProps | Yes → default | No → null | No → "" | No → 0 | No (deprecated) |
|| operator | Yes → default | Yes → default | Yes → default | Yes → default | Risky |
?? operator | Yes → default | Yes → default | No → "" | No → 0 | Good alternative |
7. The Children Prop
The children prop is a special built-in prop that contains whatever you put between a component's opening and closing tags.
Basic Usage
function Card({ children }) {
return <div className="card">{children}</div>;
}
// String children
<Card>Hello World</Card>
// children = "Hello World"
// JSX children
<Card>
<h2>Title</h2>
<p>Description here</p>
</Card>
// children = [<h2>Title</h2>, <p>Description here</p>]
// Mixed children
<Card>
Text node
<span>Element node</span>
{42}
</Card>
Wrapper / Layout Components
The children prop is the foundation of composition in React:
function PageLayout({ children }) {
return (
<div className="page">
<Header />
<main className="content">{children}</main>
<Footer />
</div>
);
}
function Modal({ title, children, onClose }) {
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>{title}</h2>
<button onClick={onClose}>×</button>
</div>
<div className="modal-body">{children}</div>
</div>
</div>
);
}
// Usage — any content can go inside
<Modal title="Confirm Delete" onClose={handleClose}>
<p>Are you sure you want to delete this item?</p>
<p>This action cannot be undone.</p>
<div className="actions">
<Button variant="danger" onClick={handleDelete}>Delete</Button>
<Button variant="secondary" onClick={handleClose}>Cancel</Button>
</div>
</Modal>
Children Can Be Anything
function Container({ children }) {
return <div className="container">{children}</div>;
}
// String
<Container>Hello</Container>
// Number
<Container>{42}</Container>
// Array of elements
<Container>
{[1, 2, 3].map(n => <span key={n}>{n}</span>)}
</Container>
// Another component
<Container>
<UserCard name="Alice" />
</Container>
// Function (Render Prop pattern — advanced)
<Container>
{(data) => <span>{data}</span>}
</Container>
// Nothing (renders empty container)
<Container />
<Container>{null}</Container>
<Container>{false}</Container>
<Container>{undefined}</Container>
Named Slots Pattern (Multiple Children Areas)
Unlike Vue or Svelte that have named slots, React uses multiple props:
function PageLayout({ header, sidebar, children, footer }) {
return (
<div className="layout">
<header className="layout-header">{header}</header>
<div className="layout-body">
<aside className="layout-sidebar">{sidebar}</aside>
<main className="layout-main">{children}</main>
</div>
<footer className="layout-footer">{footer}</footer>
</div>
);
}
// Usage
<PageLayout
header={<Navbar />}
sidebar={<SideMenu items={menuItems} />}
footer={<FooterLinks />}
>
<h1>Welcome to Dashboard</h1>
<DashboardWidgets />
</PageLayout>
8. Spreading Props
The spread operator (...) lets you pass all properties of an object as individual props.
Basic Spread
const buttonProps = {
label: "Submit",
variant: "primary",
size: "large",
disabled: false,
onClick: handleSubmit,
};
// Without spread — tedious
<Button
label={buttonProps.label}
variant={buttonProps.variant}
size={buttonProps.size}
disabled={buttonProps.disabled}
onClick={buttonProps.onClick}
/>
// With spread — clean
<Button {...buttonProps} />
// Both render identically
Spread with Override
const defaultCardProps = {
variant: "outlined",
padding: "medium",
shadow: false,
};
// Spread first, then override specific props
<Card {...defaultCardProps} shadow={true} variant="elevated" />
// Result: variant="elevated", padding="medium", shadow=true
// ORDER MATTERS — last one wins
<Card shadow={true} {...defaultCardProps} />
// Result: variant="outlined", padding="medium", shadow=false
// (defaultCardProps.shadow=false overwrites shadow=true!)
Forwarding Props Pattern
// A common pattern: custom component wrapping a native element
function TextInput({ label, error, ...inputProps }) {
return (
<div className="field">
{label && <label>{label}</label>}
<input
className={`input ${error ? "input-error" : ""}`}
{...inputProps}
/>
{error && <span className="error-text">{error}</span>}
</div>
);
}
// All standard <input> props are forwarded automatically
<TextInput
label="Username"
error="Required"
type="text"
name="username"
value={username}
onChange={handleChange}
placeholder="Enter username"
maxLength={20}
autoFocus
required
/>
When NOT to Spread
// DANGER: Spreading user input or API data directly
const apiResponse = await fetch("/api/user").then(r => r.json());
// ❌ BAD — unknown props hit the DOM, causes warnings and security risks
<div {...apiResponse}>Content</div>
// ✅ GOOD — pick only what you need
<UserCard
name={apiResponse.name}
email={apiResponse.email}
/>
9. Props Are Read-Only (Immutability)
This is React's cardinal rule for props: a component must never modify its own props.
What Immutability Means
// ❌ NEVER DO THIS — mutating props
function Greeting(props) {
props.name = props.name.toUpperCase(); // 💥 VIOLATION!
return <h1>Hello, {props.name}</h1>;
}
// ❌ NEVER DO THIS — mutating nested prop data
function UserList({ users }) {
users.push({ name: "New User" }); // 💥 VIOLATION!
users.sort((a, b) => a.name.localeCompare(b.name)); // 💥 VIOLATION!
return users.map(u => <li key={u.id}>{u.name}</li>);
}
// ✅ DO THIS — create new data, leave props untouched
function Greeting({ name }) {
const upperName = name.toUpperCase(); // New variable, props unchanged
return <h1>Hello, {upperName}</h1>;
}
// ✅ DO THIS — copy before modifying
function UserList({ users }) {
const sorted = [...users].sort((a, b) => a.name.localeCompare(b.name));
return sorted.map(u => <li key={u.id}>{u.name}</li>);
}
Why Immutability?
┌──────────────────────────────────────────────────────────┐
│ WHY PROPS ARE IMMUTABLE │
│ │
│ 1. PREDICTABILITY │
│ Parent passes name="Alice" │
│ → Child always sees "Alice" │
│ → No surprise mutations │
│ │
│ 2. PERFORMANCE │
│ React compares prev props vs next props │
│ If props didn't change → skip re-render │
│ Mutation breaks this comparison │
│ │
│ 3. DEBUGGING │
│ Data flows one way: parent → child │
│ If child could change props, data flow is circular │
│ Circular flow = impossible to debug │
│ │
│ 4. PURE FUNCTIONS │
│ Same props → same output (always) │
│ This makes components testable and composable │
└──────────────────────────────────────────────────────────┘
The "Pure Function" Rule
React's documentation says: "All React components must act like pure functions with respect to their props."
// PURE — same input always produces same output
function Price({ amount, currency }) {
const formatted = new Intl.NumberFormat("en-US", {
style: "currency",
currency: currency,
}).format(amount);
return <span className="price">{formatted}</span>;
}
// Price({ amount: 29.99, currency: "USD" })
// ALWAYS renders: <span class="price">$29.99</span>
10. Prop Types and Runtime Validation
Before TypeScript was widespread, React provided PropTypes for runtime prop validation. It's still used in JavaScript projects.
Installation
npm install prop-types
Basic PropTypes
import PropTypes from "prop-types";
function UserCard({ name, age, email, role, avatar, onEdit }) {
return (
<div className="user-card">
<img src={avatar} alt={name} />
<h2>{name}</h2>
<p>Age: {age}</p>
<p>Email: {email}</p>
<span className="badge">{role}</span>
<button onClick={onEdit}>Edit</button>
</div>
);
}
UserCard.propTypes = {
// Basic types
name: PropTypes.string.isRequired,
age: PropTypes.number,
email: PropTypes.string.isRequired,
// Enum — one of specific values
role: PropTypes.oneOf(["admin", "user", "moderator"]),
// URL string (no built-in URL type, just string)
avatar: PropTypes.string,
// Function
onEdit: PropTypes.func,
// Any renderable content (string, number, element, array of these)
children: PropTypes.node,
// A React element specifically
icon: PropTypes.element,
// Object with specific shape
address: PropTypes.shape({
street: PropTypes.string,
city: PropTypes.string.isRequired,
zip: PropTypes.string,
}),
// Array of specific type
tags: PropTypes.arrayOf(PropTypes.string),
// Array of objects with shape
orders: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.number.isRequired,
total: PropTypes.number.isRequired,
})
),
// One of several types
id: PropTypes.oneOfType([
PropTypes.string,
PropTypes.number,
]),
};
UserCard.defaultProps = {
age: 0,
role: "user",
avatar: "/default-avatar.png",
};
PropTypes Reference Table
| Validator | What It Checks |
|---|---|
PropTypes.string | Is a string |
PropTypes.number | Is a number |
PropTypes.bool | Is a boolean |
PropTypes.func | Is a function |
PropTypes.array | Is an array |
PropTypes.object | Is an object |
PropTypes.symbol | Is a symbol |
PropTypes.node | Anything renderable (string, number, element, array) |
PropTypes.element | A React element |
PropTypes.elementType | A React component type (class or function) |
PropTypes.instanceOf(Class) | Instance of a class |
PropTypes.oneOf([...]) | One of listed values |
PropTypes.oneOfType([...]) | One of listed types |
PropTypes.arrayOf(type) | Array of specific type |
PropTypes.objectOf(type) | Object with values of specific type |
PropTypes.shape({...}) | Object with specific shape |
PropTypes.exact({...}) | Object with ONLY these keys |
.isRequired | Chain to make any prop required |
Limitations of PropTypes
- Development only — Warnings only show in development mode, not production
- Runtime cost — Checks happen at runtime, not compile time
- No autocomplete — IDE can't use PropTypes for suggestions
- Easy to forget — No enforcement that you add them
This is why most modern projects prefer TypeScript instead.
11. Props with TypeScript
TypeScript provides compile-time prop validation, IDE autocomplete, and catch errors before your code runs.
Basic Interface
interface UserCardProps {
name: string;
age: number;
email: string;
role?: "admin" | "user" | "moderator"; // optional with union type
avatar?: string; // optional
onEdit?: () => void; // optional callback
}
function UserCard({
name,
age,
email,
role = "user", // default value
avatar = "/default-avatar.png",
onEdit
}: UserCardProps) {
return (
<div className="user-card">
<img src={avatar} alt={name} />
<h2>{name}</h2>
<p>Age: {age}</p>
<p>Email: {email}</p>
<span className="badge">{role}</span>
{onEdit && <button onClick={onEdit}>Edit</button>}
</div>
);
}
Type vs Interface
// Interface — preferred for props (extendable)
interface ButtonProps {
label: string;
onClick: () => void;
}
// Type alias — also works
type ButtonProps = {
label: string;
onClick: () => void;
};
// Interface can extend
interface IconButtonProps extends ButtonProps {
icon: React.ReactNode;
iconPosition?: "left" | "right";
}
Common TypeScript Prop Patterns
// Children prop
interface LayoutProps {
children: React.ReactNode; // any renderable content
}
// Event handlers
interface FormProps {
onSubmit: (data: FormData) => void;
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
}
// Forwarding HTML attributes
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label: string;
error?: string;
}
// Now InputProps includes ALL native <input> attributes plus label and error
// Generic component
interface ListProps<T> {
items: T[];
renderItem: (item: T) => React.ReactNode;
keyExtractor: (item: T) => string;
}
function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
return (
<ul>
{items.map(item => (
<li key={keyExtractor(item)}>{renderItem(item)}</li>
))}
</ul>
);
}
PropTypes vs TypeScript Comparison
| Feature | PropTypes | TypeScript |
|---|---|---|
| When errors appear | Runtime (browser console) | Compile time (editor/build) |
| IDE autocomplete | No | Yes |
| Bundle size impact | Adds ~2KB | Zero (stripped at build) |
| Learning curve | Low | Medium |
| Complex types | Limited | Full type system |
| Required in 2026? | Legacy projects only | Industry standard |
12. Functions as Props (Callbacks)
Passing functions as props is how children communicate back to parents. This is React's answer to "how does a child tell its parent something happened?"
The Pattern
┌─────────────────────────────────────────────────────┐
│ CALLBACK PROP FLOW │
│ │
│ ┌──────────────┐ │
│ │ Parent │ │
│ │ │ │
│ │ state: count │──── passes function ────┐ │
│ │ │ │ │
│ │ handleClick │ │ │
│ │ = () => { │ ▼ │
│ │ setCount │ ┌──────────┐ │
│ │ (c => c+1)│ │ Child │ │
│ │ } │ │ │ │
│ │ │ ◄── child calls ─│ onClick │ │
│ │ re-renders │ the function │ ={prop} │ │
│ │ with new │ │ │ │
│ │ count │ │ <button │ │
│ └──────────────┘ │ onClick │ │
│ │ ={onClick}>│ │
│ └──────────┘ │
└─────────────────────────────────────────────────────┘
Basic Callback
function Parent() {
const [message, setMessage] = useState("");
// Define the handler in the parent
const handleMessage = (text) => {
setMessage(text);
};
return (
<div>
<h1>Message: {message}</h1>
{/* Pass function as prop */}
<Child onSendMessage={handleMessage} />
</div>
);
}
function Child({ onSendMessage }) {
return (
<div>
<button onClick={() => onSendMessage("Hello from Child!")}>
Send Hello
</button>
<button onClick={() => onSendMessage("Goodbye from Child!")}>
Send Goodbye
</button>
</div>
);
}
Real-World Example — Todo App
function TodoApp() {
const [todos, setTodos] = useState([
{ id: 1, text: "Learn React", done: false },
{ id: 2, text: "Build project", done: false },
]);
const handleToggle = (id) => {
setTodos(prev =>
prev.map(todo =>
todo.id === id ? { ...todo, done: !todo.done } : todo
)
);
};
const handleDelete = (id) => {
setTodos(prev => prev.filter(todo => todo.id !== id));
};
const handleAdd = (text) => {
setTodos(prev => [
...prev,
{ id: Date.now(), text, done: false },
]);
};
return (
<div>
<h1>Todos ({todos.filter(t => !t.done).length} remaining)</h1>
<AddTodoForm onAdd={handleAdd} />
<TodoList
todos={todos}
onToggle={handleToggle}
onDelete={handleDelete}
/>
</div>
);
}
function TodoList({ todos, onToggle, onDelete }) {
return (
<ul>
{todos.map(todo => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={() => onToggle(todo.id)}
onDelete={() => onDelete(todo.id)}
/>
))}
</ul>
);
}
function TodoItem({ todo, onToggle, onDelete }) {
return (
<li style={{ textDecoration: todo.done ? "line-through" : "none" }}>
<input
type="checkbox"
checked={todo.done}
onChange={onToggle}
/>
<span>{todo.text}</span>
<button onClick={onDelete}>Delete</button>
</li>
);
}
function AddTodoForm({ onAdd }) {
const [text, setText] = useState("");
const handleSubmit = (e) => {
e.preventDefault();
if (text.trim()) {
onAdd(text.trim());
setText("");
}
};
return (
<form onSubmit={handleSubmit}>
<input
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Add a todo..."
/>
<button type="submit">Add</button>
</form>
);
}
Naming Convention for Callback Props
// Convention: on[Event] for the prop name
// handle[Event] for the function definition
function Parent() {
const handleClick = () => { /* ... */ }; // handle + Event
const handleSubmit = (data) => { /* ... */ };
const handleChange = (value) => { /* ... */ };
const handleClose = () => { /* ... */ };
return (
<Child
onClick={handleClick} // on + Event
onSubmit={handleSubmit}
onChange={handleChange}
onClose={handleClose}
/>
);
}
13. Common Prop Patterns in Real Applications
Pattern 1: Variant Props
function Alert({ variant = "info", title, children, onDismiss }) {
const styles = {
info: { bg: "#e3f2fd", border: "#2196f3", icon: "ℹ️" },
success: { bg: "#e8f5e9", border: "#4caf50", icon: "✅" },
warning: { bg: "#fff3e0", border: "#ff9800", icon: "⚠️" },
error: { bg: "#ffebee", border: "#f44336", icon: "❌" },
};
const style = styles[variant];
return (
<div style={{
padding: "16px",
backgroundColor: style.bg,
borderLeft: `4px solid ${style.border}`,
borderRadius: "4px",
display: "flex",
alignItems: "flex-start",
gap: "12px",
}}>
<span>{style.icon}</span>
<div style={{ flex: 1 }}>
{title && <strong style={{ display: "block", marginBottom: "4px" }}>{title}</strong>}
{children}
</div>
{onDismiss && (
<button onClick={onDismiss} style={{ background: "none", border: "none", cursor: "pointer" }}>
×
</button>
)}
</div>
);
}
// Usage
<Alert variant="success" title="Saved!">Your changes have been saved.</Alert>
<Alert variant="error" onDismiss={handleDismiss}>Something went wrong.</Alert>
<Alert>This is a default info alert.</Alert>
Pattern 2: Render Prop (Function as Child)
function DataFetcher({ url, children }) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch(url)
.then(res => res.json())
.then(setData)
.catch(setError)
.finally(() => setLoading(false));
}, [url]);
// Call children as a function, passing the state
return children({ data, loading, error });
}
// Usage — parent controls the rendering
<DataFetcher url="/api/users">
{({ data, loading, error }) => {
if (loading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
return <UserList users={data} />;
}}
</DataFetcher>
Pattern 3: Polymorphic "as" Prop
function Text({ as: Component = "p", children, ...props }) {
return <Component {...props}>{children}</Component>;
}
// Renders different HTML elements based on "as" prop
<Text as="h1" className="title">Heading</Text> // <h1>
<Text as="span" className="label">Label</Text> // <span>
<Text as="label" htmlFor="email">Email</Text> // <label>
<Text>Default paragraph</Text> // <p>
Pattern 4: Compound Component Props
function Tabs({ children, defaultTab }) {
const [activeTab, setActiveTab] = useState(defaultTab);
return (
<div className="tabs">
<div className="tab-list">
{React.Children.map(children, child => (
<button
className={activeTab === child.props.name ? "active" : ""}
onClick={() => setActiveTab(child.props.name)}
>
{child.props.label}
</button>
))}
</div>
<div className="tab-panel">
{React.Children.map(children, child =>
child.props.name === activeTab ? child.props.children : null
)}
</div>
</div>
);
}
function Tab({ name, label, children }) {
return <>{children}</>;
}
// Usage
<Tabs defaultTab="profile">
<Tab name="profile" label="Profile">
<ProfileForm />
</Tab>
<Tab name="settings" label="Settings">
<SettingsPanel />
</Tab>
<Tab name="billing" label="Billing">
<BillingInfo />
</Tab>
</Tabs>
14. Prop Drilling — The Problem
Prop drilling happens when you pass props through multiple intermediate components that don't use the data themselves — they just forward it down.
┌─────────────────────────────────────────────────────┐
│ PROP DRILLING │
│ │
│ ┌──────────┐ │
│ │ App │ theme="dark" │
│ └────┬──────┘ │
│ │ passes theme │
│ ┌────▼──────┐ │
│ │ Layout │ theme="dark" ← doesn't use it! │
│ └────┬──────┘ │
│ │ forwards theme │
│ ┌────▼──────┐ │
│ │ Sidebar │ theme="dark" ← doesn't use it! │
│ └────┬──────┘ │
│ │ forwards theme │
│ ┌────▼──────┐ │
│ │ Menu │ theme="dark" ← doesn't use it! │
│ └────┬──────┘ │
│ │ forwards theme │
│ ┌────▼──────┐ │
│ │ MenuItem │ theme="dark" ← FINALLY uses it! │
│ └───────────┘ │
│ │
│ 4 components touched, only 1 actually needs theme │
└─────────────────────────────────────────────────────┘
The Problem in Code
// Every component must accept and forward `theme`
function App() {
const theme = "dark";
return <Layout theme={theme} />;
}
function Layout({ theme }) {
// Layout doesn't USE theme, just passes it down
return (
<div>
<Sidebar theme={theme} />
<Main theme={theme} />
</div>
);
}
function Sidebar({ theme }) {
// Sidebar doesn't USE theme, just passes it down
return <Menu theme={theme} />;
}
function Menu({ theme }) {
// Menu doesn't USE theme, just passes it down
return (
<ul>
<MenuItem label="Home" theme={theme} />
<MenuItem label="About" theme={theme} />
</ul>
);
}
function MenuItem({ label, theme }) {
// FINALLY — someone actually uses `theme`!
return (
<li className={`menu-item menu-item-${theme}`}>
{label}
</li>
);
}
Why It's a Problem
| Issue | Impact |
|---|---|
| Verbosity | Every intermediate component needs the prop in its signature |
| Fragility | Rename a prop → update every component in the chain |
| Noise | Components carry props they don't care about |
| Refactoring pain | Moving components requires rewiring prop chains |
| Testing overhead | Must provide drill-through props even in unit tests |
Solutions (Preview — Covered in Later Topics)
- Context API (Topic 2.13) — Skip intermediate components entirely
- State management (Topic 2.13-2.14) — Zustand, Redux
- Component composition — Restructure to reduce nesting
- Render props / children — Pass pre-rendered content instead
// Solution preview: Composition reduces drilling
function App() {
const theme = "dark";
// Instead of drilling, pass the already-themed component
return (
<Layout
sidebar={
<Sidebar>
<Menu>
<MenuItem label="Home" theme={theme} />
<MenuItem label="About" theme={theme} />
</Menu>
</Sidebar>
}
/>
);
}
15. Anti-Patterns and Mistakes
Mistake 1: Mutating Props
// ❌ WRONG — modifying the props array
function SortedList({ items }) {
items.sort((a, b) => a.localeCompare(b)); // Mutates parent's array!
return items.map(item => <li key={item}>{item}</li>);
}
// ✅ CORRECT — create a copy first
function SortedList({ items }) {
const sorted = [...items].sort((a, b) => a.localeCompare(b));
return sorted.map(item => <li key={item}>{item}</li>);
}
Mistake 2: Using Props to Initialize State (then ignoring updates)
// ❌ WRONG — state initialized from prop but never synced
function EditForm({ initialName }) {
// If parent passes a new initialName, this component won't update!
const [name, setName] = useState(initialName);
return <input value={name} onChange={e => setName(e.target.value)} />;
}
// ✅ OPTION A — Use a key to force re-mount
<EditForm key={userId} initialName={user.name} />
// ✅ OPTION B — Use the prop directly (controlled component)
function EditForm({ name, onNameChange }) {
return <input value={name} onChange={e => onNameChange(e.target.value)} />;
}
// ✅ OPTION C — Sync with useEffect (use sparingly)
function EditForm({ initialName }) {
const [name, setName] = useState(initialName);
useEffect(() => { setName(initialName); }, [initialName]);
return <input value={name} onChange={e => setName(e.target.value)} />;
}
Mistake 3: Passing Inline Objects/Arrays (Unnecessary Re-renders)
// ❌ CAUSES RE-RENDER — new object reference every render
function App() {
return (
<UserCard
style={{ color: "red", fontSize: 16 }} // New object each render!
tags={["admin", "active"]} // New array each render!
/>
);
}
// ✅ STABLE REFERENCES — defined outside or memoized
const cardStyle = { color: "red", fontSize: 16 };
const adminTags = ["admin", "active"];
function App() {
return <UserCard style={cardStyle} tags={adminTags} />;
}
Mistake 4: Too Many Props (God Component)
// ❌ TOO MANY PROPS — component does too much
<UserCard
name={name} email={email} phone={phone} avatar={avatar}
address={address} city={city} state={state} zip={zip}
role={role} department={department} manager={manager}
startDate={startDate} salary={salary} isActive={isActive}
onEdit={onEdit} onDelete={onDelete} onPromote={onPromote}
/>
// ✅ BREAK INTO SMALLER COMPONENTS
<UserCard user={user}>
<UserContactInfo email={email} phone={phone} />
<UserAddress address={address} />
<UserEmployment role={role} department={department} />
<UserActions onEdit={onEdit} onDelete={onDelete} />
</UserCard>
Mistake 5: Not Handling Missing Props
// ❌ CRASHES if user prop is undefined
function Profile({ user }) {
return <h1>{user.name}</h1>; // TypeError if user is undefined
}
// ✅ DEFENSIVE — handle missing data
function Profile({ user }) {
if (!user) return <p>No user data</p>;
return <h1>{user.name}</h1>;
}
// ✅ ALSO GOOD — optional chaining
function Profile({ user }) {
return <h1>{user?.name ?? "Unknown User"}</h1>;
}
16. Key Takeaways
- Props are function arguments — they're how parent components pass data to children
- One-way flow — props flow down the tree, never up; callbacks enable upward communication
- Read-only — never mutate props; create copies if you need to transform data
- Destructure — use
{ name, age }in the function signature for clean, readable code - Default values — use default parameters
size = "medium"(notdefaultProps) - Children — the special prop for content between opening/closing tags; enables composition
- Spread operator —
{...props}forwards props cleanly, but don't spread unknown objects - Callbacks — pass functions as props for child-to-parent communication; name them
on[Event] - TypeScript — use interfaces for compile-time prop validation; far superior to PropTypes
- Prop drilling — passing through many layers is a code smell; use Context or composition instead
Explain-It Challenge
-
The Water Tower: Explain React's one-way data flow to someone using the analogy of a water distribution system. How do props relate to pipes? How do callback props relate to sensors that signal the tower? Why can't water flow backwards?
-
The Restaurant: A restaurant has a menu (parent), waiters (props), and kitchen (children). Explain how this maps to React's component communication. What happens when the kitchen needs to tell the front-of-house something? How does this relate to callback props?
-
The Debate: Your teammate says "Just use global variables instead of props — it's simpler." Build a counter-argument explaining why explicit prop passing makes applications more maintainable, testable, and debuggable, even though it's more verbose.
Navigation: ← 2.2.a — Functional Components · Next → 2.2.c — Dynamic Rendering