Skip to content
academia.sh

Lesson 03 / 25

The Component Concept

Splitting the view function into pieces; the contract made of a prop, an output callback, and local state, the identity of a component instance, the area re-render spreads through, and which component a piece of state should live in.

Contents

The previous lesson’s view function described the entire dashboard in a single body. When fifteen badges, a measurement table, and a filter panel fit into the same function, two things are lost at once: the body becomes unreadable and none of its parts can be reused on its own.

This lesson splits the function into pieces. The unit of the split is called a component: a function that produces its own view description and speaks to the outside through three defined channels.

The Component’s Three Channels

A component’s relationship with the outside world is limited to three channels.

A prop is a value the parent gives to the child. When the context is clear, it is also called a property for short. Its direction is one-way and it is read-only: the child cannot change the value it receives. A measurement badge’s input is the measurement’s name, value, unit, and threshold.

Output is the event the child reports to the parent. The child does not make a decision; it reports what happened upward, and the side holding state makes the decision. The filter panel reports which option was clicked; it does not decide which filter gets applied. The carrier of the report is a callback given as input.

Local state is a value that concerns only the component itself and is invisible from outside. Whether a badge’s detail section is open is an example: no other piece needs this information.

The entire contract is these three channels. A component has no other path to its parent, no direct access to a sibling, and no hidden value it reads from the tree.

The Component Tree and Instance Identity

Components use one another and form a tree. This tree is not the document tree: a component does not have to correspond to a document node, and a single component can produce dozens of nodes.

Every position in the tree corresponds to a component instance. The instance is where local state lives, and it is preserved across re-renders.

// component-runtime.mjs — a small component model with input, output, and local state
const element = (name, attrs = {}, ...children) =>
  ({ kind: "element", name, attrs, children: children.flat() });
const component = (fn, props = {}) => ({ kind: "component", fn, props });

const instances = new Map();          // id → { local, fn, props }
let renderCount = new Map();          // component name → render count

function session(id) {
  const record = instances.get(id);
  return {
    // Local state belongs to the instance; setting it re-renders only this instance.
    local(name, initial) {
      if (!record.local.has(name)) record.local.set(name, initial);
      return [record.local.get(name), (next) => {
        record.local.set(name, next);
        renderComponent(id, record.fn, record.props);
      }];
    },
  };
}

function renderComponent(id, fn, props) {
  if (!instances.has(id)) instances.set(id, { local: new Map() });
  const record = instances.get(id);
  record.fn = fn; record.props = props;
  renderCount.set(fn.name, (renderCount.get(fn.name) ?? 0) + 1);
  return renderNode(fn(Object.freeze(props), session(id)), id);
}

function renderNode(description, path) {
  if (typeof description === "string") return description;
  if (description.kind === "component")
    return renderComponent(`${path}/${description.fn.name}`, description.fn, description.props);
  return { name: description.name, attrs: description.attrs,
    children: description.children.map((c, i) => renderNode(c, `${path}/${i}`)) };
}

// --- Components -----------------------------------------------------------
// Input: value, unit, exceeded. Output: none. Local state: whether the detail is open.
function MeasurementBadge(props, { local }) {
  const [open, setOpen] = local("open", false);
  return element("div", { class: props.exceeded ? "badge exceeded" : "badge", open: () => setOpen(!open) },
    `${props.name}: ${props.value} ${props.unit}`,
    open ? element("small", {}, `threshold ${props.threshold} ${props.unit}`) : "");
}

// Input: options, selected. Output: onSelect callback.
function FilterPanel(props) {
  return element("div", { class: "filter" }, props.options.map((s) =>
    element("button", { selected: s === props.selected, click: () => props.onSelect(s) }, s)));
}

function MeasurementTable(props) {
  return element("ul", { class: "table" }, props.measurements.map((m) =>
    component(MeasurementBadge, { ...m, exceeded: m.value >= m.threshold })));
}

function Dashboard(props) {
  const visible = props.filter === "all"
    ? props.measurements : props.measurements.filter((m) => m.kind === props.filter);
  return element("section", { class: "dashboard" },
    element("h2", {}, `North Slope — ${visible.length} / ${props.measurements.length} measurements`),
    component(FilterPanel, { options: ["all", "temperature", "humidity"],
      selected: props.filter, onSelect: props.onFilterSelect }),
    component(MeasurementTable, { measurements: visible }));
}

// --- Run --------------------------------------------------------------------
const MEASUREMENTS = [
  { name: "Temperature", kind: "temperature", value: -4.2, unit: "°C", threshold: 30 },
  { name: "Relative humidity", kind: "humidity", value: 72, unit: "%", threshold: 90 },
  { name: "Ground temperature", kind: "temperature", value: 1.8, unit: "°C", threshold: 30 },
  { name: "Dew point", kind: "humidity", value: 95, unit: "%", threshold: 90 },
];
let state = { filter: "all", measurements: MEASUREMENTS };
const renderRoot = () => renderComponent("root", Dashboard, {
  ...state,
  onFilterSelect: (s) => { state = { ...state, filter: s }; renderRoot(); },
});
const report = (label) => {
  console.log(`${label.padEnd(30)} ${[...renderCount].map(([a, n]) => `${a} ${n}`).join(", ")}`);
  renderCount = new Map();
};

renderRoot();
report("initial render");

// The first badge's local state changes: only that instance re-renders.
const BADGE = [...instances.keys()].find((k) => k.endsWith("MeasurementBadge"));
console.log(`first badge instance: ${BADGE}`);
session(BADGE).local("open", false)[1](true);
report("one badge's local state");

// The dashboard's state changes: a re-render starting from the root.
instances.get("root").props.onFilterSelect("temperature");
report("the dashboard's state");

console.log(`\nthat badge's local state after the filter: ` +
  `${instances.get(BADGE).local.get("open")}`);
console.log(`registered component instances: ${instances.size}`);
initial render                 Dashboard 1, FilterPanel 1, MeasurementTable 1, MeasurementBadge 4
first badge instance: root/2/MeasurementTable/0/MeasurementBadge
one badge's local state        MeasurementBadge 1
the dashboard's state          Dashboard 1, FilterPanel 1, MeasurementTable 1, MeasurementBadge 2

that badge's local state after the filter: true
registered component instances: 7

The Area Re-render Spreads Through

Four places in the output should be read.

First, the instance’s identity is derived from its position in the tree. The path root/2/MeasurementTable/0/MeasurementBadge points to the first badge of the table in the dashboard’s third child. Local state is tied to this identity; if the identity changes, the state belongs to a different instance. This link breaks in lists, which is the subject of the topic’s fifth lesson.

Second, local state re-renders only its own instance. When the badge’s detail opened, a single render happened; the dashboard, the filter panel, and the table never ran at all. This is the concrete payoff of keeping state as low as possible.

Third, state above re-renders everything below it entirely. When the filter was selected, the render starting from the root ran five components. This is not an inherent cost but a consequence of purity: every child whose input may have changed has to be run again. The badge count stayed at two because the filter reduced the visible measurements to two.

Fourth, local state survives the re-render. The badge ran again, but its open value was preserved; state lives in the instance, not inside the function.

The last line also shows a gap: even though the filter removed two badges from the tree, seven instances remain registered. Removing the instance and resources of a component that leaves the tree is the framework’s teardown responsibility; this model does not do it.

Input Cannot Be Written, State Has One Owner

Two clauses of the contract are frequently violated: the child writes to its input, or state that should be shared is kept inside the child.

// contract.mjs — input immutability and the owner of state
// 1) Input is read-only. The framework enforces the contract by freezing the input.
function MeasurementBadge(props) {
  props.value = Math.round(props.value);   // contract violation
  return `${props.name}: ${props.value}`;
}
try {
  MeasurementBadge(Object.freeze({ name: "Temperature", value: -4.2 }));
} catch (error) {
  console.log(`writing to input: ${error.constructor.name} — ${error.message}`);
}

// 2) The invariant "at most one badge selected" depends on where state lives.
const NAMES = ["Temperature", "Relative humidity", "Wind speed"];
const CLICKS = ["Temperature", "Relative humidity", "Wind speed"];

// A) State inside each badge: the badge toggles its own selection.
const badgesA = NAMES.map((name) => ({ name, selected: false }));
const badgeA = (badge) => { badge.selected = !badge.selected; };
for (const name of CLICKS) badgeA(badgesA.find((b) => b.name === name));

// B) State in the common ancestor: the badge only reports the event upward.
let dashboard = { selectedName: null };
const badgeB = (props) => props.onSelect(props.name);
for (const name of CLICKS)
  badgeB({ name, selected: dashboard.selectedName === name,
    onSelect: (n) => { dashboard = { selectedName: dashboard.selectedName === n ? null : n }; } });

const selectedA = badgesA.filter((b) => b.selected).map((b) => b.name);
const selectedB = dashboard.selectedName ? [dashboard.selectedName] : [];
console.log(`\nclick sequence: ${CLICKS.join(" → ")}`);
console.log(`A) state in badges     → selected: [${selectedA}] (${selectedA.length} total)`);
console.log(`B) state in ancestor   → selected: [${selectedB}] (${selectedB.length} total)`);
console.log(`invariant ("at most one selected") A: ${selectedA.length <= 1}, ` +
  `B: ${selectedB.length <= 1}`);

// In A, the dashboard cannot write "1 badge selected" in its heading: the count is scattered across the badges.
console.log(`\nselection count the dashboard can read — A: none, B: ${selectedB.length}`);
writing to input: TypeError — Cannot assign to read only property 'value' of object '#<Object>'

click sequence: Temperature → Relative humidity → Wind speed
A) state in badges     → selected: [Temperature,Relative humidity,Wind speed] (3 total)
B) state in ancestor   → selected: [Wind speed] (1 total)
invariant ("at most one selected") A: false, B: true

selection count the dashboard can read — A: none, B: 1

The first section shows why writing to input is forbidden. If the write had succeeded, the change would have propagated to the parent’s object, and because the parent never changed its state, no re-render would have been triggered: state would have changed, the view would not. This is the same drift as in the first lesson. Freezing the input object turns the violation into an error with a known location, not a silent problem.

The second section shows that where state lives is a correctness question, not a preference. When the selected state is scattered across the badges, no place remains to preserve the “at most one badge selected” invariant; three clicks produce three selected badges. When it is kept in a common ancestor, the same click sequence leaves a single selection, because there is a single place that writes the value.

The last line adds a second result: scattered state is also unreadable. A dashboard that wants to write how many badges are selected in its heading cannot reach that number under arrangement A.

Where State Should Live

The decision comes down to two criteria.

If a value is read and written by only one component, it is that component’s local state. State kept lower re-renders a smaller area and keeps the component usable on its own.

If a value is read or written by more than one component, it moves to their nearest common ancestor. This move is called lifting state up. The ancestor holds the value, gives it to the children as input, and updates it with the events the children report.

Both criteria rest on the same principle: every value must have a single owner. Keeping the same information in two places is the component-to-component form of the drift seen between the tree and state in the first lesson.

Lifting has a cost: every layer between the value and the component that uses it has to carry the input through. When the inputs that intermediate layers carry only to pass along pile up, a different way of supplying a value across the tree becomes necessary; this way is covered in a later topic of the course.

Summary

  • A component is a view function that speaks to the outside through three channels: a read-only prop, output reported upward, and local state invisible from outside.
  • The component tree is separate from the document tree; the component instance corresponding to every position in the tree is where local state lives and is preserved across re-renders.
  • Local state re-renders only its own instance; state above re-runs every component below it.
  • Writing to input leads to drift because it changes state without triggering a re-render; freezing the input catches the violation early.
  • State that concerns more than one component is kept in the nearest common ancestor; scattered state can neither preserve the invariant nor be read from above.
  • Every value must have a single owner; the cost of lifting is that intermediate layers carry the input only to pass it along.

Next Step

This lesson’s components built the view description with function calls: every element a call, every attribute an object field. When a measurement table’s entire structure is written this way, the shape of the markup is lost; reading where each element closes among nested calls becomes hard. The next lesson covers writing markup in its own syntax and binding it to data: interpolating an expression into text, taking an attribute’s value from an expression, binding an event to a function, and determining at which point these require escaping.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close