Status: accepted · 2026-09-20
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.
bench:ic checks that .value reads stay monomorphic), and a
check on every read would cost a branch there for a mistake that can only
happen during setup.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.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:
props.x or x.value. That is
const id = props.id, const total = count.value * 2,
const cls = props.on ? 'a' : 'b'.signal(props.initial), peek(), computed(…), every handler and
snapshot(…) itself, without special-casing any of them by name.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.
dev.ts, which the
build aliases to a module of empty functions — verified: the shipped bundle
contains neither the check nor its message. The read path keeps the single
branch it already had.Set lookup on the
ones inside a setup. Nothing that a development build notices.@firsthandjs/core went from 2.29 kB to 2.36 kB
gzip for snapshot plus the exported no-op, and the full runtime from
5.86 kB to 5.87 kB.One Set of reported keys and one small stack of setup frames, both in the
development module only.
snapshot() gives a name to something that previously had to be explained in
a comment.signal(props.initial) from a mistake, so it would be noise in week one and
ignored by week two.const x = v.value in a
setup, and got no error — the report that would have been most useful was the
one nobody had turned on. The rule is narrow enough to be right almost every
time it fires, and strictReactivity: false is there for a codebase that
disagrees.signal(...) as an allowed wrapper. Unnecessary: “no
calls” already covers it, and a list of blessed function names would have to
grow forever.