Skip to content

Introduce custom/proxy signals for fine-grained read/write control #68280

@vs-borodin

Description

@vs-borodin

Which @angular/* package(s) are relevant/related to the feature request?

core

Description

Currently, Angular provides a robust Signal API, but lacks a primitive to explicitly intercept and control both the get (dependency tracking) and set (update triggering) operations of a WritableSignal while preserving its standard interface.

This makes it difficult to model transactional updates between reactive state and external APIs (Storage, DOM, timers etc.). Relying on effect for synchronization is often insufficient because its asynchronous, coalescing nature cannot guarantee atomic consistency between state and external side effects.

Proposed solution

Introduce a new primitive, such as customSignal or proxySignal, that allows developers to wrap an existing signal or define a new one with intercepted get and set handlers.

Examples (pseudocode)

Persistent signal (external side effects):

function storageSignal<T>(key: string, initialValue: T): WritableSignal<T> {
  const source = signal<T>(initialValue);

  return proxySignal(source, {
    set: (value, node) => {
      /**
       * Ensures a synchronous transaction in which state and storage are updated atomically.
       * This guarantees deterministic side effects, including immediate cross-tab `storage` event dispatch,
       * and avoids scheduling delays and value coalescing inherent to `effect()`.
       */
      localStorage.setItem(key, JSON.stringify(value));
      node.set(value);
    }
  });
}

Additionally, this approach allows us to ensure consistency between the signal state and external side effects:

try {
  // localStorage can throw QuotaExceededError
  localStorage.setItem(key, JSON.stringify(value));
  // the signal is updated only if the write operation succeeds.
  node.set(value);
} catch {
  /* ... */
}

Throttled signal (custom update scheduling):

function throttledSignal<T>(initialValue: T, delay = 200) {
  let lastEmit = 0;

  const source = signal(initialValue);

  return proxySignal(source, {
    set: (value, node) => {
      const wait = delay - (Date.now() - lastEmit);
      const emit = () => { lastEmit = Date.now(); node.set(value); };
      // Custom update scheduler:
      wait <= 0 ? emit() : setTimeout(emit, wait);
    }
  });
}

Fallback signal:

function routeQuerySignal<T>(
  key: string,
  defaultValue?: T
) {
  const source = signal<T | undefined>(undefined);

  return proxySignal(source, {
    get: (node) => {
      const value = node();
      return value !== undefined ? value : defaultValue;
    },
  });
}

Design considerations

  • While get interception enables control over dependency tracking, there are almost no compelling real-world use cases requiring such fine-grained control. It is primarily included for API symmetry and typically delegates directly to the underlying tracking mechanism. However, inspired by patterns such as Vue’s customRef, some libraries use it to either transform values or provide fallback values during read operations.

  • At this stage, the proposal focuses on the conceptual idea with illustrative examples, without defining a concrete API shape. The goal is to discuss the potential of this primitive with the community and contributors, and better understand its demand. In most cases, such functionality is primarily relevant for libraries built on top of signals.

Alternatives considered

  • Using effect(), which is asynchronous. It cannot intercept or modify the value before it is written to the signal. Additionally, we sometimes have to create a consumer that unnecessarily shows up in the dev tools.
  • Creating custom wrapper objects/classes (e.g., { value: Signal, set: (v) => void }), which breaks the WritableSignal API signature.
  • Building custom primitives to manually proxy read/write.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: coreIssues related to the framework runtimecore: reactivityWork related to fine-grained reactivity in the core frameworkcross-cutting: signalsgemini-triagedLabel noting that an issue has been triaged by gemini

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions