Lesson 4 · Frontend for backend developers
How two components talk — and the moment where it starts to hurt.
React's data flow is one-directional, and deliberately so. Data travels down the component tree as props. Nothing travels up. When a child needs to cause a change, it calls a function its parent gave it.
┌──────────┐
│ Parent │ owns the state
└────┬─────┘
props ↓ │ ↑ callback invocation
┌────┴─────┐
│ Child │ displays it, reports intent
└──────────┘
A child receiving onToggle and calling it is dependency
injection, or a callback passed to a handler. The child does not know what
happens next and should not — it announces "the user clicked me" and the owner
of the state decides what that means.
In Lesson 3 your TaskRow held its own done state.
Now the page needs a header: 2 of 3 done.
It cannot be built. The header is a sibling, and each row's state is private to that row. There is no legal way to read it — and this is not React being obstructive. Two components need the same fact, so that fact belongs to neither.
Move the state to the nearest common ancestor and pass it back down.
src/App.tsx
type Task = { id: number; title: string; done: boolean }
export function App() {
const [tasks, setTasks] = useState<Task[]>([
{ id: 1, title: 'Write the migration', done: false },
{ id: 2, title: 'Review the PR', done: true },
])
function toggleTask(id: number) {
setTasks(tasks.map(t =>
t.id === id ? { ...t, done: !t.done } : t
))
}
const doneCount = tasks.filter(t => t.done).length
return (
<div className="p-8">
<h1 className="mb-4 font-bold">
{doneCount} of {tasks.length} done
</h1>
{tasks.map(task => (
<TaskRow key={task.id} task={task} onToggle={toggleTask} />
))}
</div>
)
}
src/components/TaskRow.tsx
type TaskRowProps = {
task: Task
onToggle: (id: number) => void
}
export function TaskRow({ task, onToggle }: TaskRowProps) {
return (
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={task.done}
onChange={() => onToggle(task.id)}
/>
<span className={task.done ? 'line-through text-slate-400' : ''}>
{task.title}
</span>
</label>
)
}
Three things changed shape, and each is a general pattern:
TaskRow lost its state entirely. It now
renders whatever it is told and reports clicks. Components like this — no
state, output determined purely by props — are the easiest kind to reason about
and to test. Prefer them.doneCount is not state. It is computed
during render from tasks. Adding a useState for it
would create a second copy of the truth that could disagree with the first —
exactly the drift from Lesson 1. If you can calculate it, don't store
it..map() with
{ ...t, done: !t.done } returns new objects rather than mutating.
tasks[0].done = true would leave the array identity unchanged, and
React — which compares by identity — would not re-render.A sidebar and a table both need the selected row's id. Where does that state belong?
Now the app grows. The signed-in user is needed in a header avatar, buried four levels down:
App owns `user`
└─ Layout takes user, uses none of it ← pass-through
└─ Sidebar takes user, uses none of it ← pass-through
└─ Header takes user, uses none of it ← pass-through
└─ Avatar finally uses it
Three components now declare a prop they do not care about, purely to relay
it. That is prop drilling. Its costs are real: adding one field
to user means touching four files, and each intermediate component's
type signature now lies about what it actually depends on.
The reflex is to reach for Context, or a global store. Hold on. Prop drilling through two or three layers is normal, healthy, and explicit — the React docs are blunt about this: "it's not unusual to pass a dozen props down through a dozen components… it makes it very clear which components use which data." Drilling is only a problem when it is deep, wide, and changes often.
Before Context, there is a cheaper fix that most people skip — and the React docs put it first for good reason. Often, deep drilling is a symptom of a missing composition, not a missing state tool.
Any component can accept JSX as a prop, via the special children
prop. Which means the parent that owns the data can build the element that needs
it, and pass it down already assembled:
// Before — Layout must know about `user` to relay it
<Layout user={user} />
// After — Layout knows nothing; it just renders a hole
<Layout>
<Header>
<Avatar user={user} /> {/* built where the data already lives */}
</Header>
</Layout>
type LayoutProps = { children: React.ReactNode }
export function Layout({ children }: LayoutProps) {
return <div className="mx-auto max-w-4xl p-6">{children}</div>
}
The drilling is gone, and no new concept was introduced. Layout
is now genuinely reusable, because it is not coupled to a user it
never used. Try this before Context, every time.
From memory: the two fixes for deeply-passed data, in the order you should try them — and the one-line reason drilling is not automatically bad.
1. Restructure with children / passing JSX, so intermediate
components never see the data. 2. Only then, Context.
Drilling is explicit: the prop types tell you exactly which components depend on which data. That readability is worth some verbosity.
Extend the task app:
App; submitting appends to tasks and clears the
input.<TaskStats> component showing
{done} / {total}. Pass it only the two numbers — not the whole
array. Ask yourself why the narrower prop type is better.<Card> component that takes
children and applies Tailwind card styling. Note that
Card needs to know nothing about tasks.