The new invoice dialog needed a way to pick the customer, and the Customers page already had a list of them, with search. Dropping that list into the dialog worked on the first try. Then someone clicked a name, and the browser left the half-written invoice for that customer’s page.
The list had been built for one page, and it had quietly made that page’s decisions: which customers to load, where the query lived, and what a click meant. Moved anywhere else, it brought all of them along.
Presentational and container components are the split that lets a list like this travel. A presentational component draws what it is given and reports what people do; a container decides where the data comes from and what those actions mean. Older codebases call the same pair dumb and smart components.
A list that brought its page along
Here is the list’s state, as it was when the dialog borrowed it:
const [query, setQuery] = useState("");
const [customers, setCustomers] = useState<Customer[]>([]);
useEffect(() => {
let ignore = false;
fetch(`/api/customers?q=${encodeURIComponent(query)}`)
.then((response) => response.json() as Promise<Customer[]>)
.then((data) => !ignore && setCustomers(data));
return () => {
ignore = true;
};
}, [query]);And its markup:
return (
<div>
<label htmlFor="customer-search">Search customers</label>
<input
id="customer-search"
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
<ul>
{customers.map((customer) => (
<li key={customer.id}>
<a href={`/customers/${customer.id}`}>{customer.name}</a>
<span>{customer.email}</span>
</li>
))}
</ul>
</div>
);Each row is a link to the customer’s page, because on the Customers page a click always meant “open this customer”. In the dialog it had to mean “choose this customer”, and the list had no way to be told.
The second bug was quieter. The dialog opened over the Customers page, so two inputs now had the id customer-search. The dialog’s label pointed at the first one in the document, the page’s, and clicking it put focus in the search box behind the dialog.
How it looks, and what it knows
The list knows how a customer looks. Only the screen around it knows which customers to show and what choosing one means. Split along that line, the list becomes something both screens can use, each on its own terms.
A common belief is that presentational means stateless. The line is about knowledge rather than state: a presentational component may remember which row is expanded or whether a menu is open, facts about itself. What it never holds is a fact about the world, such as which customers exist or what the server said.
One list, two owners
The fetch moves into api.ts, as a searchCustomers function that the server and the browser can both call. The rest takes four steps.
The list takes its customers and its row action as props
Look at the type of action. It is a union, so a caller either gives an address for each row or a function to call, and never both:
// The customer list as people see it. It renders the customers it is
// given, and what a row does is the caller's decision. No directive:
// it runs on the server or in the browser, wherever it is imported.
import type { Customer, CustomersState } from "./customer";
export type RowAction =
| { kind: "open"; href: (customer: Customer) => string }
| { kind: "choose"; onChoose: (customer: Customer) => void };
interface CustomerListProps {
state: CustomersState;
action: RowAction;
}
const open = (count: number) =>
count === 1 ? "1 open invoice" : `${count} open invoices`;
export function CustomerList({ state, action }: CustomerListProps) {
if (state.status === "loading") return <p>Loading customers…</p>;
if (state.status === "failed")
return <p role="alert">Customers couldn’t load.</p>;
if (state.customers.length === 0) return <p>No customers match.</p>;
return (
<ul>
{state.customers.map((customer) => (
<li key={customer.id}>
{action.kind === "open" ? (
<a href={action.href(customer)}>{customer.name}</a>
) : (
<button
type="button"
onClick={() => action.onChoose(customer)}
>
{customer.name}
</button>
)}
<span>{customer.email}</span>
<span>{open(customer.openInvoices)}</span>
</li>
))}
</ul>
);
}The union decides the element as well as the behaviour. A row that goes somewhere renders a link, which people can open in a new tab or copy; a row that does something renders a button, which keeps focus inside the dialog. The list still owns its loading, failed and empty looks, because those are about how it appears.
The data moves into a hook
The browser’s side of the loading becomes a hook that reports one status:
"use client";
// The customers that match a search. While the next matches load, the
// last ones stay, so the list doesn't flash empty on every keystroke.
import { useEffect, useState } from "react";
import { searchCustomers } from "./api";
import type { CustomersState } from "./customer";
export function useCustomers(query: string): CustomersState {
const [state, setState] = useState<CustomersState>({
status: "loading",
});
useEffect(() => {
let ignore = false;
searchCustomers(query).then(
(customers) =>
!ignore && setState({ status: "ready", customers }),
() => !ignore && setState({ status: "failed" }),
);
return () => {
ignore = true;
};
}, [query]);
return state;
}While a new search is in flight, the hook keeps the last matches on screen. Typing “tom” fires a search for each letter, and the list moves from all customers to Tomás Ortega without flashing a loading line in between. The ignore flag drops any answer that arrives after a newer search has started.
The picker is a container in the browser
The dialog’s container owns the query, calls the hook and decides that choosing a customer hands it to the dialog:
"use client";
// The new invoice dialog's customer picker: a container in the
// browser. It owns the query and what choosing means; the list only
// draws.
import { useId, useState } from "react";
import type { Customer } from "./customer";
import { CustomerList } from "./customer-list";
import { useCustomers } from "./use-customers";
export function CustomerPicker({
onChoose,
}: {
onChoose: (customer: Customer) => void;
}) {
const searchId = useId();
const [query, setQuery] = useState("");
const state = useCustomers(query);
const count =
state.status === "ready" ? state.customers.length : null;
const matches =
count === 1 ? "customer matches" : "customers match";
return (
<div>
<label htmlFor={searchId}>Search customers</label>
<input
id={searchId}
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
<p role="status">
{count === null ? "" : `${count} ${matches}`}
</p>
<CustomerList
state={state}
action={{ kind: "choose", onChoose }}
/>
</div>
);
}The search box’s id comes from useId, so the dialog over the page, or two pickers on one screen, each get their own. Clicking the label focuses the picker’s input, wherever the dialog opens. The status line under the search box exists from the first render, so each new count, “1 customer matches”, is read out as the results change.
The page is a container on the server
The Customers page doesn’t need the hook at all. As a Server Component it reads the matches before any HTML is sent, and renders the same list with links:
// The Customers page as a Server Component: a container on the
// server. It reads the matches before any HTML is sent, and the
// search is a plain form, so it works before the page's script has
// loaded.
import { searchCustomers } from "./api";
import { CustomerList } from "./customer-list";
export async function CustomersPage({ query }: { query: string }) {
const customers = await searchCustomers(query);
return (
<main>
<h1>Customers</h1>
<form role="search" action="/customers">
<label htmlFor="customer-search">Search customers</label>
<input
id="customer-search"
name="q"
type="search"
defaultValue={query}
/>
<button type="submit">Search</button>
</form>
<CustomerList
state={{ status: "ready", customers }}
action={{
kind: "open",
href: (customer) => `/customers/${customer.id}`,
}}
/>
</main>
);
}The search is a plain form that sends q in the address, so the page searches and every result is a working link before any script has loaded. The dialog and the page now share the part they actually have in common, which is how a customer looks.
Where the line sits in a React 19 app
The pattern was first described when a container had to be a component. Hooks and Server Components have changed what a container is, and the presentational side has become more useful as a result.
The container can be a hook, or the server
A component that only calls a hook and passes its result on adds a layer and nothing else; call the hook where the data is used. The picker earns its place as a component because it also renders the search field. Where the data comes from usually decides what the container is:
| Where the data comes from | The container |
|---|---|
| The server, when the page is requested | A Server Component |
| The browser, after something people do | A hook, called by a client component |
| Many screens at once, such as notifications | A store, read through a hook |
Re-renders stay inside the container
Typing in the picker re-renders the picker and its list, never the dialog’s other fields, because the query lives in the picker. The inline action object is new on every render, which would defeat memo on the list. The React Compiler keeps such objects stable for you; without it, a list of this size renders fast enough that it doesn’t matter.
Links go somewhere, buttons do something
In the dialog, a link would be the wrong element even if it pointed somewhere harmless. Choosing is an action, and a screen reader announces a link as a way to leave. The union picks the element from what the row does, so each screen gets the right one by construction. The duplicate id was a smaller version of the same problem: a component that can be placed twice must not hard-code anything that has to be unique on the page.
Most of the tests need no network
The list renders from plain arrays, so its contract is tested by role with no mocks. Only the picker’s test replaces searchCustomers, and it does so at the module that owns the API:
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, test, vi } from "vitest";
import { searchCustomers } from "./api";
import type { Customer } from "./customer";
import { CustomerList } from "./customer-list";
import { CustomerPicker } from "./customer-picker";
vi.mock("./api", () => ({ searchCustomers: vi.fn() }));
const PRIYA: Customer = {
id: "c1",
name: "Priya Raman",
email: "priya@raman.example",
openInvoices: 2,
};
const TOMAS: Customer = {
id: "c2",
name: "Tomás Ortega",
email: "tomas@ortega.example",
openInvoices: 1,
};
test("on the Customers page, each customer is a link", () => {
const href = (customer: Customer) => `/customers/${customer.id}`;
render(
<CustomerList
state={{ status: "ready", customers: [PRIYA] }}
action={{ kind: "open", href }}
/>,
);
expect(
screen.getByRole("link", { name: "Priya Raman" }),
).toHaveAttribute("href", "/customers/c1");
expect(screen.queryByRole("button")).toBeNull();
});
test("in the picker, a choice goes to the dialog", async () => {
const user = userEvent.setup();
vi.mocked(searchCustomers).mockImplementation(async (query) =>
[PRIYA, TOMAS].filter((customer) =>
customer.name.toLowerCase().includes(query.toLowerCase()),
),
);
const onChoose = vi.fn();
render(<CustomerPicker onChoose={onChoose} />);
await user.type(
screen.getByRole("searchbox", { name: "Search customers" }),
"tom",
);
await user.click(
await screen.findByRole("button", { name: "Tomás Ortega" }),
);
expect(screen.getByRole("status")).toHaveTextContent(
"1 customer matches",
);
expect(onChoose).toHaveBeenCalledWith(TOMAS);
expect(screen.queryByRole("link")).toBeNull();
});Both pass. The first proves there is no button on the page; the second, no link in the dialog.
A list with no directive runs on either side
customer-list.tsx has no "use client". React decides by usage: imported by the Server Component, it renders on the server and sends HTML; imported by the picker, it runs in the browser. The page can pass it an href function because the call never leaves the server.
The picker’s onChoose could not come from a Server Component. Props that cross from server to client must be serialisable, and a function qualifies only when it is a Server Function. TypeScript won’t catch it, because the types are identical on both sides; React reports it when the page renders.
From React 18 to 19
Without Server Components, as in most React 18 apps, the page is a client container like the picker, and the list is unchanged. React 19 adds one more option: a Server Component can start the request and pass the promise itself to a client component, which reads it with use inside Suspense. The container then hands over data that is still arriving.
Deep dive
Why does the list take a status instead of a loading prop and a customers prop? Two props allow combinations nobody designed, such as loading with a full list. One status makes the three looks exclusive, and the server page states its case plainly: { status: "ready", customers }.
When the split is only ceremony
A component used once, whose data and look change together, gains nothing from a pair. The account page’s plan summary, read in one place and designed with its data, is clearer as one component than as a container passing six props to a presentation it will never share.
Split when a second screen needs the look without the data, as the dialog did, or when the server and the browser need to own different halves. Until then, keep the data access in a hook, which leaves the split one small change away.
How Turtlekit’s toasts divide the work
Turtlekit’s Toast is split in three. The toast store holds which messages exist; ToastViewport places them on screen, times them and pauses them while the pointer or focus is on one; Toast draws a single message from its props. <ToastProvider> goes once around the app, and any code can then add a message. The sample shows that container side; the viewport draws each message with Toast on its own:
// Archiving a customer with Turtlekit's toasts. The store holds the
// message, the viewport times and places it, and Toast draws it.
import { Button, toast } from "turtlekit";
import type { Customer } from "./customer";
interface ArchiveCustomerProps {
customer: Customer;
onArchive: (id: string) => void;
onRestore: (id: string) => void;
}
export function ArchiveCustomer({
customer,
onArchive,
onRestore,
}: ArchiveCustomerProps) {
function archive() {
onArchive(customer.id);
toast.success(`${customer.name} moved to Archive.`, {
action: {
label: "Undo",
onClick: () => onRestore(customer.id),
},
});
}
return (
<Button
variant="soft"
onClick={archive}
aria-label={`Archive ${customer.name}`}
>
Archive
</Button>
);
}The button never renders a toast, and the toast never knows about customers. Undo restores the customer, and a message with an action stays until people close it, so nobody has to catch it before it disappears.
Built on this pattern in Turtlekit: Accordion, Badge, Breadcrumb, Button group, Checkbox, Elements area, Icon tile, Item, Num, Page header, Pagination, Scroll area, Sidebar, Spinner, Switch, Text area, Toast, Typography and Visually hidden.
When a shared component won’t share
These usually mean a presentational component still holds a decision that belongs to its owner.
Clicking a row does the wrong thing on one screen
The component chose its own action. Take the action as a prop, and make its type say whether the row is a link or a button.
A label focuses an input somewhere else on the page
Two copies of the component share a hard-coded id. Generate ids with useId, or take them as props.
“Functions cannot be passed directly to Client Components”
A Server Component passed a handler to a client one. Pass data instead, such as an address, or move the container that owns the handler into the client.
The reusable half
The most useful thing the split does is find the part of a feature that can travel. A list that knows only how customers look is correct wherever it sits: on a page rendered on the server, in a dialog in the browser, or in a test with three made-up customers. Everything that makes a screen particular stays with the screen, and that is where a reader of the code will look for it.