This appendix defines the concrete API surface and observable semantics of the explicit reactive-dependency proposal.
The API is intentionally low-level. It provides invalidation sources, lazy cached derivations, synchronous notification, deterministic disposal, notification batching, opt-in equality cutoff, and revision-based cache validation. It does not provide writable value cells, ambient dependency tracking, effects, scheduling policy, ownership, or asynchronous computation semantics.
API overview
The proposal introduces a global Reactive namespace with the following functions:
Reactive.createSource(options)Reactive.derive(dependencies, compute, options)Reactive.deriveDynamic(selectionDependencies, resolveDependencies, compute, options)Reactive.batch(callback)Reactive.revision()Reactive.inspect(target)
The factories produce two kinds of built-in objects:
| Object | Purpose |
|---|---|
| Source | A valueless announcement that associated state may have changed |
| Derivation | A lazy cached computation over explicit dependencies |
Both objects implement the Dependency protocol through onInvalidate(), and both additionally implement the optional revision extension through revision().
Informative TypeScript declarations
declare namespace Reactive {
type Unsubscribe = () => void;
type Revision = number;
interface Dependency {
onInvalidate(listener: () => void): Unsubscribe;
/**
* Optional revision extension. See "The revision extension".
*/
revision?(): Revision;
}
interface SourceOptions {
name?: string;
}
interface Source extends Dependency {
invalidate(): void;
revision(): Revision;
[Symbol.dispose](): void;
}
interface DerivationOptions<T> {
name?: string;
/**
* Opt-in equality cutoff. Never called with thrown values.
*/
equals?: (previous: T, next: T) => boolean;
}
interface Derivation<T> extends Dependency {
get(): T;
invalidate(): void;
revision(): Revision;
[Symbol.dispose](): void;
}
function createSource(options?: SourceOptions): Source;
function derive<
const D extends readonly Dependency[],
T,
>(
dependencies: D,
compute: (dependencies: D) => T,
options?: DerivationOptions<T>,
): Derivation<T>;
function deriveDynamic<
const S extends readonly Dependency[],
const D extends readonly Dependency[],
T,
>(
selectionDependencies: S,
resolveDependencies: (
selectionDependencies: S,
) => D,
compute: (dependencies: D) => T,
options?: DerivationOptions<T>,
): Derivation<T>;
function batch<T>(callback: () => T): T;
function revision(): Revision;
interface InspectionRecord {
kind: "source" | "derivation";
name: string | undefined;
state:
| "unevaluated"
| "computing"
| "clean"
| "stale"
| "disposed";
watched: boolean;
revision: Revision;
dependencies: Dependency[] | undefined;
}
function inspect(
target: Source | Derivation<unknown>,
): InspectionRecord;
}
These declarations are informative. ECMAScript does not expose the generic types.
Terminology
Dependency
A Dependency is an object with a callable onInvalidate method that follows the contract defined below.
A dependency does not need to contain a readable value. It represents the capability to announce that associated state may have changed.
Invalidation
An invalidation means "this dependency may have changed." It does not imply that an observable value is different.
Invalidation is conservative by default. A derivation constructed without an equals option performs no value comparison. A derivation constructed with equals participates in equality cutoff as described under "Value revisions and equality cutoff".
Completion
A derivation caches a JavaScript completion produced by its compute callback:
- a normal completion, containing a returned value; or
- an abrupt completion, containing a thrown JavaScript value.
Both forms are memoized.
Notification callback
A callback registered with onInvalidate() is a notification callback. It is a wake-up or scheduling hook, not an effect and not a stable-snapshot callback.
The revision clock
Each agent maintains a single monotonically increasing revision clock. The clock advances by at least one immediately before any built-in source or derivation dispatches invalidation to its listeners, and immediately before any manual or upstream invalidation transitions a derivation from clean to stale.
Reactive.revision() returns the current clock value. Revision values are primitive numbers, are totally ordered within an agent, and have no meaning across agents.
Every built-in source and derivation records the clock value at which it last invalidated. This enables cache validation by integer comparison, without requiring a subscription (see "Watched and unwatched derivations").
Informative note: implementations should ensure the clock cannot plausibly exhaust safe-integer range during a program's lifetime; a program invalidating one million times per second would take longer than 285 years to do so.
Value revision
Each derivation additionally records a value revision: the clock value at which its cached completion last changed. A reevaluation that produces an equal result (per the derivation's equals option) does not advance the derivation's value revision. Derivation.prototype.revision() returns the value revision.
For a source, revision() returns the clock value of its most recent invalidation, or 0 if it has never invalidated.
Watched
A source or derivation is watched while it has at least one active notification subscription. Subscriptions created internally by downstream derivations count. Watchedness determines whether a derivation maintains upstream subscriptions (see "Watched and unwatched derivations").
The Dependency protocol
A conforming dependency provides:
const unsubscribe =
dependency.onInvalidate(listener);
listener must be callable. Otherwise, onInvalidate() throws a TypeError.
A conforming implementation must satisfy the following rules:
- Registering a listener does not invoke it.
- Each registration creates an independent subscription.
- Registering the same function more than once creates multiple subscriptions.
- The returned unsubscribe function is callable and idempotent.
- Calling the unsubscribe function after removal is a no-op.
- Unsubscribing does not invoke the listener.
- Invalidation listeners are invoked synchronously, except as deferred by an active batch (see "Reactive.batch()").
- Listeners receive no arguments; their return values are ignored.
- If the dependency permanently disables future subscriptions, it issues one final invalidation before discarding existing subscriptions.
- Listener mutation, re-entrancy, and failure follow the dispatch rules below.
User-defined dependencies should generally delegate their listener storage and dispatch to a built-in Source. This guarantees the notification restrictions, dispatch behavior, and batching integration required by this proposal, and provides the revision extension automatically.
The revision extension
A dependency may additionally expose:
const rev = dependency.revision();
returning the clock value at which the dependency most recently invalidated, or 0 if it has never invalidated. Built-in sources and derivations implement this method. A user-defined dependency that delegates to an internal Source can implement it by forwarding to that source.
The extension is optional. A dependency lacking revision() is fully conforming; derivations that depend on it simply cannot validate its cache state without a subscription, and fall back to subscription retention as described under "Watched and unwatched derivations".
Notification dispatch
Built-in sources and derivations use the same dispatch rules.
Snapshot semantics
At the beginning of each dispatch pass, the notifier records the subscriptions that are currently active.
During that pass:
- a listener added during dispatch is not invoked;
- a listener removed before its turn is skipped;
- removing one subscription does not affect another registration of the same callback;
- listeners are considered in registration order.
Re-entrancy
A recursive invalidation of the same built-in notifier is queued rather than dispatched recursively.
Queued dispatches are drained synchronously before the outermost invalidation, disposal, or batch-flush operation returns.
Each call to Source.prototype.invalidate() outside a batch represents a distinct source invalidation. Queued source invalidations outside a batch are not deduplicated.
A derivation deduplicates stale propagation: after it has become stale, additional invalidations do not notify its downstream listeners again until it successfully becomes clean.
Notification restrictions
While a built-in source or derivation is invoking a public notification callback, user code must not synchronously read from or mutate the built-in reactive graph.
The following operations throw a TypeError during such a callback:
-
derivation.get(); -
source.invalidate(); -
derivation.invalidate(); -
source.onInvalidate(); -
derivation.onInvalidate(); -
source[Symbol.dispose](); -
derivation[Symbol.dispose](); -
Reactive.batch().
Calling an already-created unsubscribe function remains permitted. Reactive.revision() and Reactive.inspect() remain permitted; they read bookkeeping, not graph values.
Creating a source or constructing an unevaluated derivation remains permitted because construction does not access or mutate an existing graph.
Implementation-created callbacks used for internal invalidation propagation are exempt from this restriction. If a user-defined dependency invokes such an internal callback from inside a public notification callback, the internal callback throws a TypeError rather than mutating the graph.
These restrictions prevent a notification callback from observing or creating a partially propagated graph. A callback should enqueue later work instead:
view.onInvalidate(() => {
queueMicrotask(() => {
reconcile(view.get());
});
});
Listener failures
Every eligible listener is attempted even when an earlier listener throws.
After propagation completes:
- if exactly one listener threw, that value is rethrown;
- if multiple listeners threw, an AggregateError containing those values is thrown.
Listener failures do not roll back the mutation or invalidation that caused dispatch.
Reactive.batch(callback)
Reactive.batch(() => {
count++;
countChanged.invalidate();
total++;
totalChanged.invalidate();
});
Runs callback synchronously and returns its result. While a batch is active, public notification callbacks are deferred and coalesced. Internal staleness propagation is not deferred.
Semantics
callback must be callable. Otherwise, batch() throws a TypeError.
While the batch is active:
- Internal invalidation propagation remains synchronous and immediate. Derivations become stale at the moment their dependency invalidates. Reads inside the batch therefore never observe a cache that a completed mutation should have invalidated.
- Public notification callbacks are not invoked. Instead, each subscription that would have been notified is recorded at most once.
-
get(),invalidate(), subscription, and disposal all remain permitted inside the batch body, subject to the ordinary rules.
When the outermost batch exits — whether callback returned or threw — a single flush pass runs:
- Deferred notifications are dispatched, at most once per recorded subscription, grouped by notifier, in registration order within each notifier, in the order the notifiers first recorded a deferred notification.
- The standard dispatch rules apply during the flush, including the notification restrictions and re-entrancy queueing.
- If a notifier was disposed during the batch, its deferred final invalidation is dispatched during the flush, after which its remaining subscriptions are discarded.
Batches nest. Only the outermost batch() call flushes.
Failure semantics
If callback throws and no flushed listener throws, the callback's thrown value is rethrown after the flush completes.
If listeners also throw during the flush, an AggregateError containing the callback's thrown value followed by the listener failures is thrown.
A batch does not roll back mutations or invalidations performed before the failure.
Interaction with source deduplication
Outside a batch, each invalidate() call on a source dispatches separately. Inside a batch, multiple invalidate() calls on the same source coalesce into at most one deferred notification per subscription. Frameworks requiring per-call granularity should not batch those invalidations.
Reactive.createSource(options)
const source = Reactive.createSource({
name: "cartChanged",
});
Creates a live, valueless Source with no listeners.
options is optional. options.name, when present, is coerced to a string and used only for introspection and diagnostic messages.
A source does not:
- store application state;
- expose
get()orset(); - compare values;
- evaluate derivations;
- schedule work.
Its role is only to announce that associated state may have changed.
Source.prototype.invalidate()
source.invalidate();
Advances the revision clock, records the new clock value as the source's revision, and notifies the source's active listeners — synchronously outside a batch, deferred inside one.
The method returns undefined.
Outside a batch, each call is a separate invalidation; a source does not suppress repeated invalidations. Inside a batch, repeated invalidations coalesce as described under "Reactive.batch()".
Calling invalidate() after the source has been disposed is a no-op and does not advance the clock.
Example
let count = 0;
const countChanged =
Reactive.createSource();
function increment() {
count++;
countChanged.invalidate();
}
Source.prototype.revision()
const rev = source.revision();
Returns the clock value of the source's most recent invalidation, or 0 if it has never invalidated. Disposal counts as an invalidation for this purpose.
Source.prototype.onInvalidate(listener)
const unsubscribe =
source.onInvalidate(listener);
Registers listener and returns an idempotent unsubscribe function.
Registration does not invoke listener.
Calling onInvalidate() after disposal throws a TypeError.
Source.prototypeSymbol.dispose
source[Symbol.dispose]();
Permanently disables the source.
Disposal is idempotent. The first call performs the following observable steps:
- The source becomes disposed and rejects new subscriptions.
- The revision clock advances and the source records the new value as its revision.
- One final invalidation is dispatched to listeners active when disposal began (deferred if a batch is active).
- Every remaining subscription is discarded.
- Any listener failures are reported after cleanup completes.
The final invalidation prevents a clean downstream derivation from silently retaining a cached result after its dependency becomes unavailable.
After disposal:
source.invalidate(); // no-op
source[Symbol.dispose](); // no-op
source.onInvalidate(() => {}); // TypeError
Reactive.derive(dependencies, compute, options)
const total = Reactive.derive(
[price, quantity],
([price, quantity]) =>
price.get() * quantity.get(),
{
name: "total",
equals: Object.is,
},
);
Creates a lazy Derivation with a static dependency list.
Arguments
dependencies must be an Array. Its entries must be dependency objects.
compute must be callable. Otherwise, derive() throws a TypeError.
options is optional.
-
options.nameis coerced to a string and used only for introspection and diagnostic messages. -
options.equals, when present, must be callable and enables equality cutoff (see "Value revisions and equality cutoff"). When absent, every successful reevaluation is treated as producing a changed value.
The dependency Array is synchronously copied when derive() is called. Later mutation of the caller's Array does not affect the derivation.
Array order, length, and duplicate positions are preserved for the compute callback. Dependencies are deduplicated by object identity only for internal subscription management.
Construction behavior
Construction does not:
- call
computeorequals; - call a dependency's
onInvalidate()orrevision()method; - subscribe to any dependency;
- produce a cached completion.
The derivation remains unevaluated until its first get() or until it becomes watched (see "Connection").
Reactive.deriveDynamic(selectionDependencies, resolveDependencies, compute, options)
const selected =
Reactive.deriveDynamic(
[mode],
([mode]) =>
mode.get() === "advanced"
? [advanced]
: [basic],
([selectedValue]) =>
selectedValue.get(),
);
Creates a lazy derivation whose computation dependencies may change over time.
options behaves as for derive().
Selection dependencies
selectionDependencies is a static Array of dependencies capable of changing which computation dependencies should be used.
The Array is copied at construction. Its order and duplicate positions are preserved when passed to the resolver.
The resolver is not ambiently tracked. Every mutable input capable of changing its result must be represented by a selection dependency.
Resolver
resolveDependencies must be callable. It receives a fresh Array containing the selection dependencies and must return an Array of computation dependencies.
The returned Array is copied before it is committed. Later mutation does not affect the derivation.
Computation dependencies
The resolver's returned dependencies are passed, in their original order and with duplicates preserved, to compute.
The active internal subscription set is the identity-deduplicated union of:
- the selection dependencies; and
- the current computation dependencies.
If the same dependency occurs in both groups, only one internal subscription is created. An invalidation from that dependency is treated as a selection invalidation.
When the resolver runs
The resolver runs:
- during the first evaluation;
- during connection of an unevaluated derivation (see "Connection");
- after a selection dependency invalidates;
- after
derivation.invalidate()is called manually; - after a previously cached resolver failure is invalidated.
An invalidation from a computation-only dependency does not require the resolver to run again. The derivation may reuse its committed computation dependency list and rerun only compute.
Derivation states
A derivation is in exactly one of the following states:
| State | Meaning |
|---|---|
| unevaluated | No completion has been cached |
| computing | The resolver or compute callback is active |
| clean | A cached completion is current |
| stale | The cached completion may be outdated |
| disposed | The derivation is permanently disabled |
The clean state may contain either a cached return value or a cached thrown value.
Independently of its state, a derivation is either watched or unwatched.
Watched and unwatched derivations
A derivation is watched while at least one notification subscription to it is active. Subscriptions created internally by downstream derivations count, so watchedness propagates transitively upstream through built-in derivations.
Watch
Top comments (0)