DEV Community

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 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. 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);
  • 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. 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 AggregateError containing 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() or set();
  • 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:

  1. The source becomes disposed and rejects new subscriptions.
  2. One final invalidation is dispatched to listeners active when disposal began.
  3. Every remaining subscription is discarded.
  4. 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:

  • dependencies must be an Array. Its entries must be dependency objects.
  • compute must be callable. Otherwise, derive() throws a TypeError.

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 - 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;
  • 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:

  1. Mark the derivation as computing.
  2. Establish or refresh the required dependency subscriptions atomically.
  3. Call compute with a fresh Array containing the declared dependencies.
  4. Capture either the returned value or thrown value as a completion.
  5. 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.
  6. 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:

  1. Establish or refresh selection-dependency subscriptions before calling the resolver.
  2. Call resolveDependencies with a fresh selection-dependency Array.
  3. Copy and validate the returned computation-dependency Array.
  4. Atomically replace the active combined subscription set.
  5. Commit the new computation dependency list before calling compute.
  6. Call compute with a fresh Array containing 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.