firsthand

ADR-0026: A function is a reactive scope, one level up

Status: accepted · 2026-09-21 · extends ADR-0009, ADR-0019

Problem

A setup runs once, so a view chosen in it is chosen for ever:

component(() => {
  if (token.value === null) {
    return <Navigate to="/sign-in" />; // never runs again
  }
  return <Page />;
});

0.7.0 answered this with a build error (ADR-0019, checkDecidedOnce), which is the right thing to do about a mistake that produces no exception and a wrong screen twenty minutes later. But a rule whose whole job is to forbid the natural spelling is a hole in the model, not a feature of it. Every other framework lets you write an if above your markup; this one had nowhere to put one.

Three things made that hole visible at once:

Constraints

Options

  1. Keep refusing it. The error message is good and the workaround is one line. Rejected because the workaround is worse to read than the mistake, and because the check provably cannot cover the whole class.
  2. Make the setup itself re-runnable, as React does. Rejected outright: it is the one thing this framework exists not to do, and every rule about identity, ordering and persistent state follows from the setup running once.
  3. A render function returned from the setup, which is an ordinary function and therefore an ordinary reactive scope.

Chosen design

Option 3, with one rule doing all the work:

Every function you write is a reactive scope: it runs again when something it read changes. JSX beneath it makes the smallest scopes it can — as long as they need nothing from the run that made them.

component(() => {
  const draft = signal(''); // setup: once

  return () => {
    // run: again, when what it read changes
    if (!draft.value) {
      return <Empty />;
    }
    return <Editor draft={draft} />;
  };
});

The classification is forced, not chosen. For every expression in the markup the compiler asks whether it names a binding the run made. If it does not, it becomes a part with its own scope, exactly as today. If it does, it cannot become a part — that value belongs to one call of the run, and a part holding it would hold a value from a run that is over — so the run writes it, guarded by what it last wrote. There is no policy here to get wrong: one of the two is impossible in each case, and the possible one also happens to be the faster one.

A site is made once and written afterwards. The store is an array declared in the setup, which runs once per instance, so it needs no registry and no ambient lookup. Each site takes a numbered place in it, fixed at compile time: a site inside an if keeps its place whether or not the branch was taken, which is why there is no ordering rule of any kind.

What a site makes belongs to the instance. Parts, listeners and child components created when a site is built are opened under an owner taken from the store, not under the run — a run’s scope is cleared before it runs again, and anything left there would be disposed by the very next run. This was found by measurement, not by reasoning: the first implementation put them in the run and the second run tore them down.

A branch the run leaves is disposed. Every return passes through ran, which disposes the sites this run did not reach. Cleanups fire, parts stop, nodes go. Coming back builds it again.

Children keep their instance. A component in a run is made once; props fed from run locals are held in cells the run writes, so a prop that did not change reaches nobody. Props that are not fed from the run stay ordinary live reads and cost nothing extra.

Anything a run hands to something built once goes through a cell. This is the general form of the rule above, and it is written out because it has been got wrong three times by being read as a rule about props. A run local is a binding belonging to one call of the run; anything built once that captures it goes on reading that call’s value for ever, silently, because the page still renders. So the same treatment is owed to a child a run gives a component, and to the data a run gives a keyed list, as to a prop — a cell the run writes, holding the reading rather than the result, so that what it reads is attributed to the part that displays it rather than to the run. The fourth instance of this should be caught in review rather than in an issue.

A site belongs to every place the run reaches, including through the compiler’s own wrappers. The rule above says which markup a run keeps; this says where. A dynamic child is emitted as part(() => …), and that arrow is a function — so the question “which run is this markup in?” answered itself wrongly for anything inside one. It is the compiler’s own wrapper, but it is not always a place the run returns to: in a kept template the part around it is built on the first run and never again, and what is inside belongs to that part; in a fragment a run returns, the array and every part in it are built afresh each time, so what is inside belongs to the run. So the wrapper is stepped over exactly when the code that creates it runs again, which is a question the same function already answers one level up. Until #47 the answer was “never”, and a component inside a fragment was the one child position a run could not keep: the whole fragment, and every row under it, was rebuilt on every run. A list’s rows are unaffected either way, because a row sits in a callback the author wrote and that is a boundary.

Handlers are what they look like. A closure over a run local is a new function on every run, and it replaces the previous one on the node — so it is never stale, and always exactly as old as the DOM beside it. on() had to learn to replace rather than add for non-delegated types, keyed by the options as well as the type so that onClick:native and onClick:once remain two listeners.

What cannot be written is built again. A spread, a ref or a keyed list that depends on the run turns retention off for that site, which falls back to today’s behaviour: slower, never wrong.

Two refusals, both the existing rule said once more:

And a plain function that returns markup is a view — a reactive scope with no setup, recognised inside a module by the compiler and across one by a mark it emits. Writing it as a tag is what gives it a place and a scope; calling it is a function call and behaves like one. This is what component() is for after this ADR: it means the view needs a setup.

Performance

npm run bench:runs, recorded in benchmarks/results/render-functions.json. 1000 components of 20 sites derived from one signal, both compiled by the real compiler and both verified to render 20 000 nodes before timing:

  mount update heap
a site per expression 31.40 ms 11.435 ms 14.0 MB
one render function 20.70 ms 8.355 ms 8.8 MB

1.52x, 1.36x and 1.59x. Most of it is arithmetic on effects: twenty subscriptions and twenty reads of one signal become one of each.

The shape matters and the ADR says so: twenty sites with twenty independent sources is the opposite case, and there the fine-grained form wins, because a run would look at all twenty to write the one that moved. The classification means a component that mixes the two gets both behaviours, so the question rarely has to be answered by the developer at all.

Three measurements changed the design rather than confirming it:

Memory

Less, not more: 13.8 MB to 8.8 MB in the measured shape, because an effect with its dependency links costs more than a slot in an array. A site store is one array per instance plus one small record per site and per write.

The published runtime grew about 0.35 kB gzip, and the bundle budget went from 6 kB to 7 kB to admit it. The new functions are separate exports, so an application that never returns a render function does not pay for them; the figure that moved is the everything-imported one.

DX

What a developer does about this: nothing, unless they want an if. Shape 2 — a setup returning markup — is shape 3 with an empty body, and is unchanged in behaviour and in cost.

What they gain is that the reactive graph is drawn with function boundaries they can see. const person = profile.value in a statement says this view treats the profile as one thing; the same read in the markup says this site follows it on its own. One normal line of JavaScript, two granularities, both visible.

What they must know is the short list the guide ends on: statements are the run’s dependencies and markup expressions are not; persistent things go in the setup; repeated markup takes a key; a handler passed to a child is a new function every run. Development says the last one out loud rather than leaving it to be found, and says so too when a run keeps running and keeps writing nothing:

<OrderTable> ran 20 times and wrote nothing.

That is the shape of every diagnostic this ADR adds: bookkeeping, said out loud, with nothing optimised behind anyone’s back.

Rejected alternatives