Skip to content

Container and presentational split, and its modern form

Containers load and decide; presentational components render props and report events.

Predict first

Before you read: predict

ExamHall's results chart, after a refactor moved its loading into a custom hook. Development build, no StrictMode.

The story tool has no stats server: statsApi.bins() records each call and returns a promise that never settles.

What does the story show?

Code for this question
function useScoreBins(examId) {  const [bins, setBins] = useState(null);  useEffect(() => {    statsApi.bins(examId).then(setBins);  }, [examId]);  return bins;}function ScoreHistogram({ examId }) {  const bins = useScoreBins(examId);  if (bins === null) return <p>Loading…</p>;  if (bins.length === 0) return <p>No attempts yet</p>;  return <ul>{bins.map((b) => <li key={b.from}>{b.from}–{b.to}: {b.count}</li>)}</ul>;} // the designer's story for the empty state<ScoreHistogram examId="ECON-201" bins={[]} />
Pick one

The 30-second version

A container decides: it loads data, chooses what to show and acts on events. A presentational component turns the props it is given into markup, so stories and tests feed it fixtures. Hooks and Server Components now do most container work; the split itself stays.

Containers load and decide; presentational components render props and report events.

How it works

Step 1 of 10

Two jobs. A container loads data, chooses what to show and acts on events. A presentational component turns props into markup.
In the kitchen
  • The boutique's buyer chooses this week's pieces for the window, from stock and the season → A container loads and decides (step 1)

Analogy: A stand-alone boutique's window dresser

At the boutique In React
The boutique's buyer chooses this week's pieces for the window, from stock and the season A container loads and decides (step 1)
The window dresser arranges whatever she is handed, to her own eye A presentational component renders its props, with its own UI state (step 2)
She mocks a window up in the studio with dummy boxes A story or test from fixtures (step 3)
She spots a scuffed box and flags it to the buyer An event going out as a callback (step 4)
A dresser who walks to the stockroom to choose her own pieces Loading inside the component (step 5)

In real terms: whoever chooses the data is the container; whoever only shows it can be rehearsed with stand-ins.

Breaks down when: a dressed window stays as it is until the next change, while a component renders again with every new prop.

Contrasting case: a corner-shop owner who chooses, buys and arranges her own window. One person asks for every change, so nothing needs splitting (pat-srp-cohesion step 1).

Examples, run for real

4.1 ExamHall EXH-4880 and MR !5180: a chart its test could not feed

ScoreHistogram, a Client Component, loaded ECON-201's statistics in an effect and drew the bars. Its unit test could not hand it a fixture; without a mock of the stats module, every run called the live stats API (steps 2, 3). MR !5180 makes the results page, a Server Component, load the statistics and pass plain numbers to ScoreHistogram, which now takes bins only (steps 1, 7).

// MR !5180
export default async function ResultsPage() {
  const stats = await loadStats('ECON-201');
  return <ScoreHistogram bins={stats.bins} />;
}
4.1 EXH-4880 ScoreHistogram loads its own stats | unit test: props it takes none · live stats calls 1
4.1 MR !5180 ScoreHistogram takes bins      | unit test with the ECON-201 fixture: bars 5 · live stats calls 0

The chart's test now passes a fixture, and the page's loading is the page's to test (step 9).

4.2 Streaming: UI-only state stays

EpisodeCard shows an episode and keeps one state of its own, whether its synopsis is open. A story renders it from a fixture episode and clicks More (step 2).

4.2 EpisodeCard story from a fixture episode | synopsis shown before the click no · after one click on More yes

The open panel is the card's own business; no container is needed for it, and the story still runs on a fixture alone.

Try it

Answer first, then check. Nothing is saved.

Question 1 of 7

A streaming app's episode row. watchApi is the live client. Development build, no StrictMode.

In the designer's story of one row, a click on Mark watched reached the live watchApi. The fix must let a story record which episode was clicked with no call to watchApi, while the season page, which renders one row per episode, still calls watchApi.markWatched once per click.

Which single change does both?

Code for this question
function EpisodeRow({ episode }) {  return (    <li>      {episode.title}      <button onClick={() => watchApi.markWatched(episode.id)}>Mark watched</button>    </li>  );}
Pick one

Question 2 of 7

A streaming app's show page in the App Router, a Server Component. EpisodeGrid is a Client Component that lists the titles of the episodes it is given.

A unit test in jsdom (React 19.3.0, development build) must check that the page hands EpisodeGrid the episodes loadShow returns for show s-12, with params set to Promise.resolve({ id: 's-12' }). loadShow is replaced by a stub that records each call and resolves with two episodes. The test must pass, call the stub exactly once and leave React's console empty.

Which test does all three?

Code for this question
async function ShowPage({ params }) {  const { id } = await params;  const show = await loadShow(id);  return <EpisodeGrid episodes={show.episodes} />;}
Pick one

Question 3 of 7

A video player's files. The team split every component into a container and a view, so the progress bar sits behind ProgressBarContainer. A change has just added chapterMarks to ProgressBar and passed it from PlayerPage. TypeScript 6.0.3 with strict: true and React 19.3.0's types.

Which files does tsc --noEmit report errors in?

Code for this question
// ProgressBar.tsxtype ProgressBarProps = { percent: number; chapterMarks: number[] };export function ProgressBar({ percent, chapterMarks }: ProgressBarProps) {  return <div role="progressbar" aria-valuenow={percent}>{chapterMarks.length} chapters</div>;}// ProgressBarContainer.tsximport { ProgressBar } from './ProgressBar';export function ProgressBarContainer({ percent }: { percent: number }) {  return <ProgressBar percent={percent} />;}// PlayerPage.tsximport { ProgressBarContainer } from './ProgressBarContainer';export function PlayerPage({ watched, marks }: { watched: number; marks: number[] }) {  return <ProgressBarContainer percent={watched} chapterMarks={marks} />;}
Pick one

Question 4 of 7

A live-viewer badge. Development build, no StrictMode.

The replay page renders <ViewerBadge streamId="r-7" />. The control room, watching stream live-3, receives counts pushed over a socket into liveCount, which is null until the first push. It wants this badge to show each pushed count with no call to statsApi, and the replay page must still load its count with one call.

Which single change does both?

Code for this question
function useViewerCount(streamId) {  const [count, setCount] = useState(null);  useEffect(() => {    statsApi.viewers(streamId).then(setCount);  }, [streamId]);  return count;}function ViewerBadge({ streamId }) {  const count = useViewerCount(streamId);  return <span>{count === null ? '…' : `${count} watching`}</span>;}
Pick one

Question 5 of 7

Write results-panel.jsx from a blank file, exporting two components and nothing else. ScoreBars({ bins, onSelectBin }) renders "No attempts yet" in a <p> for an empty list; otherwise a <ul> with one <li> per bin holding a <button> labelled from–to: count (an en dash), and a click calls onSelectBin(from). It imports nothing but React. ResultsPanel({ examId, statsApi }) calls statsApi.bins(examId) once per examId, shows "Loading…" in a <p> until the bins arrive, then renders ScoreBars with them; a bar click calls statsApi.openBin(examId, from).

Write results-panel.jsx in full.

Question 6 of 7

A show page's season tabs. Development build, no StrictMode.

logClick only records its argument, and catalogApi.count records each call. The story clicks Season 2, then Season 3.

How many calls has catalogApi.count recorded?

Code for this question
function SeasonTabs({ seasons, selected, onSelect }) {  return (    <nav>      {seasons.map((s) => (        <button key={s} onClick={() => onSelect(s)}>Season {s}</button>      ))}      <EpisodeCount showId="s-12" season={selected} />    </nav>  );}function EpisodeCount({ showId, season }) {  const [n, setN] = useState(null);  useEffect(() => {    catalogApi.count(showId, season).then(setN);  }, [showId, season]);  return <small>{n === null ? '…' : `${n} episodes`}</small>;} // the designer's story<SeasonTabs seasons={[1, 2, 3]} selected={1} onSelect={logClick} />
Pick one

Question 7 of 7

A show page that renders one Server Component per season.

A unit test replaces loadShow (show s-12 has seasons 1, 2 and 3) and loadEpisodes with stubs that record each call, then runs const el = await ShowPage({ params: Promise.resolve({ id: 's-12' }) }).

Once that line has finished, what is el?

Code for this question
async function ShowPage({ params }) {  const { id } = await params;  const show = await loadShow(id);  return (    <main>      <h1>{show.title}</h1>      {show.seasons.map((n) => <SeasonList key={n} showId={id} season={n} />)}    </main>  );}async function SeasonList({ showId, season }) {  const episodes = await loadEpisodes(showId, season);  return <EpisodeGrid episodes={episodes} />;}
Pick one

Go deeper

Pro tips
  1. Keep a presentational component's data props serialisable. Plain numbers, strings, arrays and objects let a Server Component, a story and a test all feed it the same way. When: a chart or list a Server Component may render. Verify (RAN, tips.mjs): the first line below.
  2. Write one story per state from fixtures. Loading, empty and filled are three props, not three server set-ups. When: any component with a loading or empty screen. Verify (RAN, tips.mjs): the second line below.
tip 1 props as given and after structuredClone -> same markup yes · with a function prop -> DataCloneError
tip 2 three fixture props, no client -> "Loading…" · "No attempts yet" · 3 bars
Source
  • react.dev: Server Components, read 2026-10-17 from the page's source. "Server Components can also run on a web server during a request for a page, letting you access your data layer without having to build an API. They are rendered before your application is bundled, and can pass data and JSX as props to Client Components." "Since async components are not supported on the client, we await the promise with use." Steps 7, 8. Named in the curriculum row. Valid as of 2026-10-17.
  • Next.js docs: Server and Client Components, read 2026-10-17 as the Markdown nextjs.org serves. "By default, layouts and pages are Server Components, which lets you fetch data and render parts of your UI on the server" and "the <Page> component is a Server Component that fetches data about a post, and passes it as props to the <LikeButton> which handles client-side interactivity." Named in the curriculum row. Valid as of 2026-10-17.

Checked against the tools and docs as of 2026-10-17.