Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

signals/core: Add a way to subscribe to signal changes without re-computing values #593

Open
mbeckem opened this issue Aug 8, 2024 · 1 comment

Comments

@mbeckem
Copy link

mbeckem commented Aug 8, 2024

Hi everyone,

first of all thank you for developing @preact/signals-core. Your library has been extremely helpful to manage state across multiple UI frameworks.

I'd like to propose a feature that would make integration with other frameworks even better.

TLDR: Add a way to subscribe to signals without triggering the re-computation of computed signals.

There are currently two ways to observe changes of a signal's value: using effect() directly or calling signal.subscribe(...), which is implemented in terms of effect. Both ways will access signal.value, even if we don't actually need it.
This is not a problem for "normal" signals (the access is cheap since the value is already available), but it may be a problem for computed signals:

  • the computation may be expensive (degrading performance)
  • the computation may have side effects or may throw exceptions

Proposed API

I'd like to propose a new method on the Signal class, analogous to subscribe:

class Signal<T> {
    // Watches the signal and invokes `fn` when the signal's previous value is no longer valid.
    // For plain signals, `fn` will be called when the signals's value has been set.
    // For computed signals, `fn` will be called when a dependency has changed (i.e. when
    // reading `signal.value` _would_ trigger a recomputation).
    onInvalidate(fn: () => void): () => void;
}

The appropriate place to invoke these callbacks appears to be the existing Signal._notify() method.

Use cases

  • Many APIs are based on reading values at specific times (often controlled by the framework) and events that tell the framework that a previously read value has become outdated.
    I'm using React's useSyncExternalStore hook for this example, but the principle applies to other APIs, too.

    function Component() {
        // getSnapshot must return the current value, usually with caching.
        // subscribe shall be called whenever that value changes; this effectively triggers
        // a rerender and as a consequence another call to `getSnapshot`.
        const currentValue = useSyncExternalStore(subscribe, getSnapshot);
    }

    With a computed signal:

    const mySignal = computed(() => /* non-trivial code */);
    
    function Component() {
        const getSnapshot = useCallback(
            // We can just read the value here, and get automatic caching. Great!
            () => mySignal.value, []
        );
        const subscribe = useCallback((cb) => {
            // We must read `.value` here to become notified about changes.
            // We will be notified correctly, but we can end up recomputing `.value` very often - without even using it!
            return effect(() => {
                mySignal.value;
                untracked(cb);
            });
        }, []);
        const currentValue = useSyncExternalStore(subscribe, getSnapshot)
    }

    In a typical UI, the Component above will only render (at most) ~60 times a second.
    However, if mySignal's dependencies change often, the effect will also trigger as often, needlessly recomputing mySignal.value.
    Error handling also suffers a bit in my opinion: if mySignal.value throws in the effect, it is not obvious where to report the error. But throwing from getSnapshot would usually be acceptable for error handling.

    With the proposed API:

    const mySignal = computed(() => /* non-trivial code */);
    
    function Component() {
        const getSnapshot = useCallback(
            () => mySignal.value, []
        );
        const subscribe = useCallback((cb) => {
            // As cheap as possible. Nice!
            return mySignal.onInvalidate(cb);
        }, []);
        const currentValue = useSyncExternalStore(subscribe, getSnapshot)

    Note that there are ways to work around this problem without changing the code of the library.
    However, all my attempts have made reading and subscribing very complicated / brittle, which kinda defeats the purpose.

  • I sometime wish to create a "tracked context" (i.e. recording signal accesses) without scheduling automatic executions like effect() does. I just want to be notified when any of the signals that were used has changed.
    Your react runtime integration seems to do the same thing, using the internal methods of Effect.
    I realized that this is just a special case of the use case above, and listening to signal invalidations with the proposed API could make this approach less brittle:

    1. Wrap the code of the "tracked context" (e.g. a React component) into a computed signal.
      The signal tracks the dependencies for us.
      (If one does not want the automatic caching, an artificial dependency can be used to invalide the signal).
    2. Listen for changes via signal.onInvalidate(). When a change happens, simply rerender the component.

Additional thoughts / alternatives

  • Instead of onInvalidate becoming a method, it could also be an option during signal creation.
    Other signal options have already been proposed, for example in Subscription integration for third-party libraries #351.

  • This can also be implemented by calling internal Signal methods (or patching them slightly), which requires using the minified names of those methods.
    I find that very brittle, and I believe this deserves to be supported natively in some way.

  • The signals proposal uses a Watcher class to achieve a similar thing.
    One or more signals can be watched, and any change will trigger a notify callback execution. without explicitly computing the new value. API copied for reference:

    class Watcher {
        // When a (recursive) source of Watcher is written to, call this callback,
        // if it hasn't already been called since the last `watch` call.
        // No signals may be read or written during the notify.
        constructor(notify: (this: Watcher) => void);
    
        // Add these signals to the Watcher's set, and set the watcher to run its
        // notify callback next time any signal in the set (or one of its dependencies) changes.
        // Can be called with no arguments just to reset the "notified" state, so that
        // the notify callback will be invoked again.
        watch(...s: Signal[]): void;
    
        // Remove these signals from the watched set (e.g., for an effect which is disposed)
        unwatch(...s: Signal[]): void;
    
        // Returns the set of sources in the Watcher's set which are still dirty, or is a computed signal
        // with a source which is dirty or pending and hasn't yet been re-evaluated
        getPending(): Signal[];
    }
  • The signals proposal also sketches an implementation of effect based on computed signals + Watcher that I found interesting.
    This technique could be used to (mostly) unify the implementation Effect and Computed..

@developit
Copy link
Member

developit commented Sep 13, 2024

Thanks for the detailed writeup.

Wrapping things in a computed does not cover the use-cases we have in the react and preact adapters, as the start and end of the tracking context are not implemented via the JS stack. Doing so would require wrapping React components, which is a huge can of worms - you have to copy properties onto the wrapper function, it breaks classes, changes stack traces in errors, adds overhead to every component, etc. Having the ability to start and stop a completely custom tracking context is lower-level and accommodates all synchronous use-cases.

import {effect} from '@preact/signals-core';

function watcher(onNotify: () => void): () => () => void {
  let start;
  effect(function () {
    this.N = onNotify;
    start = this.S.bind(this);
  });
  return start;
}

// usage:
const start = watcher(() => console.log('accessed signal changed'));
const end = start();
try {
  // access some signals here
} finally {
  end();
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants