Type something to search...
The Container/Presentational Pattern in Modern React

The Container/Presentational Pattern in Modern React

You've probably seen a component like this: it fetches data in an effect, tracks loading and error state, handles pagination, formats dates, filters by a search term, and then renders 150 lines of JSX. It works, but it's hard to test because you have to mock fetch to see a single button. It's hard to reuse because the markup is welded to one API endpoint. And it's hard to put in Storybook because it can't render without a network.

The container/presentational pattern addresses this by splitting such a component in two. A container knows where data comes from and what happens when the user acts. A presentational component receives data and callbacks through props and only decides how things look. Dan Abramov popularized the idea in 2015, and later wrote that he no longer recommends splitting components this way dogmatically, because hooks made it possible to reuse logic without the split.

So is the pattern still useful? Yes, if you apply it deliberately rather than everywhere. In this post I'll show the pattern with modern tools: custom hooks, TanStack Query, and React Server Components. I'll also cover how it improves testing and Storybook stories, and when it's just extra files.

The Idea in One Example

Here's the "everything in one component" version of an orders list:

import { useEffect, useState } from "react";

type Order = { id: string; customer: string; total: number; status: "paid" | "pending" | "refunded"; createdAt: string };

export function OrdersPage() {
  const [orders, setOrders] = useState<Order[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  const [status, setStatus] = useState<Order["status"] | "all">("all");

  useEffect(() => {
    setLoading(true);
    fetch(`/api/orders?status=${status}`)
      .then((r) => r.json())
      .then((data: Order[]) => setOrders(data))
      .catch(() => setError("Could not load orders"))
      .finally(() => setLoading(false));
  }, [status]);

  if (loading) return <p>Loading...</p>;
  if (error) return <p role="alert">{error}</p>;

  return (
    <section>
      <select value={status} onChange={(e) => setStatus(e.target.value as typeof status)}>
        <option value="all">All</option>
        <option value="paid">Paid</option>
        <option value="pending">Pending</option>
        <option value="refunded">Refunded</option>
      </select>
      <table>
        <tbody>
          {orders.map((o) => (
            <tr key={o.id}>
              <td>{o.customer}</td>
              <td>${o.total.toFixed(2)}</td>
              <td>{o.status}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </section>
  );
}

Fetching, filtering state, error handling, and markup are all in one place. Now split it.

The Presentational Component

The presentational component gets everything through props. It has no idea there's an API:

// OrdersTable.tsx
type OrderStatus = "paid" | "pending" | "refunded";

export type Order = {
  id: string;
  customer: string;
  total: number;
  status: OrderStatus;
  createdAt: string;
};

type OrdersTableProps = {
  orders: Order[];
  statusFilter: OrderStatus | "all";
  onStatusFilterChange: (status: OrderStatus | "all") => void;
  onRefund: (id: string) => void;
  refundingId?: string | null;
};

const currency = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });

export function OrdersTable({
  orders,
  statusFilter,
  onStatusFilterChange,
  onRefund,
  refundingId = null,
}: OrdersTableProps) {
  return (
    <section>
      <label>
        Status{" "}
        <select
          value={statusFilter}
          onChange={(e) => onStatusFilterChange(e.target.value as OrderStatus | "all")}
        >
          <option value="all">All</option>
          <option value="paid">Paid</option>
          <option value="pending">Pending</option>
          <option value="refunded">Refunded</option>
        </select>
      </label>

      {orders.length === 0 ? (
        <p>No orders match this filter.</p>
      ) : (
        <table>
          <thead>
            <tr>
              <th>Customer</th>
              <th>Total</th>
              <th>Status</th>
              <th />
            </tr>
          </thead>
          <tbody>
            {orders.map((o) => (
              <tr key={o.id}>
                <td>{o.customer}</td>
                <td>{currency.format(o.total)}</td>
                <td>{o.status}</td>
                <td>
                  {o.status === "paid" && (
                    <button onClick={() => onRefund(o.id)} disabled={refundingId === o.id}>
                      {refundingId === o.id ? "Refunding..." : "Refund"}
                    </button>
                  )}
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      )}
    </section>
  );
}

It can still have UI state, like whether a dropdown is open. The rule isn't "no state", it's "no knowledge of where data comes from or where actions go."

The Container

The container wires real data and actions to those props. With TanStack Query, it's short:

// OrdersContainer.tsx
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { OrdersTable, type Order } from "./OrdersTable";

type Filter = Order["status"] | "all";

async function fetchOrders(status: Filter): Promise<Order[]> {
  const res = await fetch(`/api/orders?status=${status}`);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json();
}

async function refundOrder(id: string): Promise<void> {
  const res = await fetch(`/api/orders/${id}/refund`, { method: "POST" });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
}

export function OrdersContainer() {
  const [status, setStatus] = useState<Filter>("all");
  const queryClient = useQueryClient();

  const ordersQuery = useQuery({
    queryKey: ["orders", status],
    queryFn: () => fetchOrders(status),
  });

  const refund = useMutation({
    mutationFn: refundOrder,
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ["orders"] }),
  });

  if (ordersQuery.isPending) return <p>Loading orders...</p>;
  if (ordersQuery.isError) return <p role="alert">Could not load orders.</p>;

  return (
    <OrdersTable
      orders={ordersQuery.data}
      statusFilter={status}
      onStatusFilterChange={setStatus}
      onRefund={(id) => refund.mutate(id)}
      refundingId={refund.isPending ? refund.variables : null}
    />
  );
}

The container has almost no markup. Its job is translation: query results become props, user intents become mutations. If you're new to the query and mutation APIs used here, managing server state with TanStack Query covers them in detail.

The Hooks-Era Version: Hook as Container

Abramov's later point was that hooks can do the container's job without a separate component. You can move the container logic into a custom hook and call it from the component that renders:

// useOrders.ts
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { Order } from "./OrdersTable";

type Filter = Order["status"] | "all";

export function useOrders() {
  const [status, setStatus] = useState<Filter>("all");
  const queryClient = useQueryClient();

  const query = useQuery({
    queryKey: ["orders", status],
    queryFn: async (): Promise<Order[]> => {
      const res = await fetch(`/api/orders?status=${status}`);
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      return res.json();
    },
  });

  const refund = useMutation({
    mutationFn: async (id: string) => {
      const res = await fetch(`/api/orders/${id}/refund`, { method: "POST" });
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
    },
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ["orders"] }),
  });

  return {
    query,
    status,
    setStatus,
    refund: refund.mutate,
    refundingId: refund.isPending ? refund.variables : null,
  };
}

Then the page becomes:

export function OrdersPage() {
  const { query, status, setStatus, refund, refundingId } = useOrders();

  if (query.isPending) return <p>Loading orders...</p>;
  if (query.isError) return <p role="alert">Could not load orders.</p>;

  return (
    <OrdersTable
      orders={query.data}
      statusFilter={status}
      onStatusFilterChange={setStatus}
      onRefund={refund}
      refundingId={refundingId}
    />
  );
}

This is still the container/presentational split, just with the logic packaged as a hook. The important separation, data and actions versus rendering, is preserved. Whether OrdersPage counts as a "container" is a naming question. What matters is that OrdersTable is pure UI.

Server Components: The Pattern, Built In

React Server Components give the pattern a new shape. A server component can fetch data directly, with no effect, no loading state in the client bundle, and no API layer if it can read from the database. It then passes plain data to a client component that handles interactivity.

In a Next.js App Router project, it looks like this:

// app/orders/page.tsx (Server Component)
import { db } from "@/lib/db";
import { OrdersTable } from "./orders-table";

export default async function OrdersPage() {
  const orders = await db.order.findMany({
    orderBy: { createdAt: "desc" },
    take: 50,
  });

  return <OrdersTable orders={orders.map((o) => ({ ...o, createdAt: o.createdAt.toISOString() }))} />;
}
// app/orders/orders-table.tsx (Client Component)
"use client";

import { useState } from "react";

type Order = { id: string; customer: string; total: number; status: string; createdAt: string };

export function OrdersTable({ orders }: { orders: Order[] }) {
  const [query, setQuery] = useState("");
  const visible = orders.filter((o) => o.customer.toLowerCase().includes(query.toLowerCase()));

  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search customer" />
      <ul>
        {visible.map((o) => (
          <li key={o.id}>
            {o.customer}: {o.total}
          </li>
        ))}
      </ul>
    </>
  );
}

The server component is the container. The client component is presentational plus local UI state. The boundary between them is enforced by the framework: only serializable props can cross it, which is why createdAt is converted to a string. This is arguably the strongest form of the pattern, because the container's code never ships to the browser. The db import in this example stands for whatever data layer your app uses.

Why the Split Helps

Testing Gets Simpler

Presentational components are pure functions of props, so tests don't need network mocks:

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { vi } from "vitest";
import { OrdersTable, type Order } from "./OrdersTable";

const orders: Order[] = [
  { id: "1", customer: "Ada", total: 120, status: "paid", createdAt: "2026-09-01" },
  { id: "2", customer: "Grace", total: 45, status: "pending", createdAt: "2026-09-02" },
];

it("only offers refunds for paid orders", async () => {
  const onRefund = vi.fn();
  render(
    <OrdersTable orders={orders} statusFilter="all" onStatusFilterChange={vi.fn()} onRefund={onRefund} />,
  );

  const buttons = screen.getAllByRole("button", { name: "Refund" });
  expect(buttons).toHaveLength(1);

  await userEvent.click(buttons[0]);
  expect(onRefund).toHaveBeenCalledWith("1");
});

The container or hook gets its own tests with MSW mocking the API, and there are far fewer of them because they don't care about markup.

Storybook Stories Are Trivial

A presentational component can be shown in every state just by changing props: empty list, one refund in progress, long customer names. No mock server, no providers. This is a big part of why design systems lean on presentational components.

Reuse Across Data Sources

The same OrdersTable can render orders from the main API, a customer's order history page, or a CSV import preview. Only the container changes.

When Not to Split

The pattern has costs: more files, more props to pass, and an extra jump when reading code. Skip it when:

  • The component is small and used once. A 40-line component that fetches and renders a list doesn't need two files.
  • The "presentational" part would just forward everything. If the container passes twelve props straight through, the boundary isn't adding clarity.
  • You're splitting by habit. Abramov's own caveat was that the pattern became dogma. Use it where testing, reuse, or Storybook actually benefit.

A good heuristic: split when you want to render the UI without its data source, in a test, a story, or another page. Otherwise, a custom hook inside one component is enough.

Best Practices for Containers and Presentational Components

  • Keep presentational props domain-shaped, not API-shaped. Pass orders and onRefund, not a raw query result object. That keeps the UI independent of TanStack Query or whatever library you use next.
  • Name callbacks after user intent. onRefund(id) and onStatusFilterChange(status), not refundMutation or setState.
  • Let containers own loading and error states, or pass them explicitly as props. Don't make presentational components guess.
  • Allow UI state in presentational components. Open menus, hover state, and input drafts belong there.
  • Co-locate the pair. Put OrdersTable.tsx, useOrders.ts, and their tests in one feature folder. The post on folder structure for scalable React projects has layouts that work well for this.

Frequently Asked Questions (FAQ) About the Container/Presentational Pattern

Not outdated, but no longer a default. Hooks let you reuse data logic without a separate container component, so the strict split for every component is unnecessary. The core idea of keeping UI components free of data-fetching concerns is still valuable for testing, Storybook, and reuse.

Yes. It can hold UI state like whether a menu is open, which tab is active, or what's typed in a search box. What it shouldn't do is fetch data, talk to a store, or know where actions are sent.

It plays the same role. A hook like useOrders collects data and actions and returns them, and the component calling it passes them to a presentational component. The difference is packaging: a hook is a function, while a classic container is a component.

A Server Component that fetches data and passes it to a Client Component is a natural container. The client part handles interaction and display. Because only serializable props cross the boundary, the separation is enforced for you.

No. Split when you need to render the UI without its data source, such as in tests, stories, or a second page with different data. For small, single-use components, keeping logic and markup together is simpler.

Usually in the container, which knows the request state, while the presentational component only renders data that's ready. Alternatively, pass status as an explicit prop if the design calls for skeletons inside the presentational layout.

Conclusion

The container/presentational pattern separates knowing where data comes from from deciding how it looks. In modern React, the "container" is often a custom hook or a Server Component rather than a dedicated wrapper component, but the payoff is the same: presentational components that are easy to test, easy to show in Storybook, and reusable across data sources.

Apply it where that payoff is real, not everywhere. Look for components that are hard to test because they fetch, or that you'd like to reuse with different data, and split those first. If you want to go one step further and separate behavior from markup as well, building headless UI components shows the next layer of the same idea.

Tags :
Share :

Related Posts

A Practical Guide to useEffect and Its Dependency Array

A Practical Guide to useEffect and Its Dependency Array

useEffect is the hook people get wrong most often, and the dependency array is usually where it goes wrong. Leave a value out and your effect works

Continue Reading
Accessibility Best Practices for React Developers

Accessibility Best Practices for React Developers

React makes it easy to build interfaces out of anything. A div with an onClick looks and behaves like a button for a mouse user, so it ships. The

Continue Reading
Animations in React with Motion (Framer Motion)

Animations in React with Motion (Framer Motion)

CSS transitions get you far, until you need to animate something leaving the page. React removes the element from the DOM immediately, so there's not

Continue Reading