Components & props
Props as a one-way contract, defaults and destructuring, composition patterns, and why purity is a requirement rather than a style.
Props flow down, one way
A component receives a single object — props — and must treat it as read-only.
type BadgeProps = {
label: string
count?: number
tone?: "info" | "warn" | "danger"
}
function Badge({ label, count = 0, tone = "info" }: BadgeProps) {
return (
<span>
[{tone}] {label}
{count > 0 ? ` (${count})` : ""}
</span>
)
}
export default function App() {
return (
<ul>
<li>
<Badge label="Inbox" count={4} />
</li>
<li>
<Badge label="Archive" />
</li>
<li>
<Badge label="Errors" count={2} tone="danger" />
</li>
</ul>
)
}Destructuring in the signature gives you defaults, and documents the component's interface in one line.
⚠️ Never write props.count = 5. Props belong to the parent: mutating them changes an object React may still be comparing against, and the change will not trigger a render. If a child needs to change something, the parent passes a function down.
Passing data back up
function Row({ name, onSelect }: { name: string; onSelect: (name: string) => void }) {
return (
<li>
<button onClick={() => onSelect(name)}>{name}</button>
</li>
)
}
export default function App() {
// In a real app this would be state; here it shows the shape.
const handleSelect = (name: string) => {
console.log("selected", name)
}
return (
<ul>
{["Ada", "Grace", "Linus"].map((n) => (
<Row key={n} name={n} onSelect={handleSelect} />
))}
</ul>
)
}Data flows down as props; events flow up as callbacks. There is no other channel, which is what makes a React tree possible to reason about.
Spreading props
type InputProps = React.ComponentPropsWithoutRef<"input"> & { label: string }
function Field({ label, id, ...rest }: InputProps) {
return (
<p>
<label htmlFor={id}>{label} </label>
<input id={id} {...rest} />
</p>
)
}
export default function App() {
return (
<form>
<Field label="Name" id="name" defaultValue="Ada" />
<Field label="Email" id="email" type="email" placeholder="you@example.com" />
<Field label="Age" id="age" type="number" defaultValue={36} readOnly />
</form>
)
}React.ComponentPropsWithoutRef<"input"> gives you every real <input> prop, correctly typed, so your wrapper does not have to re-declare them.
💡 Tip: {...rest} is right for a thin wrapper around a host element. It is wrong for a domain component: <UserCard {...user} /> hides what the component actually needs, and a typo in the caller becomes a silently missing prop rather than an error.
Composition beats configuration
Two ways to make a component flexible. The second scales better.
// Configuration: every new variation needs a new prop and a new branch.
function ConfigCard({ title, body, footer }: { title: string; body: string; footer?: string }) {
return (
<section>
<h3>{title}</h3>
<p>{body}</p>
{footer ? <small>{footer}</small> : null}
</section>
)
}
// Composition: the caller decides what goes inside.
function Card({ children }: { children: React.ReactNode }) {
return <section>{children}</section>
}
export default function App() {
return (
<>
<ConfigCard title="Configured" body="Limited to what the props allow." footer="a footer" />
<Card>
<h3>Composed</h3>
<p>Anything at all can go here —</p>
<ul>
<li>a list,</li>
<li>a form,</li>
<li>another Card.</li>
</ul>
</Card>
</>
)
}🧭 Scenario: A <Modal> grows showCloseButton, closeButtonLabel, headerAlign, hideHeader, footerButtons… Each one is a branch inside the component and a decision the caller has to look up. Replacing them with children and a couple of named slots removes all of it — the caller writes the markup it wants, and the modal only handles the behaviour that is genuinely modal.
Slots
type PageProps = {
header: React.ReactNode
sidebar?: React.ReactNode
children: React.ReactNode
}
function Page({ header, sidebar, children }: PageProps) {
return (
<div>
<header>{header}</header>
<div>
{sidebar ? <aside>{sidebar}</aside> : null}
<main>{children}</main>
</div>
</div>
)
}
export default function App() {
return (
<Page
header={<h1>Dashboard</h1>}
sidebar={
<nav>
<a href="#a">One</a>
</nav>
}
>
<p>Main content goes in children.</p>
</Page>
)
}Purity is a requirement
Given the same props, a component must return the same JSX and change nothing outside itself.
// Impure: mutates something that existed before the render.
let renderCount = 0
function Impure() {
renderCount++ // ← a side effect during render
return <p>rendered {renderCount} times</p>
}
// Pure: everything it touches was created during this render.
function Pure({ items }: { items: string[] }) {
const upper = items.map((i) => i.toUpperCase()) // a NEW array, fine
return <p>{upper.join(", ")}</p>
}
export default function App() {
return (
<div>
<Impure />
<Pure items={["ada", "grace"]} />
</div>
)
}Where the boundaries go
A component should have one reason to change. When a component starts needing a paragraph to describe, it is two components.
type Person = { name: string; role: string; online: boolean }
function Status({ online }: { online: boolean }) {
return <span>{online ? "●" : "○"}</span>
}
function PersonRow({ person }: { person: Person }) {
return (
<li>
<Status online={person.online} /> <strong>{person.name}</strong> — {person.role}
</li>
)
}
function PersonList({ people }: { people: Person[] }) {
return (
<ul>
{people.map((p) => (
<PersonRow key={p.name} person={p} />
))}
</ul>
)
}
export default function App() {
const people: Person[] = [
{ name: "Ada", role: "engineer", online: true },
{ name: "Grace", role: "admiral", online: false },
]
return (
<section>
<h2>Team</h2>
<PersonList people={people} />
</section>
)
}Splitting is cheap — a component is a function — and it is the main tool for keeping re-renders local, which the performance chapter builds on.
Reference: Passing Props to a Component and Keeping Components Pure on react.dev.