tc39 / proposal-signals

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

Concerns about this being too opinionated. #154

Open Nefsen402 opened 1 month ago

Nefsen402 commented 1 month ago

There is no one way of doing signals. It seems that the popular way of doing signals these days is by relying on global state, however a more explicit model could also be used. As an author of a library that has a concept of signals and that it is used in production, I have some notes about this. I originally developed destam to implement real time collaboration between clients. This library was originally born out of the concept of wrapping regular JavaScript Objects and Arrays into proxy objects to add reactivity. Initially, there was no concept of a signal, you could subscribe to these proxy objects directly. However, as the library matured, I concurrently developed signals without knowing I was developing signals. This means that the API that I have for my signals is actually quite different than the ones being proposed here, and in fact are fundamentally incompatible. If this ever gets introduced into JavaScript, this library cannot use signals as they are in this proposal. Let's talk over some of the differences to compare and contract:

Signal creation

Signals in destam (they are called Observers in the library) can be created in multiple ways. The way that most people would be familiar is Observer.mutable which is just creating an observer that can be mutable. On the flip coin, Observer.immutable is a version that cannot mutate. Observers can also be created from a so called Observable. Observables are these proxy objects I've been talking about and they can be used to generate signals/observers.

For example

const state = OObject({
    counter: 0,
    name: "doomsday counter",
});

let counterSignal = state.observer.path('counter');
let nameSignal = state.observer.path('name');

console.log(counterSignal.get()); // 0
state.counter += 1;
console.log(counterSignal.get()); // 1

In this case, we also have other features to sort of "drill down" and precisely extract the information we are talking about. The nameSignal will not be bothered if the counter state has been changed and vice versa. Note that state.observer is already itself a destam signal. Destam signals themselves provide these query features such as path and other. These can get so much more complex. Please see destam state trees for more information about this.

Computed signals

Destam tries to use global state is little as possible (there is one function that uses global state to atomically manage multiple observers and when events should be fired - but I wish to refactor this). For computed signals, no global state is used to track what the dependence for the information are, this is all explicit.

let counterSignalTimes2 = counterSignal.map(counter => counter * 2);

The most basic way to create a computed signal is by using the map method. Map can also be used to go backwards:

let counterSignalTimes2 = counterSignal.map(counter => counter * 2, counterTwice => counterTwice / 2);

counterSignalTimes2.set(2);
console.log(state.counter); // 1

The most basic way to create a computed signal is by using the "map". We can also depend on multiple pieces of state using map in conjunction with Observer.all:

let taggedCounter = Observer.all([nameSignal, counterSignal]).map(([name, count]) => `${name}: ${count}`);

This signal will combine the count state and the name state which will be (lazily) updated when either of the dependent states are changed.

This may be a little bit clumsy to some, but the reason why we did it this way is because this allows for a variable amount of signals to map over. If the argument sent to Observer.all is itself a signal that resolves to an array, we can have a variable about of signals.

const myNumbers = Observer.mutable([
    counterSignal,
    Observer.immutable(10),
]);

const sum = Observer.all(myNumbers).map(count => count.reduce((a, c) => a + c, 0));

counterSignal.set(2) // sum is now 12
myNumbers.set([...myNumbers.get(), Observer.mutable(5)]); // sum is now 17

Takeaway

The signals as proposed here cannot support these features and I don't think they ever will. There is also this whole world that destam implements called deltas (which is the basis of the said collaboration feature) and those are also fundamental to signals in destam. Since I developed these signals in mostly isolation without realizing it, I have come up with an API that looks quite different than what is popular. But things were also designed in this way to also support deltas and state trees. This extra complexity that is required by destam probably isn't also a great candidate to actually just throw into the proposal.

I don't think that signals should be part of JavaScript as the way they stand currently, or ever. This will benefit a few simpler state models, but not satisfy more complex ones. I don't think this should be part of JavaScript as a first party feature.

NullVoxPopuli commented 1 month ago

The signals as proposed here cannot support these features and I don't think they ever will

Why do you feel this way? :thinking:

I think we could implement an Observer and OObject structure based of the current proposed implementation as well as the utilities prototyped over here: https://github.com/NullVoxPopuli/signal-utils

fabiospampinato commented 1 month ago

I'm not sure which exact feature would be unimplementable here 🤔 You can deeply proxy plain objects in userland, turn off automatic tracking with untrack, listen to all properties in one of these state objects cheaply etc.

The existence of some "global" doesn't seem like a big deal since that's an implementation detail.


At the end of the day just because a proposal exists doesn't mean it has to be adopted, if you don't see a reason for doing that. If you think the current proposal is too limited it would be worth clarifying what's one thing that can't be implemented on top of it that you think is important enough to have.

Nefsen402 commented 1 month ago

My concerns were mostly about the concept of deltas being propagate through the library. A main feature is to be able to nest Observables to create one reactive mega object.

const user = OObject({
    projects: OArray([
        createProject(),
    ]),
   profile: OObject({
        name: '...',
        password: '...'
    })
});

user.observer.watch(delta => {
     // these deltas can be synchronized to the database or whatever
    console.log(delta);
    console.log(delta.path());
});

user.observer.path('profile').ignore('password').watch(delta => {
     // we also want to synchronize with the client, so we can create a signal that listens on a subset of the state tree
})

user.profile.name = "new name"; // the watcher will log: {value: "new name", prev: "..."} with the path ["profile", "name"]

My concern isn't that the current proposal couldn't add some way to pass arbitrary auxiliary state through the effect() method, my concern is whether we really should given the basis that so called governors - the things that control what part of the state tree a signal cares about are also a fundamental feature of the library. I believe this complexity should stay within a library and should not be part of JavaScript.