preactjs / signals

Manage state with style in every framework
https://preactjs.com/blog/introducing-signals/
MIT License
3.78k stars 93 forks source link

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

Open mbeckem opened 2 months ago

mbeckem commented 2 months ago

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:

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

Additional thoughts / alternatives

developit commented 1 month ago

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();
}