---
title: 'The Component Concept'
source: 'https://academia.sh/en/courses/component-based-development/component-concept'
course: 'Component-Based Interface Development'
language: en
updated: '2026-08-17T18:11:04+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```js
// 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.

```js
// 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.
