Chapter 3: Component Composition
A React app isn't one massive component โ it's a tree of small, focused ones. This chapter teaches you the art of breaking UI apart, putting it back together, and organizing your files so you don't lose your mind at 2 AM.
๐ Prerequisites: You've completed Chapter 2 โ TaskFlow has state, event handlers, add/delete/toggle/filter functionality all working inside a growing
App.tsx.
๐ง Conceptsโ
1. Thinking in Componentsโ
The single most important React skill isn't hooks or state management โ it's knowing when and where to split components. Let's build that intuition.
The Single Responsibility Principleโ
Each component should do one thing well. If you can describe what a component does and you need the word "and," it might be doing too much:
- โ "TaskCard displays a single task"
- โ "TaskForm handles new task input"
- โ "App manages tasks AND renders the form AND renders the list AND handles filtering" โ too much
When to Splitโ
Split when you see any of these signals:
- Reusability โ You'll use this UI chunk in multiple places
- Complexity โ The component is over ~100 lines and doing many things
- Separate concerns โ Form logic shouldn't be tangled with list rendering
- State isolation โ Part of the UI has its own state that doesn't affect the rest (e.g., "is this dropdown open?")
- Performance โ A frequently updating piece can be isolated so it doesn't re-render siblings (more in Chapter 16)
When NOT to Splitโ
Don't create a component for every <div>. Premature abstraction is real:
- If it's only used once and it's simple โ inline JSX is fine
- If splitting means you need 5 props just to pass data through โ that's worse
- If the "component" is 3 lines โ a variable or helper function might be better
The gut check: "If someone new joined the team, would this file be easy to understand?" If a component is getting hard to read, split it. If it's still clear, leave it.
2. Props Drillingโ
Props drilling is when you pass data through multiple layers of components to reach a deeply nested one:
Main and TaskSection don't use the props โ they just forward them. Every new prop = edit 4 files. This is the "drilling" problem.
Every intermediate component (Main, TaskSection) must accept and pass down props it doesn't use itself.
// App โ Main โ TaskSection โ TaskList โ TaskCard
// Every layer just passing things through ๐ฉ
const Main = ({ tasks, onToggle, onDelete }: MainProps) => {
return (
<main>
<TaskSection tasks={tasks} onToggle={onToggle} onDelete={onDelete} />
</main>
);
}
When Drilling is Fineโ
- 2-3 levels deep โ totally normal, don't over-engineer
- Explicit data flow โ you can trace where data comes from
- Simple apps โ if your component tree is shallow, drilling is the simplest solution
When Drilling Becomes a Problemโ
- 5+ levels โ intermediate components are just "pass-through" wrappers
- Many props โ a component accepts 10 props just to forward most of them
- Frequent changes โ adding a new piece of data means editing 5 files
Solutions we'll learn later:
- Component composition (below) โ restructure to reduce depth
- Context API (Chapter 5) โ skip intermediate levels
- External state (Chapter 14) โ Zustand, Redux, etc.
3. The children Prop and Compositionโ
The children prop is React's most powerful composition tool. It lets a component wrap other components without knowing what's inside:
interface CardProps {
title: string;
children: React.ReactNode;
}
const Card = ({ title, children }: CardProps) => {
return (
<div className="card">
<h3>{title}</h3>
<div className="card-body">{children}</div>
</div>
);
}
// Usage โ Card doesn't need to know what's inside it
<Card title="User Info">
<p>Name: Alice</p>
<p>Email: alice@example.com</p>
</Card>
<Card title="Settings">
<SettingsForm />
</Card>
Why This Matters: Solving Props Drilling with Compositionโ
Here's the key insight. Instead of this:
// โ Drilling: App passes tasks through Layout to reach TaskList
const App = () => {
const [tasks, setTasks] = useState<Task[]>([]);
return <Layout tasks={tasks} onToggle={toggleTask} onDelete={deleteTask} />;
}
const Layout = ({ tasks, onToggle, onDelete }: LayoutProps) => {
return (
<main>
<TaskList tasks={tasks} onToggle={onToggle} onDelete={onDelete} />
</main>
);
}
Use composition:
// โ
Composition: App renders TaskList directly, Layout just wraps
const App = () => {
const [tasks, setTasks] = useState<Task[]>([]);
return (
<Layout>
<TaskList tasks={tasks} onToggle={toggleTask} onDelete={deleteTask} />
</Layout>
);
}
const Layout = ({ children }: { children: React.ReactNode }) => {
return <main className="layout">{children}</main>;
}
Layout doesn't need to know about tasks at all! It just provides structure. The data flows directly from App to TaskList, skipping Layout entirely.
This is the #1 way to avoid props drilling โ restructure your component tree so that data providers are closer to data consumers.
Slot Pattern: Multiple Children Areasโ
Sometimes a layout has multiple "slots" for different content. Use named props:
interface PageLayoutProps {
header: React.ReactNode;
sidebar: React.ReactNode;
children: React.ReactNode;
}
const PageLayout = ({ header, sidebar, children }: PageLayoutProps) => {
return (
<div className="page">
<header>{header}</header>
<aside>{sidebar}</aside>
<main>{children}</main>
</div>
);
}
// Usage
<PageLayout
header={<Header title="TaskFlow" />}
sidebar={<Sidebar filters={filters} />}
>
<TaskList tasks={tasks} />
</PageLayout>
This is the compound layout pattern โ each area is independently composable.
Render Props (Less Common Now, But Know It Exists)โ
Before hooks, render props were the main way to share logic. You might still see them in libraries:
interface MouseTrackerProps {
children: (position: { x: number; y: number }) => React.ReactNode;
}
const MouseTracker = ({ children }: MouseTrackerProps) => {
const [pos, setPos] = useState({ x: 0, y: 0 });
return (
<div onMouseMove={(e) => setPos({ x: e.clientX, y: e.clientY })}>
{children(pos)}
</div>
);
}
// Usage
<MouseTracker>
{({ x, y }) => <p>Mouse at: {x}, {y}</p>}
</MouseTracker>
Today you'd usually use a custom hook for this (Chapter 6), but the pattern is worth recognizing.
4. Component Organizationโ
As your app grows, throwing everything into src/components/ becomes chaos. Here's a practical structure for TaskFlow:
Feature-Based Structure (Recommended)โ
src/
โโโ components/ โ shared, reusable components
โ โโโ Button.tsx
โ โโโ Card.tsx
โ โโโ Layout.tsx
โโโ features/ โ feature-specific components
โ โโโ tasks/
โ โโโ TaskCard.tsx
โ โโโ TaskForm.tsx
โ โโโ TaskFilter.tsx
โ โโโ TaskList.tsx
โโโ types.ts โ shared TypeScript types
โโโ App.tsx โ root composition
โโโ main.tsx โ entry point
Rules of Thumbโ
- One component per file โ
TaskCard.tsxexportsTaskCard. Always. - Name the file after the component โ
TaskCard.tsx, nottask-card.tsxorCard1.tsx - Co-locate related files โ a component's styles, tests, and types live near it
- Shared components go in
components/โ used by multiple features - Feature components go in
features/โ specific to one feature domain - Index files are optional โ barrel exports (
index.ts) can simplify imports but slow bundling
๐ก Vercel Best Practice: Avoid barrel exports (
export * from './TaskCard'inindex.ts) for large projects. Bundlers have to parse the entire barrel to tree-shake, which slows builds. Import directly from the file:import TaskCard from '../features/tasks/TaskCard'.
Extracting Shared Componentsโ
Look for components that have no domain knowledge โ they don't know about tasks, users, or any specific feature. These belong in components/:
// src/components/Button.tsx โ generic, reusable
interface ButtonProps {
children: React.ReactNode;
variant?: 'primary' | 'secondary' | 'danger';
disabled?: boolean;
onClick?: () => void;
}
const Button = ({ children, variant = 'primary', disabled, onClick }: ButtonProps) => {
return (
<button
className={`btn btn-${variant}`}
disabled={disabled}
onClick={onClick}
>
{children}
</button>
);
}
export default Button;
This Button knows nothing about tasks. It's pure UI infrastructure.
5. TypeScript Tip: Extracting Common Typesโ
When multiple components share the same prop types, extract them:
// src/types.ts
export interface Task {
id: string;
title: string;
completed: boolean;
createdAt: Date;
}
export type Filter = 'all' | 'active' | 'completed';
// Callback types used by multiple components
export interface TaskActions {
onToggle: (id: string) => void;
onDelete: (id: string) => void;
}
Now components import what they need:
import type { Task, TaskActions } from '../types';
interface TaskCardProps extends TaskActions {
task: Task;
}
๐ก Examplesโ
Example 1: Container/Presentational Splitโ
A pattern where one component handles logic and another handles display:
// EmptyState.tsx โ presentational (just UI)
interface EmptyStateProps {
icon: string;
message: string;
action?: React.ReactNode;
}
const EmptyState = ({ icon, message, action }: EmptyStateProps) => {
return (
<div className="empty-state">
<span className="icon">{icon}</span>
<p>{message}</p>
{action ? action : null}
</div>
);
}
// Usage in TaskList โ the container decides the content
{filteredTasks.length > 0 ? (
filteredTasks.map((task) => <TaskCard key={task.id} task={task} />)
) : (
<EmptyState
icon="๐"
message="No tasks found"
action={<button onClick={onReset}>Clear filters</button>}
/>
)}
Example 2: Composition over Configurationโ
Instead of one component with 15 props ("configuration approach"), compose smaller pieces:
// โ Configuration: one mega-component
<DataTable
data={users}
columns={['name', 'email', 'role']}
sortable
filterable
paginated
pageSize={20}
onRowClick={handleClick}
emptyMessage="No users found"
loadingSpinner={<Spinner />}
headerActions={<Button>Add User</Button>}
/>
// โ
Composition: smaller, focused pieces
<DataTable data={users}>
<DataTable.Header>
<DataTable.Sort />
<DataTable.Filter />
<Button>Add User</Button>
</DataTable.Header>
<DataTable.Body
columns={['name', 'email', 'role']}
onRowClick={handleClick}
/>
<DataTable.Pagination pageSize={20} />
<DataTable.Empty>
<p>No users found</p>
</DataTable.Empty>
</DataTable>
The second approach is more flexible โ you can rearrange, remove, or add sections without changing the DataTable component's API.
Example 3: Wrapper Componentโ
// GlassCard โ adds visual styling, delegates content via children
interface GlassCardProps {
children: React.ReactNode;
className?: string;
}
const GlassCard = ({ children, className = '' }: GlassCardProps) => {
return (
<div className={`glass-card ${className}`}>
{children}
</div>
);
}
// Usage
<GlassCard>
<h3>Task Statistics</h3>
<p>12 tasks completed this week</p>
</GlassCard>
<GlassCard className="highlight">
<TaskForm onAdd={addTask} />
</GlassCard>
๐จ Project Task: Decompose TaskFlowโ
Right now, App.tsx is doing everything. Let's break it into a clean component architecture.
Target Structureโ
src/
โโโ components/
โ โโโ Layout.tsx โ app shell with header
โ โโโ EmptyState.tsx โ reusable empty state
โโโ features/
โ โโโ tasks/
โ โโโ TaskCard.tsx โ single task display
โ โโโ TaskForm.tsx โ new task input
โ โโโ TaskFilters.tsx โ filter buttons
โ โโโ TaskList.tsx โ task list with empty state
โโโ types.ts
โโโ App.tsx โ composition root
โโโ main.tsx
โโโ index.css
Step 1: Create <Layout />โ
Create src/components/Layout.tsx:
interface LayoutProps {
children: React.ReactNode;
}
const Layout = ({ children }: LayoutProps) => {
return (
<div className="app">
<header className="app-header">
<h1>๐ TaskFlow</h1>
</header>
<main>{children}</main>
</div>
);
}
export default Layout;
Notice: Layout knows nothing about tasks. It provides structure and branding. The children prop means any content can go inside.
Step 2: Create <EmptyState />โ
Create src/components/EmptyState.tsx:
interface EmptyStateProps {
icon?: string;
message: string;
}
const EmptyState = ({ icon = '๐', message }: EmptyStateProps) => {
return (
<div className="empty">
<span style={{ fontSize: '2rem' }}>{icon}</span>
<p>{message}</p>
</div>
);
}
export default EmptyState;
This is a generic empty state โ not tied to tasks. We could reuse it anywhere.
Step 3: Create <TaskList />โ
Create src/features/tasks/TaskList.tsx:
import type { Task } from '../../types';
import TaskCard from './TaskCard';
import EmptyState from '../../components/EmptyState';
interface TaskListProps {
tasks: Task[];
emptyMessage?: string;
onToggle: (id: string) => void;
onDelete: (id: string) => void;
}
const TaskList = ({ tasks, emptyMessage = 'No tasks yet.', onToggle, onDelete }: TaskListProps) => {
if (tasks.length === 0) {
return <EmptyState message={emptyMessage} />;
}
return (
<div className="task-list">
{tasks.map((task) => (
<TaskCard
key={task.id}
task={task}
onToggle={() => onToggle(task.id)}
onDelete={() => onDelete(task.id)}
/>
))}
</div>
);
}
export default TaskList;
TaskList owns the logic of "how to render a list of tasks," including the empty state. The parent doesn't need to handle that anymore.
Step 4: Move Task Componentsโ
Move your existing components into src/features/tasks/:
src/components/TaskCard.tsxโsrc/features/tasks/TaskCard.tsxsrc/components/TaskForm.tsxโsrc/features/tasks/TaskForm.tsxsrc/components/TaskFilters.tsxโsrc/features/tasks/TaskFilters.tsx
Update the imports in each file accordingly.
Step 5: Simplify App.tsxโ
Now App.tsx becomes a clean composition root โ it owns state and wires components together:
import { useState } from 'react';
import Layout from './components/Layout';
import TaskForm from './features/tasks/TaskForm';
import TaskFilters from './features/tasks/TaskFilters';
import TaskList from './features/tasks/TaskList';
import type { Task } from './types';
type Filter = 'all' | 'active' | 'completed';
const INITIAL_TASKS: Task[] = [
{ id: '1', title: 'Learn React fundamentals', completed: true, createdAt: new Date('2026-01-15') },
{ id: '2', title: 'Build TaskFlow app', completed: false, createdAt: new Date('2026-02-01') },
{ id: '3', title: 'Master TypeScript', completed: false, createdAt: new Date('2026-02-03') },
];
const App = () => {
const [tasks, setTasks] = useState<Task[]>(INITIAL_TASKS);
const [filter, setFilter] = useState<Filter>('all');
// Derived state
const filteredTasks = filter === 'all'
? tasks
: tasks.filter((t) => (filter === 'completed' ? t.completed : !t.completed));
const activeCount = tasks.filter((t) => !t.completed).length;
// Handlers
const addTask = (title: string) => {
setTasks((prev) => [
{ id: crypto.randomUUID(), title, completed: false, createdAt: new Date() },
...prev,
]);
};
const toggleTask = (id: string) => {
setTasks((prev) =>
prev.map((t) => (t.id === id ? { ...t, completed: !t.completed } : t))
);
};
const deleteTask = (id: string) => {
setTasks((prev) => prev.filter((t) => t.id !== id));
};
return (
<Layout>
<p className="task-count">
{activeCount} {activeCount === 1 ? 'task' : 'tasks'} remaining
</p>
<TaskForm onAdd={addTask} />
<TaskFilters current={filter} onChange={setFilter} />
<TaskList
tasks={filteredTasks}
emptyMessage={filter === 'all' ? 'No tasks yet. Add one!' : `No ${filter} tasks.`}
onToggle={toggleTask}
onDelete={deleteTask}
/>
</Layout>
);
}
export default App;
Look how clean this is! App is now a composition root:
- It owns the state (tasks, filter)
- It computes derived values (filteredTasks, activeCount)
- It defines handlers (addTask, toggleTask, deleteTask)
- It composes components together via
<Layout>and children
Each child component is focused and independent.
Step 6: Verifyโ
Everything should still work exactly as before:
- Add, complete, delete tasks
- Filter by all/active/completed
- Empty state shows appropriate messages
But now the code is organized, and each component has a clear purpose.
๐งช Challengeโ
-
<Header />component โ Extract the header fromLayoutinto its own component. Pass theactiveCountto it so it can display "TaskFlow โ 3 tasks remaining" in the header bar. This is a valid case for props passing (only 1 level deep). -
<TaskStats />component โ Create a component that shows a progress bar: "5/8 tasks completed (62%)". Place it between the header and the form. It receives the full task array and computes everything. -
Compound component experiment โ Create a
<Card>component that has sub-components:<Card.Header>,<Card.Body>,<Card.Footer>. WrapTaskCardin it. -
Spot the drill โ In the current architecture, how many levels deep do
onToggleandonDeletetravel? (Answer: just 2 โ App โ TaskList โ TaskCard.) When would you switch to Context? (Think about this; we'll solve it in Chapter 5.)
๐ Further Readingโ
- React docs: Thinking in React โ the definitive guide to component design
- React docs: Passing Props to a Component โ the children prop section
- React docs: Keeping Components Pure โ why purity matters
- Kent C. Dodds: Colocation โ why you should keep related files together
- Patterns.dev: Compound Pattern โ compound components deep dive
Next up: Chapter 4 โ Side Effects & Lifecycle โ
TaskFlow is well-organized but ephemeral โ refresh the page and your tasks vanish. Next, we'll learn about side effects, data persistence, and the lifecycle of components.