---
title: 'Side Effects'
source: 'https://academia.sh/en/courses/component-based-development/side-effects'
course: 'Component-Based Interface Development'
language: en
updated: '2026-08-17T18:11:03+00:00'
license: 'CC BY-SA 4.0'
---

# Side Effects

How a component synchronizes with the outside world; the purity of rendering, an effect running after the commit phase, comparing the dependency list, the order of the cleanup function, and race conditions in asynchronous effects.

The previous lesson established that the component body has exactly one job: computing
an output from state and inputs. Part of what the measurement station page has to do
falls outside this computation. Fetching measurements from the server, subscribing to a
measurement stream, writing the selected filter to local storage, changing the document
title, setting up a timer — none of these is an output computation. These are the points
where the program touches the outside world, and they carry the name introduced in the
Programming Fundamentals course: **side effect**.

The question this lesson asks is where this work is written. The answer is a
registration mechanism: the body does not perform the side effect, it declares the work
to be done. The runtime takes this declaration, runs it at the right moment, and — the
half of this lesson most often skipped — tears down what it set up at the right moment.

## The Purity of Rendering

There are three separate reasons a side effect cannot be written into the body, and all
three come from the body being bound to a contract: a body called with the same inputs
must produce the same output and do nothing else.

The first is timing. The tree the body produces has not yet been written to the document;
the difference from reconciliation has not been applied yet. Code inside the body that
tries to measure or focus a node is talking to a node that does not exist.

The second is cancelability. A render can be interrupted midway; when a higher-priority
update arrives, the runtime can abandon a computation already underway and start over. A
request sent inside the body is a request whose result will never be used; a counter
incremented inside the body may end up incremented twice.

The third is duplication. The same body can run twice, either because of checks during
development or because of a server-side render pass. For a pure body this is an invisible
operation; for a body that opens a subscription, it is two subscriptions.

These three reasons collapse into one rule: **the body reads and computes; every piece of
work that touches the outside is declared through registration.**

## The Effect Record and the Dependency List

The work the body declares is called an **effect**. A record has two parts: the function
to run and a **dependency list**. After the runtime applies the computed difference to
the document — this stage is called the **commit** phase in this course — it reviews the
records and runs the ones whose list differs from the previous render's.

The comparison is an item-by-item identity comparison, not a deep equality test. Its
consequences are taken up at the end of this lesson.

```js
// effect.mjs — dependency list, run condition, and cleanup order
let active = null;
const log = (s) => console.log("   " + s);

function registerEffect(fn, deps) {
  active.pending.push({ i: active.cursor++, fn, deps });
}

// If no list is given the effect runs after every commit; if given, items are compared one by one.
function sameDeps(a, b) {
  if (a === undefined || b === undefined) return false;
  return a.length === b.length && a.every((v, k) => Object.is(v, b[k]));
}

function render(inst, props) {
  active = inst; inst.cursor = 0; inst.pending = [];
  console.log(`render: station=${props.station}`);
  inst.body(props);
  active = null;
  for (const record of inst.pending) {         // commit phase: after the render
    const previous = inst.effects[record.i];
    if (previous && sameDeps(previous.deps, record.deps)) continue;
    if (previous && previous.cleanup) previous.cleanup();
    record.cleanup = record.fn() || null;
    inst.effects[record.i] = record;
  }
}

function unmount(inst) {
  console.log("removing from tree");
  for (const e of inst.effects) if (e && e.cleanup) e.cleanup();
  inst.effects = [];
}

const panel = { effects: [], cursor: 0, pending: [] };
panel.body = ({ station }) => {
  registerEffect(() => { log("A ran (no list)"); });
  registerEffect(() => {
    log("B ran (empty list)");
    return () => log("B cleaned up");
  }, []);
  registerEffect(() => {
    log(`C ran: subscribed to ${station} stream`);
    return () => log(`C cleaned up: unsubscribed from ${station}`);
  }, [station]);
};

render(panel, { station: "NS-01" });
render(panel, { station: "NS-01" });
render(panel, { station: "NS-02" });
unmount(panel);
```

```
render: station=NS-01
   A ran (no list)
   B ran (empty list)
   C ran: subscribed to NS-01 stream
render: station=NS-01
   A ran (no list)
render: station=NS-02
   A ran (no list)
   C cleaned up: unsubscribed from NS-01
   C ran: subscribed to NS-02 stream
removing from tree
   B cleaned up
   C cleaned up: unsubscribed from NS-02
```

The list's three forms can be read separately in the output.

**When no list is given**, the effect runs after every commit. Record A ran in three of
the four renders. This form is chosen when the value to synchronize is unclear, and it is
expensive.

**When an empty list** is given, the effect runs only after the first render. Record B
ran once and was cleaned up only when removed from the tree. Work that should be set up
once for the component's lifetime — setting up an observer, attaching a listener to the
document — uses this form.

**When a list with values** is given, the comparison result is decisive. Record C did
not run at all on the second render because the station had not changed; it ran on the
third because it had.

## Cleanup

The second half of a side effect is tearing down what it set up. If the effect function
returns a function, that function is stored as the **cleanup function** and called at two
moments: right before the effect reruns, and when the component is removed from the tree.

This is why the order in the third render matters. The old subscription was cut first,
then the new one was opened. The reverse order would mean the two subscriptions coexist
for a moment; for a server that counts subscribers, this is a measurable difference.
Skipping cleanup accumulates one subscription every time the station changes. This
accumulation produces two costs: connections that never close, and — the leak defined in
the Asynchronous JavaScript and the Runtime course — callbacks that hold access to a
component no longer in the tree.

Both cleanups ran on removal from the tree. This defines cleanup's contract:
**everything an effect sets up must have a matching teardown in cleanup.** An effect that
attaches a listener removes the listener, an effect that sets up a timer cancels the
timer, an effect that subscribes cuts the subscription. Cleanup itself is written to be
idempotent, like the cleanup hooks in the Shell Programming course; closing an
already-closed connection a second time must not produce an error.

## Race Conditions in Asynchronous Effects

The least understood use of cleanup is with network requests. Even when a request cannot
be canceled, using its result can be prevented.

```js
// race.mjs — response order breaking in an asynchronous side effect
function channel() {
  const pending = new Map();
  return {
    request: (name) => new Promise((resolve) => pending.set(name, resolve)),
    respond: (name) => pending.get(name)(`${name} measurements`),
  };
}
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));

async function scenario(guarded) {
  console.log(guarded ? "with cleanup:" : "without cleanup:");
  const net = channel();
  let view = "-";
  let cleanup = null;

  // The effect that runs when the dependency list [station] changes.
  const effect = (station) => {
    if (cleanup) cleanup();                 // cleans up the previous effect
    let canceled = false;
    net.request(station).then((data) => {
      if (guarded && canceled) { console.log(`  ${data} arrived, discarded`); return; }
      view = data;
      console.log(`  view <- ${data}`);
    });
    cleanup = () => { canceled = true; };
  };

  effect("NS-01");             // user selects NS-01 first
  effect("NS-02");             // immediately selects NS-02 next
  net.respond("NS-02");        // the nearby station responded first
  await tick();
  net.respond("NS-01");        // the distant station responded later
  await tick();
  console.log(`  selected station NS-02, on screen: ${view}`);
}

await scenario(false);
await scenario(true);
```

```
without cleanup:
  view <- NS-02 measurements
  view <- NS-01 measurements
  selected station NS-02, on screen: NS-01 measurements
with cleanup:
  view <- NS-02 measurements
  NS-01 measurements arrived, discarded
  selected station NS-02, on screen: NS-02 measurements
```

The user selected two stations in quick succession, and the second request was answered
before the first. This is a **race condition**: the result ends up depending on the order
responses return in, not the order requests were sent in. Without cleanup, the
measurements of the unselected station stay on screen, and no error message appears.

The fix is for each effect run to carry its own validity flag. Cleanup drops the flag; a
late-arriving response checks the flag and discards itself. The portable form of the same
pattern is built with the abort signal from the Asynchronous JavaScript and the Runtime
course: cleanup cancels the request, and the late response is never produced at all.

## Three Common Mistakes

**A missing dependency.** A value not written into the list freezes in the closure of the
render where the effect first ran. Since the effect never reruns, that value is never
updated; this is a **stale closure**. The rule is single: every variable value read in
the effect's body goes into the list. Shortening the list to reduce how often the effect
runs does nothing but hide the bug.

**A dependency rebuilt on every render.** Because the comparison is an identity
comparison, an object or array rebuilt inside the body looks different on every render,
and the effect runs every time. This arrives at the same result as never giving a list at
all.

**Deriving state with a side effect.** An effect that computes one value from another and
writes the result into state produces two renders from a single user action, and shows an
inconsistent frame in between. This mistake is common enough that the entire next lesson
is devoted to it.

## Summary

- The component body is pure: it reads and computes; work that touches the outside is not
  done in the body, it is declared through registration.
- An effect record runs after the commit phase; the dependency list is put through an
  item-by-item identity comparison against the previous render's list.
- With no list, the effect runs after every commit; with an empty list, only once; with a
  list of values, only when the values change.
- The cleanup function is called before the effect reruns and when the component is
  removed from the tree; everything an effect sets up must have a matching teardown in
  cleanup.
- In asynchronous effects, response order can differ from request order; a validity flag
  that cleanup drops is the smallest way to discard a late-arriving response.
- A missing dependency produces a stale closure; an object rebuilt on every render
  produces an effect that keeps running.

## Next Step

The effect record exists for synchronizing with the outside world; yet the place it is
set up most often is not the outside world. Code that filters the measurement list when
the selected type changes in the filter panel, writes the result into state, and thereby
triggers a second render, is a common pattern — and it is wrong. The filtered list is not
state; it is a result computable from two pieces of state, and holding it in a separate
slot creates a second source of truth. The next lesson draws this distinction: which
values are stored, which are computed on every render, and, when the computation is
expensive, what the measurable payoff of storing the result is.
