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

Reactive/Store-like primitive #4

Open
marvinhagemeister opened this issue Aug 9, 2022 · 17 comments
Open

Reactive/Store-like primitive #4

marvinhagemeister opened this issue Aug 9, 2022 · 17 comments
Labels
enhancement New feature or request

Comments

@marvinhagemeister
Copy link
Member

marvinhagemeister commented Aug 9, 2022

// Turns properties into signals
const r = reactive({ foo: 123, bar: "hehe" });

console.log(r.foo.value);

Open question: Should conversion be deep or shallow?

Pros

  • Easy to make an existing object reactive

Cons

  • tbd

Update with assign()-syntax

// Update a Reactive with assign()-like syntax:
const r = reactive({ name: "Alice" });
update(r, { name: "Bob" });

// property 'age' does not exist in type '{ name?: string }'
update(r, { age: 42 });

// '2' has no properties in common with '{ name?: string }'
update(r, 2);

console.log(r.name.value); // "Bob"

Pros

  • Good for Redux users?

Cons

  • Ambiguity for collections/arrays: Should we update or concat?
  • Is this needed for an MVP or could this be added in users themselves? What's the use case?
  • Risks forking update logic
@marvinhagemeister
Copy link
Member Author

FYI: Devtools doesn't use something like that.

@JoviDeCroock
Copy link
Member

I think we should deeply wrap items so that we can for instance do

const r = reactive({ count: 0 });
r.count.value++

and see stuff change, not entirely sure if it's possible but those are my two cents coming from the Vue-like syntax.

@marvinhagemeister
Copy link
Member Author

marvinhagemeister commented Aug 11, 2022

@JoviDeCroock That's a great idea! I think it'd definitely be nice to have some sort of helper method that initializes object properties as Signals. I'm not sure how deep that should go. Do you know how Vue handles these cases?

// Are both "foo" and "bar" signals now?
const r = reactive({ foo: { bar: 123 }});
// Are both "foo" and the inner elements in the array signals?
const r = reactive({ foo: [ { bar: 123 }, { bar: 444 } ]});

@JoviDeCroock
Copy link
Member

The reactive conversion is "deep": it affects all nested properties. A reactive object also deeply unwraps any properties that are refs while maintaining reactivity.

From the explanation I assume it would wrap all of them as individual signals, it's hard to tell in the CSB as they eagerly unwrap https://codesandbox.io/s/magical-swanson-3cpwb8?file=/src/App.vue

@marvinhagemeister
Copy link
Member Author

Currently looking at valtio and the more I look at it, the more I like it. Originally, I was worried that having two kind of values (proxy + boxed .value) primitives would be confusing, but I see myself moving away from that stance. Looking at devtools the proxy approach would help me reduce typing noise quite a bit. There, I've typed everything as explicitly being an observable, but with proxies all that could be dropped.

@fabiospampinato
Copy link

fabiospampinato commented Sep 6, 2022

Quick and dirty, but I repurposed the port of $.store from Oby that I did for Solid for Preact/Signals: https://codesandbox.io/s/sparkling-cherry-bn9bu5?file=/src/index.js

It doesn't seem entirely broken, but there seem to be a bunch of APIs missing that would be needed to implement this efficiently.

@EthanStandel
Copy link

EthanStandel commented Sep 8, 2022

I came here to recommend this and for anyone in the mean-time, I just published a package called preact-signal-store which is meant to do just this. I expose a deepSignal which takes an object and makes every atomic property a Signal and makes every parenting object a DeepSignal type. The DeepSignal type has peek and a value getter and setter that act just like Signal but it strictly reads from and writes to the underlying Signal properties lower in the tree.

I personally think this model would make sense in @preact/signals but I'm curious what maintainers and the community think

@chenjiangui
Copy link

deepSingnal is helpfull

@chenjiangui
Copy link

I think we should deeply wrap items so that we can for instance do

const r = reactive({ count: 0 });
r.count.value++

and see stuff change, not entirely sure if it's possible but those are my two cents coming from the Vue-like syntax.

r.value.count++

is better, if you have three or four deep object

@EthanStandel
Copy link

EthanStandel commented Sep 20, 2022

r.value.count++

is better, if you have three or four deep object

@chenjiangui I feel like that's kind of more confusing. One of the rules of a Signal in @preact/signals is that it's always the .value that's writable, and in the value setter there is the code to fire off the updates to the listeners. But I think that if r.value gave you a set of properties that were writable & listenable, it would kind of go against the model that's set and would really blur the line between what is listenable and where the actual state is, because r.value.count++ isn't reassigning r.value, it's reassigning r.value.count.

In preact-signal-store, I set up deepSignal such that you can't do this (in TS at least, I didn't feel like it was worth the overhead to actually freeze the objects at run time) so there's no lack of clarity.

import { deepSignal } from "preact-signal-store";

const userStore = deepSignal({
  name: {
    first: "Thor",
    last: "Odinson"
  },
  email: "thor@avengers.org"
});

// TS error: Cannot assign to 'email' because it is a read-only property.
userStore.value.email = "another@email.com";

Notably, what would work is this, which would actually update the original email Signal for all listeners.

userStore.value = { email: "another@email.com" };

@eddyw
Copy link
Contributor

eddyw commented Sep 26, 2022

I opened a PR #217 PoC which could allow a single signal to track multiple values rather than creating multiple signals per each property.

The PR doesn't attempt to be the "Reactive/Store-like primitive" but rather a building block which could allow to do it.

@luisherranz
Copy link

Another take at deep signals, based on Proxies.

StackBlitz with examples

I'll refine it a bit (add tests, avoid wrapping unsupported elements and built-ins, etc) and publish an npm package so people can use it and report feedback.

I made it work as similar as possible to plain JavaScript objects, not to signals, because for my use case that's preferable. But making it as similar as possible to signals (like @EthanStandel's library) is also possible.

Plain JS objects

It's using $ to return the signal instead of the value.

const App = () => {
  return (
    <div>
      {/* Pass the signal down to JSX */}
      <div>Name: {user.$name}</div>
      {/* It works, but triggers a component rerender */}
      <div>Email: {user.email}</div>
    </div>
  );
};

It writes to the signal when you update the plain object:

user.name = "Jon";

Signal-like objects

For primitives, it could always return the signal:

const App = () => {
  return (
    <div>
      {/* Pass the signal down to JSX */}
      <div>Name: {user.name}</div>
      {/* It works, but triggers a component rerender */}
      <div>Email: {user.email.value}</div>
    </div>
  );
};

And it could force people to write to the signal using .value, like:

user.name.value = "Jon";

I guess the main differences with @EthanStandel's library, apart from the plain-JS-object API, are that it's using lazy initialization (it creates proxies and signals on the fly) and it supports adding properties on the fly.

@melnikov-s
Copy link
Contributor

I'll throw in my library in here as well: preact-observables

The goal here was to create transparent observable proxies for objects, arrays, Set, Map, WeakMap and WeakSet that have underlying signals to track properties.

It wraps another library i'm working on nu-observables. Which allows for creating deep proxied observables with any underlying signal implementation (preact-signals, solid, mobx, etc etc)

It uses lazy initialization and permanently associates an observable object with its source, meaning if you create an observable from a plain object it will always return the same observable instance no matter how many times you do it. So observing existing giant objects with circular references works and in constant time. When mutating , preact-observables will unwrap the observable before saving it to the source reference. So you won't end up with a plain object that is polluted with observable proxies.

Other notable features include being able to model signal state with classes, optimized Array.prototype methods compared to a naive proxy implementation as well as some interesting performance escape hatches (more details in readme)

Big thanks to @luisherranz as I copied their idea of using $ to return the signal instead of the value.

@luisherranz
Copy link

I've been trying to make $ work with TypeScript, but I haven't been able to make it work yet. I'm not sure if it's possible.

I've tried a similar approach to the one you're using here, but it has problems when mutating a nested object (because TypeScript thinks the $ props are required).

export type DeepSignal<T extends object> = {
	[P in keyof T & string as `$${P}`]: T[P] extends object
		? Signal<DeepSignal<T[P]>>
		: Signal<T[P]>;
} & {
	[P in keyof T]: T[P] extends object ? DeepSignal<T[P]> : T[P];
};

I've opened an issue in your repository not to hijack this one with our problems 🙂

@fabiospampinato
Copy link

fabiospampinato commented Nov 28, 2022

@luisherranz fwiw the store I posted here shouldn't have that problem, as it doesn't change in interface of the input object.

If there's enough interest I could polish and maintain that somewhat rough port 🤔

@luisherranz
Copy link

I'll refine it a bit (add tests, avoid wrapping unsupported elements and built-ins, etc) and publish an npm package so people can use it and report feedback.

It took me a bit, but here is the deep signal package. It is based on proxies and uses the $ prefix I mentioned here.

It's small (the goal is to keep it under 1Kb), has lazy initialization, and full TypeScript support.

https://github.com/luisherranz/deepsignal

I used the exact same structure and testing as this repo (actually, I started with a fork) to make it as familiar as possible.

@dy
Copy link

dy commented Jan 13, 2023

There's also signal-struct for comparison.
It's not based on proxies, essentially just seals initial passed object.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

No branches or pull requests

9 participants