proposal-signals
proposal-signals copied to clipboard
A critical primitive: `Signal.Derived` (in addition to `Signal.Computed`) should be added.
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
- Implicit-dependency-collection logically/conceptually comes after the explicit-dependency-collection
- Implementation validates that. Intuitive way of implementing
Derived
andComputed
would be in this order.* - This is because
Computed
does extra work, which is keeping track of the "current getter" (demonstrated above) - Moreover, implementing
Derived
is is ~90% the work. After that ~10% of the work is implementingComputed
. That's how most of the complexity lies inDerived
. - Therefore, it would be absurd to expose
Computed
, and not exposeDerived
. 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
- 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.
- 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!
I just updated my issue description, and made some points more clear.
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);
});
});