tc39 / proposal-signals

A proposal to add signals to JavaScript.
MIT License
2.95k stars 54 forks source link

A critical primitive: `Signal.Derived` (in addition to `Signal.Computed`) should be added. #155

Open onurkerimov opened 1 month ago

onurkerimov commented 1 month ago

First of all, thanks to all contributors.

In my opinion, a critical API primitive is missing from this proposal. In addition to Signal.Computed, there should be Signal.Derived. (I'm going to call this Derived in this document, the naming can be changed if they look like they mean the same thing) I'm the author of a state management library called xoid, and I had chance to think about these topics in the recent years, and I feel like I have something to add after examining the proposal.

Rationale

This proposal involves two concepts which don't necessarily come together: "push-then-pull" model, and implicit dependency tracking, and it looks like it doesn't provide a way to use the built-in "push-then-pull" model without using implicit-dependency-tracking related native code.

Is this going to make the proposal better for library authors? Yes, I strongly believe so.

Is this going to confuse some people? Yes. I wouldn't bring this up if it didn't have crucial benefits which I'll explain further in this document. If you also agree with the benefits I'll explain, but if you think that adding an extra thing would harm the learning curve of this API, it also might be an option to place the related functionality under the.subtle key. If it's already possible using the current.subtle interface, consider this issue as a question/request-for-docs and please demonstrate so. It'll be appreciated!

What is Signal.Derived?

Signal.Derived would be the "explicit dependency collection" version of Signal.Computed. Let me demonstrate the usages of the two:

const counter = new Signal.State(0);

const isEven = new Signal.Computed(() => (counter.get() & 1) == 0);
const parity = new Signal.Computed(() => isEven.get() ? "even" : "odd");

// This is the proposal:
const isEven_explicit = new Signal.Derived((get) => (get(counter) & 1) == 0);
const parity_explicit = new Signal.Derived((get) => get(isEven) ? "even" : "odd");

Computed and Derived are similar contructors. Computed collects dependencies automatically when .get() is used, and Derived, which I propose, collects them explicitly via its own collector/"getter" function. (Perhaps using the word getter for this one should be avoided) If you've used Recoil before, then you may find this familiar. In Recoil selector function is used to create selector atoms, which is similar to the concept of computed signals. In selector, there's no implicit/automatic dependency collection in the callback function. Everyting needs to be wrapped inside get(...).

My criticism with the current state of the proposal is that building an explicit-collection-based library like Recoil, or Jotai, or xoid, doesn't seem trivial as building implicit/magical-collection-based libraries such as MobX, Valtio, @vue/reactivity. Both families of libraries make use of the "push-then-pull" model built-in to this proposal, and in the current proposed API, implementing the former family of libraries seem more convoluted than the latter.

Why this would be a problem? Is the explicit-collection as the "default" behavior, and the implicit ones are an "enhancement"? I think, yes, and I'll try to explain it. You can prove me wrong here, and I'd love to learn if I'm wrong about this. It seems very obvious to me that Derived precedes Computed. Because, implicit tracking is the opt-in one. It's less intuitive to think the other way around. Computed requires more work than Derived, because it holds a "keeping the current context/listener" while intercepting "get()" calls. I'll explain more concretely why it involves more work (computation) in the next section. One of my most important standing points is that it requires more computation, and therefore the its better to expose the API primitive that does less work (and is also useful as is).

Why Derived is a more fundamental API primitive than Computed

Imagine we have Signal.Derived. Using it, an oversimplified version of implementing Signal.Computed looks like this:

const NOOP = () => {}
let currentGetter // imagine

class Computed extends Derived {
  collector(otherSignal) {
    // TODO: this functions adds listeners to other signals
  }
  constructor(callback) {
    super((this.collector) => {
      let result
      currentCollector = collector
      result = callback()
      currentCollector = NOOP
      return result
    })
  }
}

Derived doesn't do implicit tracking by itself, so we add a logic to set a currentCollector temporarily. I have also tried this in the past for my libraries. You can check https://github.com/xoidlabs/xoid/blob/shave-off-2/packages/xoid/src/tracking/index.tsx to see how I implemented computed as a special case of atom in xoid.

I think this is the intuitive way to derive one from another. It cannot be the other way around. Well, maybe someone can come up with the opposite alternative, but I strongly suspect that it would be too convoluted compared to this one, and of course, I'd love to see if I'm wrong here. Currently I'm thinking strongly in terms of "implicit dependency collection" is more work compared to the explicit one, and conceptually that it's opt-in rather than opt-out.

Thought Experiment: What happens when we try to derive Signal.Derived from Signal.Computed

Let me try to disprove my claims. Imagine we don't have Signal.Derived (current state of the proposal). To implement my Jotai-like derived function, I'd have to do:

// collector should run signal's `.get()`
const collector = (atom) => { atom[internalSignalSymbol].get() }

const derived = () => {
  return new Signal.Computed(() => {
    return callback(collector)
  })
}

The latter may not seem absurd at the first glance, but don't forget that there's still an UNUSED .get() tracking mechanism under the hood! This is the whole point. There's an elegant way of deriving Derive from Computed, but there's isn't for the opposite.

Summary

  1. Implicit-dependency-collection logically/conceptually comes after the explicit-dependency-collection
  2. Implementation validates that. Intuitive way of implementing Derived and Computed would be in this order.*
  3. This is because Computed does extra work, which is keeping track of the "current getter" (demonstrated above)
  4. Moreover, implementing Derived is is ~90% the work. After that ~10% of the work is implementing Computed. That's how most of the complexity lies in Derived.
  5. Therefore, it would be absurd to expose Computed, and not expose Derived. Which is already 90% of the work, and it would be useful primitive for one family of state management libraries.

*: Conceptualization and implementation are hand-to-hand. It's not very surprising that they agree with each other, but I think it's worth double-checking.

Last words

  1. This proposal is giving something very precious to JavaScript: It removes the burden of implementing "push then pull” model from library authors. This is going to be great for state management libraries and frameworks.
  2. Push-then-pull model is beneficial not only to implicit-based state management libraries. To truly help developers, the proposal should be modular, ie, it should not go without exposing useful primitives, especially if it's already implementing them internally. Otherwise, there would be an illogical status quo in JavaScript such as: "I need to implement my Jotai-like library on top of built-in Signals object, how can I opt out of the implicit dependency collection? Would it be more performant if I implement it from scratch?".

Thanks again to all contributors!

onurkerimov commented 1 month ago

I just updated my issue description, and made some points more clear.

justinfagnani commented 1 month ago

Can you not build Derived on top of Signal.subtle.untrack()?

import {describe as suite, test} from 'node:test';
import assert from 'node:assert';
import {Signal} from 'signal-polyfill';

type Signal<T> = Signal.State<T> | Signal.Computed<T>;

suite('Derived', () => {
  test('can do explicit tracking', () => {

    class Derived<T> extends Signal.Computed<T> {
      constructor(fn: (get: <T>(signal: Signal<T>) => T) => T) {
        const watchedSignals = new Set<Signal<any>>();
        const get = <T>(signal: Signal<T>) => {
          watchedSignals.add(signal);
          return signal.get();
        };
        super(() => {
          // First, run the computation untracked
          const result = Signal.subtle.untrack(() => fn(get));

          // Then trigger the automatic tracking outside of untracked()
          for (const signal of watchedSignals) {
            signal.get();
          }

          return result;
        });
      }
    }

    const a = new Signal.State(0);
    const b = new Signal.State(0);
    const computedSum = new Signal.Computed(() => a.get() + b.get());
    const desrivedSum = new Derived((get) => get(a) + b.get());
    assert.equal(computedSum.get(), 0);
    assert.equal(desrivedSum.get(), 0);

    // a is explicitly tracked, so both sums update
    a.set(1);
    assert.equal(computedSum.get(), 1);
    assert.equal(desrivedSum.get(), 1);

    // b is only implicitly tracked, so only the Computed updates
    b.set(1);
    assert.equal(computedSum.get(), 2);
    assert.equal(desrivedSum.get(), 1);
  });
});