@williamwutq/scopeflow
v0.1.0
Published
A strongly typed, framework-independent scope-based event-reactive model for TypeScript: scopes own state and behavior for a meaningful lifetime, events are the communication boundary between them.
Maintainers
Readme
scopeflow
A scope owns state and behavior for a meaningful lifetime; events are how that behavior communicates, and dropping the scope ends that lifetime.
A strongly typed, framework-independent scope/event model for TypeScript. Built on @williamwutq/noexcept — state defaults come from Default/Default.option, and nothing in this library throws: every fallible operation returns a Result.
Overview
A scope (modal, editor, chat panel, ...) is a self-contained unit of stateful behavior, not a DOM element. It has:
- a parent (except the root), and zero or more children;
- named state, initialized from declared defaults, readable only from inside its own handlers;
- event handlers, registered per event name;
- drop hooks, for lifecycle/resource cleanup — a separate concern from behavior.
Parents and children never touch each other's state directly. A parent can only send events to a child's handle; a child can only propagate events up via toParent. This boundary is enforced by the type system, not just convention.
import { Default, NonEmptyString, Refinement } from "@williamwutq/noexcept";
import { defineHandlers, PARENTLESS, useScope, type InferState } from "@williamwutq/scopeflow";
interface PanelEvents {
tokenSaved: { field: NonEmptyString; value: NonEmptyString };
}
const { on: onPanel } = defineHandlers<object, PanelEvents>();
const panel = useScope<PanelEvents>(PARENTLESS, "panel", {}, [
onPanel("tokenSaved", (_ctx, payload) => console.log(`saved: ${payload.field}`)),
]).unwrap();
const Edit = Refinement.shape({ field: NonEmptyString, value: NonEmptyString });
const tokensState = { editing: Default.option(Edit) };
type TokensState = InferState<typeof tokensState>;
interface TokensEvents {
save: { field: NonEmptyString; value: NonEmptyString };
}
const { on: onTokens } = defineHandlers<TokensState, TokensEvents, PanelEvents>();
const tokens = useScope<TokensEvents, typeof tokensState, PanelEvents>(panel, "tokens", tokensState, [
onTokens("save", (ctx, payload) => ctx.toParent("tokenSaved", payload)),
]).unwrap();
tokens.send("save", { field: NonEmptyString.parse("title")!, value: NonEmptyString.parse("Hi")! });See src/examples/panel-tokens.ts for a full worked example (state, defaults, edit/save, drop hooks) — run it with bun src/examples/panel-tokens.ts.
Core concepts
| Concept | What it is |
|------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------|
| createScope / useScope | The core primitive, and the ergonomic wrapper that turns a state declaration (Default/Default.option fields) into initialized state |
| ScopeHandle<M> | What a parent holds: send, drop, alive — no state access |
| ScopeContext<S, M, PM> | What a handler sees: state, update, emit (self), toParent, dropSelf |
| defineHandlers<S, M, PM>() | Returns a bound on/onDrop pair for one scope's types |
| PARENTLESS | Pass as the owner argument to create a root scope — a named null |
| ScopeError | Every failure mode (scope-dropped, no-parent, invalid-owner, duplicate-sibling, invalid-name), returned in a Result, never thrown |
Dispatch is deterministic: each scope drains its own FIFO queue, so self-emits during a handler run breadth-first, and re-entrant emit/dropSelf calls queue rather than nest. Dropping a scope tears down its children first (in creation order), then its own drop event, then its drop hooks, then marks it dead — a dead scope rejects every further send/emit/toParent/update via Result, never by throwing.
Scripts
bun run build # emit dist/ with declarations
bun run typecheck # tsc --noEmit (includes *.typecheck.ts compile-time assertions)
bun run lint # eslint
bun test # bun test
bun run check # typecheck + lint + testLicense
MIT © William Wu
