Skip to content
Get the beta

Search the docs

Start with the beta
Installation
Build your first page
Thinking in Turtlekit
Apps, websites and embeds
Components or native elements
Colour
Typography
Layout
Spacing, icons and shape
Components
Root
Layout
Typography
Code block
Button
Icon button
Button group
Call to action
Toolbar
Field
Text field
Text area
Select
Combobox
Date input
Save bar
Newsletter signup
File upload
Calendar
One-time code
Checkbox
Radio group
Switch
Segmented control
Toggle
Slider
Badge
Card
Data list
Item
Icon tile
Num
Time
Metric
Accordion
FAQ
Avatar
Media
Scroll area
Resizable
Carousel
Image slider
Logo cloud
Testimonials
Table
Data table
Chart
Site header
Site footer
Tabs
Page header
Breadcrumb
Pagination
Sidebar
Navigation menu
Callout
Error summary
Toast
Empty state
Skeleton
Progress
Spinner
Tooltip
Dialog
Popover
Hover card
Dropdown menu
Context menu
Menubar
Command
Elements area
Visually hidden
Error boundary
Design rules
Staying within the kit
Layout
Icons
Images and media
Typography
Readability
Colour
Composition
Accessibility
Elements
Motion
Interaction
AI pitfalls
Pricing
React design patterns
Single responsibility in React components
Presentational and container components in React
Children, function children and render props in React
The variant pattern in React
Compound components in React
Higher-order components in React
State colocation in React
Lifting state up in React
Deriving state during render
One status instead of many booleans
Adjusting state when a prop changes
Reading context with use() in React 19
Context selectors in React
Custom hooks and hook composition in React
One effect per concern in React
The latest ref pattern in React
Effect Events with useEffectEvent in React
Detecting the first render in React
Exposing methods with useImperativeHandle in React
Debouncing input in React
useDeferredValue in React
Loading components on demand in React
Virtualising long lists in React
Guard clauses and early returns in React
Skeleton placeholders in React
Rendering partial data in React
Empty states in React
Error boundaries in React
Reading async data with use() and Suspense in React
useTransition and Actions in React
Optimistic UI updates with useOptimistic
useActionState and form Actions in React
useSyncExternalStore in React
Ref as a prop and ref cleanup in React 19
Accessible ids with useId in React
Portals in React
Roving focus and keyboard navigation in React
Controlled and uncontrolled components in React
The state reducer and prop getters in React
Headless components and the provider pattern in React
The polymorphic as prop in React

Appearance

These change every page and preview on this site.

Presentational and container components in React

Turtlekit Editorial Team

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.

CustomersPageserver · linksCustomerPickerbrowser · buttonscustomers · actionCustomerListdraws what it is given
Each container decides which customers and what a row does. The list decides only how they look, so the same list serves both.

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:

What plays the container
Where the data comes fromThe container
The server, when the page is requestedA Server Component
The browser, after something people doA hook, called by a client component
Many screens at once, such as notificationsA 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.

Single responsibility in React components

When a component answers to several owners and you need to find its seams.

Children, function children and render props in React

When the container should decide what goes inside the presentational component, not only its data.

Compound components in React

When a presentational component’s parts need to share state among themselves.

The variant pattern in React

When a presentational component needs a few named looks rather than open styling.