firsthand

ADR-0019: Strict reactivity, paid for at build time

Status: accepted · 2026-09-20

Problem

A setup function runs exactly once per instance. That is the model, and almost everything good about the framework follows from it: no dependency arrays, no stale closures, no useMemo, no hook order.

It also has one failure mode, and it is silent:

const Widget = component<{ items: Item[] }>((props) => {
  const count = props.items.length; // read now, frozen now
  return <p>{count} items</p>; // and so this number never moves
});

Nothing throws. The number is right the first time and wrong afterwards, which is the worst shape a bug can have. And the habit that produces it — read at the top, use below — is correct in a framework that re-renders, where reading and using are the same act because the body is re-entered. It is not a beginner’s mistake; it is a correct habit meeting a different model.

Constraints

Options

  1. Documentation only. Cheapest, and not enough: the guide can describe the mistake but cannot point at the line.
  2. A runtime check on every read, in production too. Would catch everything, and would put a branch in the hottest path in the framework for a development concern. Rejected on the same grounds as every other “safety in production” feature here.
  3. Compile-time where possible, development-only runtime for the rest.
  4. A type-level solution — a branded type that cannot be assigned to a plain number. Would catch it at the keyboard with no runtime at all, and would change every prop and every signal read into something that no longer composes with ordinary code.

Chosen design

Option 3, in two halves that are switched on separately.

Build time. firsthand({ strictReactivity: true }) refuses to compile a declaration in a component setup whose initialiser is nothing but a read. The error names the declaration and offers both ways out. The rule is narrow on purpose, because a false positive stops a build:

Development runtime. setStrictReactivity(true) reports what the compiler cannot follow — a read that leaves the module, doSomething(props) with the access in another file. It hooks the one place a read finds no subscriber, which is the existing else of the branch that links one, so no new branch is added to the read path. It reports once per read, not once per instance, keyed by the component’s build id plus the position of the read within its setup; a stack trace cannot do that, because three <Label /> in one template are three call sites for one mistake.

snapshot(read) is the escape hatch and, more importantly, the way to say what you meant. It untracks exactly as untrack does. The difference is that it is documented as intent, so both halves stay quiet inside it and the next reader knows the frozen value was chosen.

The compiler rule is on by default; the runtime report is not. That split is the second constraint applied where it actually bites. The compiler sees the shape of a declaration and reports only the one that is almost always wrong — anything containing a call is left alone, which is most deliberate reads. The runtime sees a read with nothing subscribing, and signal(props.initial) is exactly that, so it would be wrong often enough to be ignored.

A check that is usually right can be on. One that is often wrong cannot.

Performance implications

Memory implications

One Set of reported keys and one small stack of setup frames, both in the development module only.

DX implications

Rejected alternatives