Lesson 4 · Frontend for backend developers

Props down, events up

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
        └──────────┘
In your language

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.

The problem: state in the wrong place

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.

The fix: lift the state up

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:

A sidebar and a table both need the selected row's id. Where does that state belong?

Where it starts to hurt: prop drilling

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.

Resist the obvious conclusion

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.

The fix you should try first

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.

Exercise

Extend the task app:

  1. Add an "Add task" input above the list. The input's text is state in App; submitting appends to tasks and clears the input.
  2. Add a <TaskStats> component showing {done} / {total}. Pass it only the two numbers — not the whole array. Ask yourself why the narrower prop type is better.
  3. Wrap the whole page in a <Card> component that takes children and applies Tailwind card styling. Note that Card needs to know nothing about tasks.
Ask Martin. Worth asking on the real codebase: "show me a place where we drill props, and one where we used composition instead — was it the right call?" Reading real trade-offs beats any toy example.