Proposed API Reference
API overview
The proposal introduces a global Reactive namespace with three factory functions:
Reactive.createSource()Reactive.derive(dependencies, compute)Reactive.deriveDynamic(selectionDependencies, resolveDependencies, compute)
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().
Informative TypeScript declarations
declare namespace Reactive {
type Unsubscribe = () => void;
interface Dependency {
onInvalidate(listener: () => void): Unsubscribe;
}
interface Source extends Dependency {
invalidate(): void;
[Symbol.dispose](): void;
}
interface Derivation<T> extends Dependency {
get(): T;
invalidate(): void;
[Symbol.dispose](): void;
}
function createSource(): Source;
function derive<
const D extends readonly Dependency[],
T,
>(
dependencies: D,
compute: (dependencies: D) => 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,
): Derivation<T>;
}
These declarations are informative. ECMAScript does not expose the generic types.
Terminology
- Dependency - A
Dependencyis an object with a callableonInvalidatemethod 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. The core does not compare old and new values.
- 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 Dependency protocol
A conforming dependency provides:
const unsubscribe = dependency.onInvalidate(listener);
listenermust be callable. Otherwise,onInvalidate()throws aTypeError.- 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
unsubscribefunction is callable and idempotent. - Calling the
unsubscribefunction after removal is a no-op. - Unsubscribing does not invoke the listener.
- Invalidation listeners are invoked synchronously. 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 and dispatch behavior required by this proposal.
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 or disposal operation returns.
Each call to Source.prototype.invalidate() represents a distinct source invalidation. Queued source invalidations are not deduplicated. A derivation does deduplicate 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]().
Calling an already-created unsubscribe function remains permitted. 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
AggregateErrorcontaining those values is thrown.
Listener failures do not roll back the mutation or invalidation that caused dispatch.
Reactive.createSource()
const source = Reactive.createSource();
Creates a live, valueless Source with no listeners. 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();
Synchronously notifies the source’s active listeners. The method returns undefined. Each call is a separate invalidation. A source does not suppress repeated invalidations. Calling invalidate() after the source has been disposed is a no-op.
Example:
let count = 0;
const countChanged = Reactive.createSource();
function increment() {
count++;
countChanged.invalidate();
}
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.prototype[Symbol.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.
- One final invalidation is dispatched to listeners active when disposal began.
- 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)
const total = Reactive.derive(
[price, quantity],
([price, quantity]) => price.get() * quantity.get(),
);
Creates a lazy Derivation with a static dependency list.
Arguments:
dependenciesmust be anArray. Its entries must be dependency objects.computemust be callable. Otherwise,derive()throws aTypeError.
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
compute; - call a dependency’s
onInvalidate()method; - subscribe to any dependency;
- produce a cached completion.
The derivation remains unevaluated until its first get().
Reactive.deriveDynamic(selectionDependencies, resolveDependencies, compute)
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.
- Selection dependencies -
selectionDependenciesis a staticArrayof dependencies capable of changing which computation dependencies should be used. TheArrayis 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 -
resolveDependenciesmust be callable. It receives a freshArraycontaining the selection dependencies and must return anArrayof computation dependencies. The returnedArrayis 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;
- 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.
Derivation.prototype.get()
const value = derivation.get();
Returns the derivation’s current value, evaluating it when necessary.
Clean derivation
If the derivation is clean:
- a cached normal completion is returned;
- a cached abrupt completion is rethrown.
No resolver, compute callback, or dependency method is called.
Unevaluated or stale derivation
If the derivation is unevaluated or stale, get() evaluates it synchronously.
Circular evaluation
If get() is called on a derivation which is already computing in the current synchronous evaluation chain, it throws a TypeError. This detects direct and indirect cycles:
A.get()
└─ B.get()
└─ A.get() // TypeError
If this error escapes a compute callback, it is cached like any other compute-thrown value.
Disposed derivation
Calling get() after disposal throws a TypeError.
Evaluation semantics
Static derivation
An evaluation attempt for a static derivation behaves as follows:
- Mark the derivation as
computing. - Establish or refresh the required dependency subscriptions atomically.
- Call
computewith a freshArraycontaining the declared dependencies. - Capture either the returned value or thrown value as a completion.
- Check whether the derivation was invalidated or disposed during the attempt.
- If disposed, discard the completion and throw a
TypeError. - If invalidated, discard the completion and retry evaluation.
- If disposed, discard the completion and throw a
- Otherwise, cache the completion, mark the derivation
clean, and return or throw it.
Dynamic derivation
A dynamic evaluation additionally behaves as follows when dependency selection is required:
- Establish or refresh selection-dependency subscriptions before calling the resolver.
- Call
resolveDependencieswith a fresh selection-dependencyArray. - Copy and validate the returned computation-dependency
Array. - Atomically replace the active combined subscription set.
- Commit the new computation dependency list before calling
compute. - Call
computewith a freshArraycontaining the committed computation dependencies.
Committing the dependency set before compute ensures that an invalidation occurring during the computation is observed.
Invalidation during evaluation
An invalidation received during resolver execution, subscription replacement, or compute execution invalidates the current attempt. A completion produced by an invalid attempt is never cached. Evaluation retries with the latest dependency state. Resolvers and compute callbacks are expected to stabilize. If every attempt synchronously invalidates the derivation, get() does not terminate. The API does not impose an observable retry limit.
Atomic subscription replacement
A derivation maintains at most one committed internal subscription per unique dependency identity. When the required set changes…
Comments
No comments yet. Start the discussion.