Skip to content
academia.sh

Lesson 08 / 25

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.

Contents

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.

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

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

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close