Skip to content
academia.sh

Lesson 18 / 25

Presentational and Container Separation

Separating the responsibility that fetches data from the responsibility that produces the view; measuring the separation by the number of test doubles, using a single presentational component with two containers, and where the separation goes too far.

Contents

The measurement table currently does four jobs: it fetches measurements from the network, reads the user’s sort preference from storage, sorts the rows, and produces a table tree. All four live in the body of the same function.

Someone who wants to test how this component looks with an empty table first has to set up a fake network source. Someone who wants to render the same table with measurements from an archive file has to change the network call inside the component. Neither request has anything to do with the view, but both run into the data layer.

Two Responsibilities

The separation defines these two roles.

A presentational component produces a tree only from the props it receives. It does not reach the network, does not read the clock, does not look at storage, and does not take a value from an outer scope. It always gives the same tree for the same props; it fits the pure function definition from the Programming Fundamentals course.

A container component obtains the data, shapes it, and hands it to the presentational component as props. It produces no markup itself; the only thing it produces is a set of props. (The term container here refers to this component role, not a CSS flex or grid container.)

The rule of the separation comes down to one sentence: a presentational component’s input is written completely in its signature. If it depends on nothing that is missing from its signature, the separation has been made.

The Measure of the Separation

The gain can be measured by the number of test doubles that have to be set up for testing.

// separation.mjs — testability difference between a combined component and presentational/container separation
import { deepStrictEqual } from "node:assert";

const element = (name, attrs = {}, ...children) => ({ name, attrs, children: children.flat().filter(Boolean) });
const text = (value) => ({ name: "#text", attrs: {}, children: [], text: value });

// --- Combined component: also fetches, sorts, formats, and renders the data ---
async function combinedTable({ source, clock, store }) {
  const field = store.read("sort") ?? "name";
  const raw = await source.fetch();                      // network
  const rows = [...raw].sort((a, b) => (a[field] > b[field] ? 1 : -1));
  const timestamp = new Date(clock.now()).toISOString();  // time
  return element("table", {},
    element("caption", {}, text(`North Slope — ${timestamp}`)),
    element("tbody", {}, ...rows.map((r) =>
      element("tr", { "data-name": r.name },
        element("th", {}, text(r.name)),
        element("td", {}, text(`${r.value.toFixed(1)} °C`))))));
}

// --- Separated version ---
// Presentational component: builds a tree only from its props. No network, no clock, no store.
const tableView = ({ title, timestamp, rows }) =>
  element("table", {},
    element("caption", {}, text(`${title} — ${timestamp}`)),
    element("tbody", {}, ...rows.map((r) =>
      element("tr", { "data-name": r.name },
        element("th", {}, text(r.name)),
        element("td", {}, text(`${r.value.toFixed(1)} °C`))))));

// Container 1: live measurement source.
async function LiveContainer({ source, clock, store }) {
  const field = store.read("sort") ?? "name";
  const raw = await source.fetch();
  return tableView({
    title: "North Slope",
    timestamp: new Date(clock.now()).toISOString(),
    rows: [...raw].sort((a, b) => (a[field] > b[field] ? 1 : -1)),
  });
}

// Container 2: data of the same shape read from an archive file.
function ArchiveContainer({ archiveRows, timestamp }) {
  return tableView({
    title: "North Slope",
    timestamp,
    rows: [...archiveRows].sort((a, b) => (a.name > b.name ? 1 : -1)),
  });
}

// --- Test: how many test doubles are needed? ---
const MEASUREMENTS = [{ name: "Upper Slope", value: -4.2 }, { name: "Lower Slope", value: 1.63 }];
const TIMESTAMP = "2026-02-11T06:00:00.000Z";

let fake = 0;
const fakeSource = (v) => { fake++; return { fetch: async () => v }; };
const fakeClock = (t) => { fake++; return { now: () => t }; };
const fakeStore = (d) => { fake++; return { read: (a) => d[a] }; };

fake = 0;
const combinedTree = await combinedTable({
  source: fakeSource(MEASUREMENTS), clock: fakeClock(Date.parse(TIMESTAMP)), store: fakeStore({ sort: "name" }),
});
const combinedFakes = fake;

fake = 0;
const viewTree = tableView({
  title: "North Slope", timestamp: TIMESTAMP,
  rows: [{ name: "Lower Slope", value: 1.63 }, { name: "Upper Slope", value: -4.2 }],
});
const viewFakes = fake;

deepStrictEqual(combinedTree, viewTree);
console.log("combined component's tree matches presentational component's tree: yes");
console.log(`test doubles required — combined: ${combinedFakes}, presentational: ${viewFakes}`);
console.log(`does the test need to be asynchronous — combined: yes, presentational: no`);

// Purity: two calls with the same props give the same tree.
const a = tableView({ title: "North Slope", timestamp: TIMESTAMP, rows: MEASUREMENTS });
const b = tableView({ title: "North Slope", timestamp: TIMESTAMP, rows: MEASUREMENTS });
deepStrictEqual(a, b);
console.log("presentational component produces the same tree with the same props: yes");

// Two containers, one presentational component.
fake = 0;
const live = await LiveContainer({
  source: fakeSource(MEASUREMENTS), clock: fakeClock(Date.parse(TIMESTAMP)), store: fakeStore({}),
});
const liveFakes = fake;
fake = 0;
const archive = ArchiveContainer({ archiveRows: MEASUREMENTS, timestamp: TIMESTAMP });
const archiveFakes = fake;
deepStrictEqual(live, archive);
console.log(`\nthe tree produced by both containers is identical: yes (live fakes=${liveFakes}, archive fakes=${archiveFakes})`);

// Concurrent countability of view states
const states = [
  ["full table", { title: "North Slope", timestamp: TIMESTAMP, rows: MEASUREMENTS }],
  ["single row", { title: "North Slope", timestamp: TIMESTAMP, rows: [MEASUREMENTS[0]] }],
  ["empty table", { title: "North Slope", timestamp: TIMESTAMP, rows: [] }],
  ["negative and zero", { title: "North Slope", timestamp: TIMESTAMP, rows: [{ name: "Summit", value: -0.04 }] }],
];
console.log("\nstate               rows  first cell");
for (const [name, props] of states) {
  const tree = tableView(props);
  const body = tree.children[1];
  const first = body.children[0]?.children[1].children[0].text ?? "-";
  console.log(`${name.padEnd(19)} ${String(body.children.length).padStart(4)}  ${first}`);
}
combined component's tree matches presentational component's tree: yes
test doubles required — combined: 3, presentational: 0
does the test need to be asynchronous — combined: yes, presentational: no
presentational component produces the same tree with the same props: yes

the tree produced by both containers is identical: yes (live fakes=3, archive fakes=0)

state               rows  first cell
full table             2  -4.2 °C
single row             1  -4.2 °C
empty table            0  -
negative and zero      1  -0.0 °C

Three test doubles against zero. Testing the combined component requires faking the network source, the clock, and the store; none of the three has anything to do with the view. None of them is needed for the presentational component, because all three stand as a prop in its signature.

The count of test doubles is not the only gain. The combined component is asynchronous; testing it has to wait on a promise and carries timing-related fragility. The presentational component is synchronous: it is called and its result is examined.

The Container Changes, the View Does Not

The second section shows the real gain of the separation. The live container reads from the network and asks for three test doubles; the archive container uses the array it already has and asks for none. The two produce an identical tree.

This means the same view can be used on the station page with live measurements, on the archive page with past measurements, and on a server-prerendered page with measurements read from a file. The view component knows nothing about any of these three contexts.

The rule is this: the container answers the question of where from, the presentational component answers the question of how it looks. Because the two questions change independently, the two components change independently.

What the Separation Makes Visible

The final table shows that the separation gains something beyond testing. Four view states were produced without a network, in seconds, side by side. The fourth one exposed a flaw: -0.04 degrees Celsius, rounded to one decimal place, becomes -0.0 °C.

This is a concrete example of the Functional Colors and microcopy discussions in the Fundamentals of Interface Design course: a zero carrying a negative sign tells the reader the temperature is below freezing, when the value is in fact nearly exactly zero. The flaw is a formatting decision and has nothing to do with the network layer — but in the combined component, it could only have been seen by setting up a fake source and placing the right data in it.

Being able to produce view states cheaply means the design side’s state table can actually be checked. The five interaction states defined in the Component States topic in the Fundamentals of Interface Design course, the loading and empty states, the error states — all of these are a set of props, and all of them can be produced with zero test doubles.

Where the Separation Does Not Pay Off

Splitting every component in two is not a gain.

If a component has no outside dependency at all, it is already a presentational component; adding a container to it only adds a file and a layer of indirection. The measurement badge is an example: its input is already the measurement’s name, value, and threshold.

The reverse is also true. Separating out a view that is used in only one place and will never be reused forces going back and forth between two files and raises the cost of reading. The separation pays off once a second use or a view state that needs testing shows up.

A third misapplication is doing the separation at the file level while missing the dependency. If a presentational component sits in its own file but reads the clock inside it, or takes a format setting from a global value, the separation has not been made; the test-double count does not drop. The measure is not the number of files but the completeness of the signature.

Another shape of the separation is that the container itself is not even a component: the custom hook or composable built in the previous lesson takes on obtaining the data, and the component only calls it and hands the result to the presentational component. The separation of responsibility is the same; what changes is which unit the separation is done in.

Summary

  • A presentational component produces a tree only from its props; it does not read the network, the clock, storage, or an outer scope. The container obtains the data and turns it into a set of props.
  • The measure of the separation is the number of test doubles that have to be set up for testing; the example came out three against zero, and the presentational component’s test stayed synchronous.
  • A single presentational component produces the same tree with different containers; live data, archive data, and a prerendered page share the same view.
  • Being able to produce view states cheaply makes formatting flaws visible without ever touching the network layer.
  • Adding a container to a component with no outside dependency only adds indirection; the separation pays off once a second use or a state that needs testing shows up.
  • The measure is not the number of files but the completeness of the signature; if a component in a separate file reads a global value, the separation has not been made.

Next Step

This lesson separated data from view, but it did not separate behavior. Looking at the option list in the filter panel, what remains is this: navigating with arrow keys, jumping to the first and last item, going to a matching item when a letter is pressed, selecting, and closing. This behavior is the same everywhere, but the list’s view is different everywhere — a dropdown in one place, a side panel in another, a set of tags in a third. Embedding the behavior in one component fixes the view along with it. The next lesson pulls the behavior away from the view and shows that what is needed for this is not a component but a pure state machine.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close