Props & Component Composition
Props & Component Composition
Props are how components communicate. Master the patterns in this lesson and you'll be able to build any component API — flexible enough for edge cases, simple enough for daily use.
Props Fundamentals
Props are plain JavaScript values passed from parent to child. Any type works: strings, numbers, arrays, objects, functions, even other components:
// String, number, boolean, array
<ProductCard
name="React Course"
price={49}
isBestseller={true}
tags={["react", "javascript", "frontend"]}
/>
// Object
<UserAvatar user={{ name: "Alice", avatarUrl: "/alice.jpg", role: "admin" }} />
// Function (callback)
<Button onClick={() => setOpen(true)}>Open Modal</Button>
// Component (render prop)
<Modal header={<h2>Confirm Delete</h2>}>
<p>This cannot be undone.</p>
</Modal>
Destructuring Props
Always destructure — it's cleaner and shows exactly what a component needs:
// ❌ Verbose
function Card(props: CardProps) {
return <div>{props.title} — {props.subtitle}</div>;
}
// ✅ Destructured
function Card({ title, subtitle, children }: CardProps) {
return <div>{title} — {subtitle}{children}</div>;
}
// With defaults
function Button({
children,
variant = "primary",
size = "md",
disabled = false,
onClick
}: ButtonProps) {
// ...
}
The children Prop
children is the JSX content placed between a component's opening and closing tags. It lets you build wrapper components:
interface CardProps {
title: string;
className?: string;
children: React.ReactNode;
}
function Card({ title, className = "", children }: CardProps) {
return (
<div className={`bg-white rounded-xl border border-gray-200 shadow-sm ${className}`}>
<div className="px-6 py-4 border-b border-gray-100">
<h3 className="font-semibold text-gray-900">{title}</h3>
</div>
<div className="p-6">
{children}
</div>
</div>
);
}
// Usage — anything goes inside:
<Card title="User Details">
<div className="flex items-center gap-3">
<Avatar user={user} />
<div>
<p className="font-medium">{user.name}</p>
<p className="text-gray-500 text-sm">{user.email}</p>
</div>
</div>
</Card>
Composition Over Configuration
Instead of adding more props to handle more cases, use composition:
// ❌ Configuration approach — gets messy fast
<Modal
title="Delete Course"
body="Are you sure?"
primaryButtonLabel="Delete"
primaryButtonVariant="danger"
secondaryButtonLabel="Cancel"
showIcon={true}
iconType="warning"
onPrimary={handleDelete}
onSecondary={() => setOpen(false)}
/>
// ✅ Composition approach — flexible and readable
<Modal>
<Modal.Header>
<WarningIcon className="text-red-500" />
<Modal.Title>Delete Course</Modal.Title>
</Modal.Header>
<Modal.Body>
<p>Are you sure? This action cannot be undone.</p>
</Modal.Body>
<Modal.Footer>
<Button variant="ghost" onClick={() => setOpen(false)}>Cancel</Button>
<Button variant="danger" onClick={handleDelete}>Delete</Button>
</Modal.Footer>
</Modal>
The second API can handle any content in any arrangement. The first needs a new prop every time you encounter an edge case.
Slot Pattern
Pass named sections as props — useful when children isn't enough:
interface PageLayoutProps {
header: React.ReactNode;
sidebar: React.ReactNode;
children: React.ReactNode;
}
function PageLayout({ header, sidebar, children }: PageLayoutProps) {
return (
<div className="min-h-screen flex flex-col">
<header className="h-16 border-b">{header}</header>
<div className="flex flex-1">
<aside className="w-64 border-r p-4">{sidebar}</aside>
<main className="flex-1 p-8">{children}</main>
</div>
</div>
);
}
// Usage
<PageLayout
header={<NavBar user={user} />}
sidebar={<CourseSidebar chapters={course.chapters} />}
>
<LessonContent lesson={lesson} />
</PageLayout>
Render Props
A function prop that returns JSX — passes data from child to parent for rendering:
interface ListProps<T> {
items: T[];
renderItem: (item: T, index: number) => React.ReactNode;
renderEmpty?: () => React.ReactNode;
}
function List<T>({ items, renderItem, renderEmpty }: ListProps<T>) {
if (items.length === 0) {
return renderEmpty ? <>{renderEmpty()}</> : <p>No items</p>;
}
return (
<ul className="divide-y">
{items.map((item, index) => (
<li key={index} className="py-3">
{renderItem(item, index)}
</li>
))}
</ul>
);
}
// Generic, reusable for any data type
<List
items={courses}
renderItem={(course) => (
<div className="flex items-center gap-3">
<img src={course.image} className="w-12 h-12 rounded" alt=""/>
<div>
<p className="font-medium">{course.title}</p>
<p className="text-sm text-gray-500">${course.price}</p>
</div>
</div>
)}
renderEmpty={() => <p className="text-center py-8 text-gray-500">No courses found.</p>}
/>
Forwarding Props with Spread
For wrapper components that should accept all native element props:
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label: string;
error?: string;
}
function FormInput({ label, error, className = "", ...props }: InputProps) {
return (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{label}
</label>
<input
className={`w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 ${error ? "border-red-400" : "border-gray-300"} ${className}`}
{...props} // passes type, placeholder, disabled, value, onChange, etc.
/>
{error && <p className="mt-1 text-sm text-red-600">{error}</p>}
</div>
);
}
// Works with all native input attributes
<FormInput
label="Email"
type="email"
placeholder="alice@example.com"
value={email}
onChange={e => setEmail(e.target.value)}
error={errors.email}
disabled={loading}
autoComplete="email"
/>
TypeScript Props Patterns
// Union types for variants
type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
type ButtonSize = "sm" | "md" | "lg";
interface ButtonProps {
variant?: ButtonVariant;
size?: ButtonSize;
children: React.ReactNode;
onClick?: () => void;
disabled?: boolean;
}
// Discriminated union — different props for different uses
type AlertProps =
| { type: "success"; message: string }
| { type: "error"; message: string; onRetry?: () => void }
| { type: "warning"; message: string; onDismiss?: () => void };
function Alert(props: AlertProps) {
if (props.type === "error" && props.onRetry) {
return (
<div className="bg-red-50 border border-red-200 rounded p-4">
<p>{props.message}</p>
<button onClick={props.onRetry}>Try again</button>
</div>
);
}
// ...
}
// Polymorphic component — renders as different HTML elements
interface TextProps {
as?: "h1" | "h2" | "h3" | "p" | "span";
children: React.ReactNode;
className?: string;
}
function Text({ as: Tag = "p", children, className }: TextProps) {
return <Tag className={className}>{children}</Tag>;
}
<Text as="h1" className="text-4xl font-bold">Hero Title</Text>
<Text as="p" className="text-gray-600">Body text</Text>
Key Rules
- Props flow down — only parent-to-child, never child-to-parent directly
- Callbacks flow up — pass a function down so the child can call it and communicate back
- Never modify props — they're read-only; use state for things that change
- Keep prop interfaces minimal — only pass what the component actually needs
Next lesson: Event handling and synthetic events — making components respond to user interactions.
Get this course's notes on Telegram!
Free cheat sheets, summaries & practice exercises