Scope loss
A scope lives in the current execution context. Synchronous code inside a scoped(...) block, an effect handler, or a reaction body all run with a scope in context. Scope loss is when work that should belong to a scope runs with no scope in context — because control passed through an asynchronous boundary the scope did not survive.
Unlike some libraries, Virentia does not silently fall back to a global scope. A read or a call with no scope throws Scope is required, so scope loss usually fails loudly and points at the offending unit. That is a feature: you find out at the boundary, not three screens later through a piece of state that never updated.
What it looks like
The typical symptom is a Scope is required error raised from a store read, or from calling an event or effect, inside a callback that ran later:
Scope is required to call event "tick", but no scope is active.
Unit path that led here: … → call event "tick".A timer model shows it clearly. This looks correct but throws once per tick:
function createTimerModel() {
const tick = event<void>();
const seconds = store(0);
reaction({ on: tick, run() { seconds.value += 1; } });
// ❌ setInterval calls this later, outside any scope — `tick()` throws.
setInterval(() => tick(), 1000);
return { seconds, tick };
}The interval fires after the synchronous setup returned, so there is no scope in context when tick() runs.
Where it happens
Scope is lost at any boundary where control leaves your scoped execution and comes back later — usually through a callback some other system owns:
setTimeout/setIntervaladdEventListenerand other DOM/event-emitter callbacks- WebSocket / socket handlers (
socket.on(...)) .then(...)/ a rawawait fetch()/ any bare promise- third-party async APIs that call you back
The common thread: the scope is a contextual value set during synchronous execution; a raw await or an externally-invoked callback resumes with that context gone. This is JavaScript's asynchronicity, not a quirk of Virentia — the same reason this or an AsyncLocalStorage value would be gone.
Fixing it
Sequential async in a rule → use effects
Awaiting a unit keeps the scope: after await someFx() or await someEvent() the continuation still runs in the same scope. So wrap external async in an effect instead of awaiting a bare promise.
// ❌ raw await drops the scope
reaction({
on: submitted,
async run() {
const res = await fetch("/api/save"); // scope lost here
saved.value = await res.json(); // throws: Scope is required
},
});
// ✅ the effect owns the request; awaiting the effect keeps the scope
const saveFx = effect(async () => (await fetch("/api/save")).json());
reaction({
on: submitted,
async run() {
saved.value = await saveFx(); // still in scope after the await
},
});This is the answer for almost all async in a model: inside a reaction or effect, only ever await units (someFx(), someEvent(), scoped(...)), never a bare promise.
External callbacks → scoped(scope).wrap
When another system calls you back later — a timer, an event listener, a socket — capture the scope and reopen it for that callback with scoped().wrap(fn). Called with no scope argument, scoped() captures the scope it is currently running in and returns a runner bound to it; .wrap then reruns your callback in that scope whenever it is eventually invoked. You never name the scope by hand.
import { scoped } from "@virentia/core";
reaction({
on: started,
run() {
// `scoped()` captures the scope this reaction is running in.
const inScope = scoped();
const id = setInterval(inScope.wrap(() => tick()), 1000);
onCleanup(() => clearInterval(id));
},
});The same pattern covers listeners and sockets:
const inScope = scoped();
socket.on("message", inScope.wrap((msg) => messageReceived(msg)));
window.addEventListener("resize", inScope.wrap(() => resized()));Create these inside an owner and register onCleanup so the timer/listener is torn down with the model.
Boundaries → name the scope
When you start a unit from a test, a server loader, or a framework adapter, do not rely on an ambient scope — pass it:
await scoped(appScope, () => submitted(form));
scoped(appScope, () => count.value++);Checklist
- Inside a reaction/effect,
awaitonly units. A bareawait fetch()/await delay()means you have left the scope — move it into aneffect. - Any callback handed to a timer, listener, socket, or third-party API must be wrapped:
scoped().wrap(cb). - Build the wrapper with
scoped()while the scope is still active (a reaction body, an effect handler) — it captures the current scope for you — not later inside the callback. - Tear down timers/listeners with
onCleanupin anowner. - At system boundaries pass the scope explicitly via
scoped.
See also Scope Rules for the short version.