On this page
Props and Children: Flexible Component Design
What Are Props?
Props (short for "properties") are the mechanism React uses to pass data from a parent component to its children. Props flow downward through the component tree — from parent to child — and are read-only inside the receiving component. A component should never modify its own props.
// Parent passes props
<UserCard name="Alice" role="admin" score={1500} />
// Child receives props
function UserCard({ name, role, score }: UserCardProps) {
return <div>{name} — {role} — {score} pts</div>;
}Typing Props with TypeScript
TypeScript makes prop definitions explicit and self-documenting. The standard approach is to define an interface for your props:
interface ButtonProps {
label: string; // required string
variant: 'primary' | 'secondary' | 'ghost'; // union type
size?: 'sm' | 'md' | 'lg'; // optional
disabled?: boolean;
onClick?: () => void;
}
function Button({
label,
variant,
size = 'md',
disabled = false,
onClick,
}: ButtonProps) {
return (
<button
type="button"
className={`btn btn--${variant} btn--${size}`}
disabled={disabled}
onClick={onClick}
>
{label}
</button>
);
}TypeScript will now give you autocomplete and type errors when you use <Button /> incorrectly. For example, passing variant="invalid" will produce a compile-time error.
Required vs. Optional Props
- Props without
?are required — TypeScript will error if they are omitted. - Props with
?are optional — you should always handle the case where they areundefined.
interface AlertProps {
message: string; // required
type?: 'info' | 'warning' | 'error'; // optional, defaults to 'info'
onDismiss?: () => void; // optional callback
}
function Alert({ message, type = 'info', onDismiss }: AlertProps) {
return (
<div
role="alert"
className={`alert alert--${type}`}
>
<p>{message}</p>
{onDismiss && (
<button type="button" onClick={onDismiss} aria-label="Dismiss">
×
</button>
)}
</div>
);
}Passing Functions as Props
Props can be any JavaScript value — including functions. This is how child components communicate back to their parents (the "lifting state up" pattern):
interface NumberInputProps {
value: number;
min?: number;
max?: number;
onChange: (newValue: number) => void;
}
function NumberInput({ value, min = 0, max = 100, onChange }: NumberInputProps) {
function handleDecrement() {
if (value > min) onChange(value - 1);
}
function handleIncrement() {
if (value < max) onChange(value + 1);
}
return (
<div className="number-input">
<button type="button" onClick={handleDecrement} aria-label="Decrease">−</button>
<output>{value}</output>
<button type="button" onClick={handleIncrement} aria-label="Increase">+</button>
</div>
);
}
// Parent manages state
function QuantitySelector() {
const [quantity, setQuantity] = React.useState(1);
return (
<div>
<NumberInput value={quantity} min={1} max={99} onChange={setQuantity} />
<p>Total: {quantity} items selected</p>
</div>
);
}The `children` Prop
The children prop is a special prop that receives whatever JSX is placed between the opening and closing tags of a component. This is the primary mechanism for composition in React.
import { type ReactNode } from 'react';
interface PanelProps {
heading: string;
children: ReactNode;
}
function Panel({ heading, children }: PanelProps) {
return (
<aside className="panel">
<h3 className="panel__heading">{heading}</h3>
<div className="panel__content">{children}</div>
</aside>
);
}
// Usage — anything can go inside Panel
function Sidebar() {
return (
<Panel heading="Navigation">
<nav>
<a href="/dashboard">Dashboard</a>
<a href="/settings">Settings</a>
</nav>
</Panel>
);
}Slot-Based Composition with Named Props
When a component needs multiple slots of content (not just one children), use named ReactNode props:
interface LayoutProps {
header: ReactNode;
sidebar: ReactNode;
main: ReactNode;
footer?: ReactNode;
}
function Layout({ header, sidebar, main, footer }: LayoutProps) {
return (
<div className="layout">
<header className="layout__header">{header}</header>
<div className="layout__body">
<aside className="layout__sidebar">{sidebar}</aside>
<main className="layout__main">{main}</main>
</div>
{footer && <footer className="layout__footer">{footer}</footer>}
</div>
);
}
// Usage
function App() {
return (
<Layout
header={<NavBar />}
sidebar={<TreeMenu />}
main={<ArticleContent />}
footer={<SiteFooter />}
/>
);
}This pattern is sometimes called the "render props via JSX" pattern and gives the parent full control over each region of the layout.
Spreading Props
When you wrap an existing HTML element and want to forward all its native attributes, you can spread props:
import { type ComponentPropsWithoutRef } from 'react';
// Extend native button attributes + add custom props
interface IconButtonProps extends ComponentPropsWithoutRef<'button'> {
icon: string;
label: string;
}
function IconButton({ icon, label, className = '', ...rest }: IconButtonProps) {
return (
<button
type="button"
className={`icon-btn ${className}`}
aria-label={label}
{...rest}
>
<span aria-hidden="true">{icon}</span>
</button>
);
}
// All native button props work automatically
<IconButton
icon="🗑"
label="Delete item"
onClick={handleDelete}
disabled={isDeleting}
data-testid="delete-btn"
/>Using ComponentPropsWithoutRef<'button'> from React's type definitions ensures your wrapper component exposes the full API of the native element it wraps.
Component Composition Patterns
Container / Presenter Pattern
Separate the logic (container) from the visual representation (presenter):
// Presenter — pure display, no side effects
function UserListView({ users, onSelect }: UserListViewProps) {
return (
<ul>
{users.map((u) => (
<li key={u.id}>
<button type="button" onClick={() => onSelect(u.id)}>
{u.name}
</button>
</li>
))}
</ul>
);
}
// Container — fetches data, manages state
function UserListContainer() {
const [users, setUsers] = React.useState<User[]>([]);
React.useEffect(() => {
fetch('/api/users').then((r) => r.json()).then(setUsers);
}, []);
function handleSelect(id: string) {
console.log('Selected user:', id);
}
return <UserListView users={users} onSelect={handleSelect} />;
}Compound Components
Compound components share implicit state through a common parent:
import { createContext, useContext, useState, type ReactNode } from 'react';
interface TabsContextValue {
activeTab: string;
setActiveTab: (tab: string) => void;
}
const TabsContext = createContext<TabsContextValue | null>(null);
function Tabs({ defaultTab, children }: { defaultTab: string; children: ReactNode }) {
const [activeTab, setActiveTab] = useState(defaultTab);
return (
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
<div className="tabs">{children}</div>
</TabsContext.Provider>
);
}
function TabList({ children }: { children: ReactNode }) {
return <div role="tablist" className="tab-list">{children}</div>;
}
function Tab({ id, label }: { id: string; label: string }) {
const ctx = useContext(TabsContext)!;
return (
<button
type="button"
role="tab"
aria-selected={ctx.activeTab === id}
onClick={() => ctx.setActiveTab(id)}
>
{label}
</button>
);
}
function TabPanel({ id, children }: { id: string; children: ReactNode }) {
const ctx = useContext(TabsContext)!;
if (ctx.activeTab !== id) return null;
return <div role="tabpanel">{children}</div>;
}
Tabs.List = TabList;
Tabs.Tab = Tab;
Tabs.Panel = TabPanel;
// Elegant usage
function App() {
return (
<Tabs defaultTab="overview">
<Tabs.List>
<Tabs.Tab id="overview" label="Overview" />
<Tabs.Tab id="details" label="Details" />
</Tabs.List>
<Tabs.Panel id="overview"><p>Overview content</p></Tabs.Panel>
<Tabs.Panel id="details"><p>Detailed content</p></Tabs.Panel>
</Tabs>
);
}Prop Types Best Practices
- Always define an interface for component props — never use inline type annotations for complex shapes.
- Export prop interfaces alongside the component so consumers can extend them.
- Use union types for variants:
'sm' | 'md' | 'lg'instead ofstring. - Prefer
ReactNodeoverJSX.Elementfor thechildrenprop. - Use
ComponentPropsWithoutRef<'element'>when wrapping native HTML elements.
In the next lesson, you will add interactivity with useState and learn how React handles user events.
import { type ReactNode } from 'react';
interface CardProps {
title: string;
description?: string;
variant?: 'default' | 'featured' | 'danger';
footer?: ReactNode;
children: ReactNode;
}
function Card({
title,
description,
variant = 'default',
footer,
children,
}: CardProps) {
const variantClass = {
default: 'card--default',
featured: 'card--featured',
danger: 'card--danger',
}[variant];
return (
<section className={`card ${variantClass}`}>
<div className="card__header">
<h2 className="card__title">{title}</h2>
{description && (
<p className="card__description">{description}</p>
)}
</div>
<div className="card__body">
{children}
</div>
{footer && (
<footer className="card__footer">
{footer}
</footer>
)}
</section>
);
}
export { Card };
export type { CardProps };
Sign in to track your progress