Scope and Resource Lifetime
A practical mental model for “when logic starts, when it gets interrupted, and when cleanup runs” in Effect / Logix.
Many “mysterious bugs” have the same root cause:
You assumed a piece of logic is attached to lifecycle A, but it is actually attached to lifecycle B’s
Scope.
Once you get Scope right, onDestroy, cancellation, interruption, finalizers, and React Strict Mode mount/unmount behavior all become explainable with one model.
1) What is a Scope?
In Effect, a Scope is a resource attachment point:
- resources register themselves on a
Scope(connections, subscriptions, background Fibers, registry entries, …); - when the
Scopeis closed:- long-lived Fibers attached to it are interrupted;
- registered finalizers run (cleanup).
2) The 4 APIs you must be able to map
These four APIs form the minimal “resource lifetime” grammar:
Effect.scoped(effect): open a temporary Scope, runeffect, and close the Scope automatically.Layer.scoped(Tag, effect): mount a lifecycle-managed service onto the current Scope.Effect.forkScoped(effect): attach a Fiber to the current Scope (interrupted on Scope close).Effect.addFinalizer(cleanup): register cleanup on the current Scope (runs on Scope close).
Key point:
Effect.addFinalizer(...)runs when the current Scope closes, not when the current Fiber finishes. If you need “cleanup when this Effect finishes”, useEffect.acquireRelease/Effect.ensuring.
3) The 3 questions to locate the Scope boundary
To answer “when does it get cut off / when does cleanup happen”, ask:
- Who created the current Scope? (
Effect.scoped/Layer.buildWithScope/ a React hook / your host code) - Who closes it? (
runtime.dispose()/Scope.close(...)/ React unmount / key change / GC) - What is attached to it? (Fibers forked via
forkScoped, cleanup registered viaaddFinalizer)
4) Common Scope boundaries in Logix (know these by heart)
Logix “productizes” lifecycles, but it’s still Scope underneath:
4.1 Runtime Scope (global)
- held by the Runtime created via
Logix.Runtime.make(...) - typical close:
- the host calls
runtime.dispose(); - Node/CLI uses
Runtime.runProgram/openProgram, which closesctx.scopeon finish/signal.
- the host calls
- React:
RuntimeProviderdoes not automatically callruntime.dispose()(a runtime may be shared). Therefore:- typical SPA: export a module-level singleton runtime (don’t create it in render) and you usually don’t need an explicit dispose;
- micro-frontends / repeated mount-unmount / tests: call
runtime.dispose()at the host boundary that created the runtime (e.g. micro-frontendunmount(), or your React root teardown).
- impact: global modules resolved via
useModule(Tag)runonDestroyhere.
4.2 Local Module Scope (local / multi-instance)
- created/managed by
useModule(Impl)/useLocalModule(Module)/ModuleScope(usually with caching andgcTime) - typical close: after the last holder unmounts (optionally delayed by
gcTime) - impact: local module
onDestroyis “instance scope closes”, not “a single component unmounts”.
4.3 RuntimeProvider.layer Scope (local Env)
- each
RuntimeProvider.layerbuilds itsLayerinside an independent Scope - typical close: Provider unmounts, or the
layerreference changes and triggers a rebuild - impact: affects only subtree Env overrides (services / logger / debug sinks, etc.), not
runtime.dispose().
5) addFinalizer vs ensuring/acquireRelease: don’t mix them up
5.1 You want “cleanup when Scope closes” → use addFinalizer
Typical: register/unregister, subscribe/unsubscribe, registry entries.
const Logic = M.logic(($) =>
Effect.gen(function* () {
const unsubscribe = subscribeSomething()
yield* Effect.addFinalizer(() => Effect.sync(() => unsubscribe()))
}),
)5.2 You want “cleanup when this Effect finishes” → use ensuring/acquireRelease
Typical: open → use → close, even on failure/interruption.
const program = Effect.acquireRelease(
Effect.sync(() => openResource()),
(res) => Effect.sync(() => closeResource(res)),
).pipe(Effect.flatMap((res) => useResource(res)))6) Common pitfalls (worth checking in code review)
- Treating
useModule(Tag)as “local state disposed on component unmount”: wrong. It’s Runtime-scoped; lifetime is driven byruntime.dispose(). - Creating a new
Layer/ new deps array every render underRuntimeProvider: causes frequent Provider layer Scope rebuilds; memoize withuseMemo. - Doing IO / reading Env in the setup phase of
logic(): likely to triggerlogic::invalid_phase/logic::setup_unsafe_effect; move IO intoonInitRequired/onStartor run-phase watchers. - Expecting
addFinalizerto run when a watcher finishes: wrong.addFinalizeris Scope-bound; useensuring/acquireReleasefor task-level cleanup.
Next
- Watcher mounting/stopping patterns: Lifecycle and Watchers.
- “Actually cancel the HTTP request”: Cancelable IO (cancellation and timeouts).