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

What are Signals introducing that Proxy doesn't currently handle? #101

Open
ddamato opened this issue Apr 1, 2024 · 11 comments
Open

What are Signals introducing that Proxy doesn't currently handle? #101

ddamato opened this issue Apr 1, 2024 · 11 comments

Comments

@ddamato
Copy link

ddamato commented Apr 1, 2024

As per title, it is not clear in the current Readme.

@NullVoxPopuli
Copy link
Collaborator

I tried responding here: #102

lemme know what you think!

@EisenbergEffect
Copy link
Collaborator

We definitely need to add some more info on this.

I think there are a couple of key ideas to understand. The first is that behind signals is an acyclic graph of sources and sinks that represents what state/computed contributes to a computed and what state/computed a state/computed contributes to. Second, the graph is evaluated using a "push then pull" algorithm. When a signal changes, the downstream sinks are not immediately re-evaluated. Instead, the sinks are only eagerly marked as "dirty". The computations are then lazily evaluated only when requested. I've got a bit on this in my blog, hopefully publishing in a matter of minutes. I'll link it back here as well.

@ddamato With a proxy, folks are typically trying to create an observer-based system*. Signals are different than that via the unidirectional data flow through the graph and the push/pull algo. Does this help provide some clarity for you?

*There are very cool things you can do by creating Proxies that use signals internally. @NullVoxPopuli could tell you all about that, I'm sure.

@ddamato
Copy link
Author

ddamato commented Apr 1, 2024

@ddamato With a proxy, folks are typically trying to create an observer-based system*. Signals are different than that via the unidirectional data flow through the graph and the push/pull algo. Does this help provide some clarity for you?

It does not, and perhaps that's due to the language being used. In my opinion, it would be more helpful to provide an example of a Signal vs Proxy and what problem Signal solves that a Proxy would not.

@EisenbergEffect
Copy link
Collaborator

Instead of "sources" insert the word "dependencies". With a signal, we keep a graph of all the data dependencies in the system, the relationships between signals and computations. So, we know what things potentially effect what other things. My blog has an example. I'll go get that published 🤣 Please stand by.

@ddamato
Copy link
Author

ddamato commented Apr 1, 2024

With a signal, we keep a graph of all the data dependencies in the system, the relationships between signals and computations.

Isn't the proxy handler the "graph" of all the relationships between the "signals" and the functions that could be called?

@NullVoxPopuli
Copy link
Collaborator

NullVoxPopuli commented Apr 1, 2024

@ddamato with proxies,

let data = new Proxy({ /* what goes here */ }, { trap });

Signals are used entirely differently -- let's say we want to make a Reactive Object with dynamically reactive properties (a very useful utility!)

In reactive-world, we may want something like this:

let obj = { isLoading: true, result: null, error: null };

to represent the loading and result state of a promise -- but this is not inherently reactive!
To make it reactive we could use signals like this:

let obj = {
  isLoading: new Signal.State(false),
  result: new Signal.State(null),
  error: new Signal.State(null)
}

but this has a problem in that now all consumers of obj have the signal API exposed (needing to call .get() to get the value of each property.

obj.isLoading.get()
obj.result.get()

Signals work with proxies, in the following way,

if, at author-time, we know we want obj to be reactive, we can create this abstraction:

Disclaimer: this is a rough sketch -- actual implementation happening here: proposal-signals/signal-utils#1

The below code is hastily adapted from from this implementation: https://github.com/tracked-tools/tracked-built-ins/blob/master/addon/src/-private/object.js
(but hopefully seeing real code shows the relationship between these structures, and that Proxies aren't really at all a reactive primitive -- they are an ergonomics tool)

class TrackedObject {
  static fromEntries(entries) {
    return new TrackedObject(Object.fromEntries(entries));
  }

  constructor(obj = {}) {
    let proto = Object.getPrototypeOf(obj);
    let descs = Object.getOwnPropertyDescriptors(obj);

    let clone = Object.create(proto);

    for (let prop in descs) {
      Object.defineProperty(clone, prop, descs[prop]);
    }

    let self = this;

    return new Proxy(clone, {
      get(target, prop) {
        return self.#readStorageFor(prop);
      },

      has(target, prop) {
        self.#readStorageFor(prop);

        return prop in target;
      },

      ownKeys(target) {
        self.#collection.get();

        return Reflect.ownKeys(target);
      },

      set(target, prop, value) {
        target[prop] = value;

        self.#dirtyStorageFor(prop);
        self.#dirtyCollection();

        return true;
      },

      deleteProperty(target, prop) {
        if (prop in target) {
          delete target[prop];
          self.#dirtyStorageFor(prop);
          self.#dirtyCollection();
        }

        return true;
      },

      getPrototypeOf() {
        return TrackedObject.prototype;
      },
    });
  }

  #storages = new Map();

  #collection = createStorage(null, () => false);

  #readStorageFor(key) {
    let storage = this.#storages.get(key);

    if (storage === undefined) {
      storage = createStorage(null, () => false);
      this.#storages.set(key, storage);
    }

    return storage.get();
  }

  #dirtyStorageFor(key) {
    const storage = this.#storages.get(key)?.set(null);
  }

  #dirtyCollection() {
    this.#collection.set(null);
  }
}

and then, we could author obj like this:

let obj = new TrackedObject({ isLoading: false, result: null, error: null });

and accesses to properties are inherently reactive, thanks to the proxy:

obj.isLoading
obj.result

@ddamato
Copy link
Author

ddamato commented Apr 1, 2024

To make it reactive we could use signals like this:

let obj = {
  isLoading: new Signal.State(false),
  result: new Signal.State(null),
  error: new Signal.State(null)
}

What is "reactive" about this? This looks like you are setting values to an object from a class.

but this has a problem in that now all consumers of obj have the signal API exposed (needing to call .get() to get the value of each property.

obj.isLoading.get()
obj.result.get()

Is the problem the .get() call? I think you've shown how Proxy would handle that:

obj.isLoading
obj.result

Maybe I'm not clear in the question. I'm not asking about how Signals can be used with Proxy, I'm asking what Signals are introducing that isn't already possible with current technology like Proxy. So the example I'm expecting is some configuration that ends with "see with Signal we can do X, where a Proxy can't". What is X?

Edit: I'd also appreciate a eli5 answer.

@EisenbergEffect
Copy link
Collaborator

Here's a link to my blog post with some more explanation on this topic:

https://eisenbergeffect.medium.com/a-tc39-proposal-for-signals-f0bedd37a335

Have a read through the section "What are signals?"

@fabiospampinato
Copy link

fabiospampinato commented Apr 1, 2024

Fundamentally proxies can only intercept reads and writes for objects, not plain values, which signals can also wrap.

You could wrap the plain values in an object and wrap them in an proxy, and if you did that you'd have the same ability to intercept reads and write as a signal does. A signal would be a bit cheaper though, it provides an API more closely designed for reactivity use cases, and a signal is useless in isolation, it's meant to be used with the rest of the reactivity system, while proxies just provide you with the ability to intercept things and aren't opinionated about what you should do with that capability.

@ddamato
Copy link
Author

ddamato commented Apr 1, 2024

Here's a link to my blog post with some more explanation on this topic:

https://eisenbergeffect.medium.com/a-tc39-proposal-for-signals-f0bedd37a335

Have a read through the section "What are signals?"

The word "proxy" isn't found in that blog post so it doesn't seem specifically relevant to the question of this issue.

Fundamentally proxies can only intercept reads and writes for objects, not plain values, which signals can also wrap.

You could wrap the plain values in an object and wrap them in an proxy, and if you did that you'd have the same ability to intercept reads and write as a signal does. A signal would be a bit cheaper though, it provides an API more closely designed for reactivity use cases, and a signal is useless in isolation, it's meant to be used with the rest of the reactivity system, while proxies just provide you with the ability to intercept things and aren't opinionated about what you should do with that capability.

This. Thank you. Here's the answer I've been asking for:

// This is not possible, the first arg needs to be an object
const counter = new Proxy(0, {...});

I don't have enough experience with Symbols but this smells like there's something that could help here too. Though having a great deal of boilerplate just to store a value is certainly annoying, it's not unlike an other low-level JS API that has been abstracted away through some popular library (ie., custom elements vs. Lit).

But there's a few other things in this response that I want to call out.

You could wrap the plain values in an object and wrap them in an proxy, and if you did that you'd have the same ability to intercept reads and write as a signal does.

Precisely; while messier it is still possible.

while proxies just provide you with the ability to intercept things and aren't opinionated about what you should do with that capability.

And this is also very powerful, since they aren't opinionated you can have more flexibility with what you want to do.

If the objective is to make the JavaScript ecosystem easier because this would be more cumbersome with the current native implementations, then there should be examples of how cumbersome in comparison to existing techniques.

@EisenbergEffect
Copy link
Collaborator

@ddamato The reason I wanted to share the blog post here is because one needs to understand what the proposal defines a signal to be. That helps in understanding how it is different than a proxy.

Proxies only provide a mechanism for intercepting interactions with an object. Signals provide a mechanism for creating values and computations that participate in a reactive graph. If you want to use a proxy to create your reactive state store, you can do that, but what code are you going to write to manage the values and their relationships so that you can efficiently react to state changes? How are you going to make that interoperate with every view engine? How do you maintain the consistency of the model, ensuring that you don't over update or have stale computed values...and do it in a performant and memory efficient way? Those pieces of the reactivity puzzle are what the signals proposal is attempting to standardize.

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

4 participants