Composition Patterns
By the end of this lesson
Share behaviour without prop drilling or premature abstraction.
Two problems appear as an application grows. The same behaviour is needed in several places, and a value known at the top of the tree is needed near the bottom.
React offers a few tools for each: children and slot props for arranging markup, custom hooks for sharing logic, and context for values genuinely needed across a subtree. The tools are straightforward. Knowing which problem you have — and whether you have one yet — is the harder part.
import type { ReactNode } from "react";
interface ListScreenProps {
heading: string;
/** Slot props: the caller supplies the markup, this component places it. */
filters?: ReactNode;
actions?: ReactNode;
children: ReactNode;
}
export function ListScreen({ heading, filters, actions, children }: ListScreenProps) {
return (
<section className="space-y-4">
<div className="flex items-baseline justify-between gap-4">
<h1 className="text-2xl font-semibold">{heading}</h1>
{actions}
</div>
{filters && <div className="rounded-xl border p-4">{filters}</div>}
{children}
</section>
);
}
// The employee directory and the course catalogue share the layout
// without sharing anything about employees or courses.
export function EmployeesPage() {
return (
<ListScreen
heading="Employee directory"
filters={<EmployeeFilters />}
actions={<AddEmployeeButton />}
>
<EmployeeTable employees={employees} />
</ListScreen>
);
}- filters and actions are slots. They are props whose value is markup, which works because markup is a value — the JSX lesson's point, applied.
- ListScreen decides where things go and how much space they get. It has no idea what an employee is, which is why the course catalogue can use it unchanged.
- The alternative would be props like showFilters, filterFields, actionLabel and onActionClick, each one a decision ListScreen would have to make on behalf of its callers. Every new requirement would add another.
- filters is optional and guarded, so a screen with no filters gets no empty bordered box.
The same requirement — a panel that sometimes shows a footer with buttons — done two ways:
| Configuration props | Composition | |
|---|---|---|
| The interface | footerButtonLabel, onFooterClick, showSecondaryButton, secondaryLabel... | footer?: ReactNode |
| Adding a link instead of a button | A new prop, and a branch inside the panel | The caller passes a link. The panel does not change |
| Who owns the decisions | The panel, for every caller | Each caller, for itself |
| What the panel knows | Every variation anyone has ever needed | That there is a footer area and how it is spaced |
| When configuration is the better choice | The variations are genuinely fixed and few, and consistency matters more than flexibility — a design system button with three sizes | The content varies by screen and you cannot enumerate the cases |
"use client";
import { useState } from "react";
/** Sorting behaviour needed by the employee table and the course table. */
export function useSortedBy<T>(items: T[], initialKey: keyof T) {
const [sortKey, setSortKey] = useState<keyof T>(initialKey);
const [isAscending, setIsAscending] = useState(true);
const sorted = [...items].sort((a, b) => {
const left = String(a[sortKey]);
const right = String(b[sortKey]);
return isAscending ? left.localeCompare(right) : right.localeCompare(left);
});
function toggleSort(key: keyof T) {
if (key === sortKey) {
setIsAscending((current) => !current);
return;
}
setSortKey(key);
setIsAscending(true);
}
return { sorted, sortKey, isAscending, toggleSort };
}- The hook holds the two pieces of state and the rule for changing them. Any component can use it, and each gets its own independent sort.
- It is generic over T, so the same hook sorts employees and courses. localeCompare is used rather than the subtraction trick, because these are strings and the default sort would order them by code point.
- The array is copied before sorting. sort mutates, and the input here is a prop — sorting it in place would modify the caller's data, which the props lesson explained is a bug.
- Note what the hook does not do: it renders nothing and knows nothing about tables. Logic in a hook, markup in a component. That split is why both can be reused separately.
"use client";
import { createContext, useContext } from "react";
interface CurrentUser {
id: string;
name: string;
canEditEmployees: boolean;
}
const CurrentUserContext = createContext<CurrentUser | null>(null);
export function CurrentUserProvider({
user,
children,
}: {
user: CurrentUser;
children: React.ReactNode;
}) {
return <CurrentUserContext.Provider value={user}>{children}</CurrentUserContext.Provider>;
}
/** Throws rather than returning null, so a component cannot silently
render as though nobody is signed in. */
export function useCurrentUser(): CurrentUser {
const user = useContext(CurrentUserContext);
if (!user) {
throw new Error("useCurrentUser must be used inside a CurrentUserProvider");
}
return user;
}
// Any depth below the provider, with no props threaded through:
function EmployeeActions({ employee }: { employee: Employee }) {
const user = useCurrentUser();
if (!user.canEditEmployees) return null;
return <EditEmployeeButton employee={employee} />;
}- createContext makes the channel. The provider supplies a value to everything rendered inside it, and useContext reads the nearest one above.
- The custom hook wrapper does two useful things: it gives the context a name components use instead of importing the context object, and it turns a missing provider into a clear error instead of a null that spreads through the code.
- This is a reasonable use of context. The signed-in user is needed at many depths, is not specific to any one screen, and changes rarely.
- The value is an object created by the caller. If it were built inline in the provider's own render, every render would produce a new object and every consumer would re-render — the identity comparison again.
Summary
- Composition — children and slot props — lets a component arrange content it knows nothing about
- Prefer accepting markup over adding a configuration prop for every variation
- Custom hooks share behaviour; components share structure; keep the two separate
- Context suits values needed widely and changing rarely, and costs visibility plus wider re-rendering
- Prop drilling two levels is usually better than context, and restructuring often removes the choice
- Wait for the third occurrence before abstracting; two similar things are often just two things
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Try it yourself
Try it yourself
You have an EmployeeTable and a CourseTable. Both need sortable column headers with an accessible sort indicator.
Share that behaviour without making one table know about the other's data. Decide what belongs in a hook and what belongs in a component.
Show solution
The hook owns the sort state and the ordering rule. A small SortableHeader component owns the button, the indicator and the accessibility attributes. Neither knows anything about employees or courses, so both tables use both.
aria-sort is the part that is easy to miss. It tells a screen reader which column is sorted and in which direction, which is information sighted users get from an arrow glyph. The arrow itself is marked aria-hidden, since the attribute already conveys it.
The header is a button inside the th rather than a click handler on the th. That gives keyboard operation and the correct announcement for free — the reason is the same one the accessibility lesson makes about divs with onClick.
Resisting a shared SortableTable component is deliberate. The two tables have different columns, different cell rendering and different empty states, so a component covering both would need configuration props for all of it. Sharing the behaviour and the header, and letting each table keep its own markup, is less code and less coupling.
interface SortableHeaderProps<T> {
label: string;
columnKey: keyof T;
activeKey: keyof T;
isAscending: boolean;
onSort: (key: keyof T) => void;
}
export function SortableHeader<T>({
label,
columnKey,
activeKey,
isAscending,
onSort,
}: SortableHeaderProps<T>) {
const isActive = columnKey === activeKey;
return (
<th
scope="col"
aria-sort={isActive ? (isAscending ? "ascending" : "descending") : "none"}
>
<button type="button" onClick={() => onSort(columnKey)}>
{label}
<span aria-hidden="true">{isActive ? (isAscending ? " ↑" : " ↓") : ""}</span>
</button>
</th>
);
}
// Each table keeps its own markup and shares the behaviour:
function EmployeeTable({ employees }: { employees: Employee[] }) {
const { sorted, sortKey, isAscending, toggleSort } = useSortedBy(employees, "name");
return (
<table>
<thead>
<tr>
<SortableHeader
label="Name"
columnKey="name"
activeKey={sortKey}
isAscending={isAscending}
onSort={toggleSort}
/>
<SortableHeader
label="Department"
columnKey="department"
activeKey={sortKey}
isAscending={isAscending}
onSort={toggleSort}
/>
</tr>
</thead>
<tbody>
{sorted.map((employee) => (
<tr key={employee.id}>
<td>{employee.name}</td>
<td>{employee.department}</td>
</tr>
))}
</tbody>
</table>
);
}Think about it
Think about it
A directory page passes selectedDepartment through four components to reach a filter chip at the bottom. Two of the four do not use it.
Weigh three options: keep the prop, introduce context, or restructure so the chip is passed in as children from the top. What would decide it?
Show solution
Restructuring is worth considering first, and is the option most often skipped. If the page renders the chip itself and passes it down as children, the two uninterested components go back to accepting ReactNode and knowing nothing about departments. No context, no threading, and the dependency is visible at the top where the value lives.
Context is the right answer when several unrelated components at various depths need the value, or when the tree shape means composition cannot reach them. It buys convenience and costs traceability, so it is worth it when there are many consumers rather than one.
Keeping the prop is defensible too. Four levels is annoying rather than unworkable, and the alternative adds a provider, a hook and a rule that the chip can only be rendered inside it.
What decides it: how many components need the value, whether they can be composed from above, how often the value changes, and whether you want the chip to be renderable in isolation — in a test, or on another page. If the answer to the last is yes, context makes that harder and the prop makes it trivial.
Saved in this browser only.