Presentational and container components: the split that keeps testing sane
The single most useful split in a component tree is by responsibility: does this component render, or does it fetch and decide? A presentational component takes props and produces markup — it is “dumb” on purpose. A container component talks to the outside world — the store, an API, the router — figures out what the presentational component needs, and hands it down as props. Draw that one line and both halves get dramatically easier to test and reuse: the presentational half has no dependencies to mock, and the container half has no markup to assert against.
The presentational half takes props and stays dumb
A presentational component receives everything it needs and reports what happened via callbacks. It does not know where the data came from or where the events go:
// presentational: no store, no fetch, no idea what "save" does — just renders
function UserCard({ name, email, isSaving, onSave }) {
return (
<article className="card">
<h3>{name}</h3><p>{email}</p>
<Button disabled={isSaving} onClick={onSave}>
{isSaving ? "Saving…" : "Save"}
</Button>
</article>
);
}
Testing this is trivial: pass props, assert on output, click the button, assert
onSave fired. No store to set up, no network to mock.
The container half wires it to the world
The container holds the messy part — reading the store, dispatching, fetching — and translates it into the simple props the presentational component wants:
// container: all the coupling lives here, and it renders no markup of its own
function UserCardContainer({ id }) {
const user = useSelector((s) => s.users.byId[id]);
const isSaving = useSelector((s) => s.users.savingId === id);
const dispatch = useDispatch();
return (
<UserCard {...user} isSaving={isSaving}
onSave={() => dispatch(saveUser(id))} />
);
}
Now the coupling is concentrated in one small, markup-free file, and it is the only thing you mock the store for.
The boundary is what buys reuse and Storybook
This split is why a component library can exist at all: presentational components
have no app-specific dependencies, so they render in isolation in Storybook, work
in a second app, and are safe to reuse. It is also the practical form of the
UI/State boundary — the container is the seam where state meets UI. You do not
need a container for every presentational component (many are composed directly by
a parent that already has the data), but every fetch, every useSelector, every
dispatch should live at or above a container line, never inside a dumb renderer.
Keep the decision of “where does data enter?” separate from “how does it look?”
and both questions get simpler. The presentational-vs-container exercise makes you
perform exactly this refactor — pull the fetch out of a component and into a
container — which is the clearest way to feel why the boundary pays off.