sycamore
sycamore copied to clipboard
Deeply nested state reactivity
Is your feature request related to a problem? Please describe.
The current way of having fine grained reactivity for nested state is basically wrapping every member around Signal. This is cumbersome and unergonomic.
Describe the solution you'd like
An attribute macro called observable (or something else) which creates a new struct with the previous struct name appended with a Observable (e.g. AppState becomes AppStateObservable. This struct should have methods for all the fields which are wrappers around Signal::get and Signal::set.
A new method should be added to Signal called subscribe which is identical to get but without returning any value (saving a Rc::clone). The -Observable struct should then have methods subscribe and subscribe_nested where the former subscribes to all the fields and the later subscribes to all the fields and all nested fields (subscribe when any element in the tree changes).
The existing struct should also have methods fn observable(&self) -> SelfObservable, fn into_observable(self) -> SelfObservable and fn new_default_observable() -> SelfObservable for creating the Observable struct.
Additional context
This would be similar to SolidJS's createState function except it uses the JS Proxy for property get and set.
Questionnaire
- [ ] I would like to implement this feature but I don't know where to start
- [ ] I would like to implement this feature and I have a solution
- [ ] I don't have time to implement this / I don't want to implement this
It sounds like you're essentially describing what mobx does. You might be interested in this https://github.com/s-panferov/observe implementation of the mobx observable pattern.
Hi there! Before giving this a try, could we work out an example?
This is what I think should be the public interface: a derive macro.
#[derive(Observable)]
struct AppState {
field1: u8,
pub field2: u64,
}
The macro would add:
- An
implblock - A new struct
- An
implblock for the new struct
impl block
impl AppState {
pub fn observable(&self) -> AppStateObservable {
// What whould we like here?
// Do the fields need to implement Clone?
// Or should it return `AppStateObservableRef<'a>`?
}
pub fn into_observable(self) -> AppStateObservable {
AppStateObservable {
field1: Signal::new(self.field1),
field2: Signal::new(self.field2),
}
}
pub fn new_default_observable() -> AppStateObservable {
// Should this be the same as AppStateObservable::default,
// given that all filds of AppState implement defaiult?
}
}
Should this be an impl block, or wrap these methods in a public trait?
A public trait would explain better the derive macro.
New struct
All fields are replaced by wrapping them inside a Signal.
struct AppStateObservable {
field1: Signal<u8>,
pub field2: Signal<u64>, // Public behaviour should be maintained
}
impl for new struct
impl AppStateObservable {
pub fn subscribe(&self) {
self.field1.subscribe();
self.field2.subscribe();
}
pub fn subscribe_nested(&self) {
// What should be here?
// You were thinking in more than one level of nesting, but for now there is nothing to subscribe to, right?
}
}
Changes to Signal
Make get call two functions, where the first subscribe step can be called independently.
// In sycamore_reactive, signal.rs
impl Signal {
// Todo: Use inside of Signal::get
pub fn subscribe(&self) {
// If inside an effect, add this signal to dependency list.
// If running inside a destructor, do nothing.
let _ = LISTENERS.try_with(|listeners| {
if let Some(last_context) = listeners.borrow().last() {
let signal = Rc::clone(&self.0);
last_context
.upgrade()
.expect_throw("Running should be valid while inside reactive scope")
.borrow_mut()
.as_mut()
.unwrap_throw()
.dependencies
.insert(Dependency(signal));
}
});
}
}
On top of this
I think it would be great to have a way to translate methods of AppState into methods of AppStateObservable, but I do not think this is easy.
I'm currently rewriting the reactive primitives for sycamore so some of the API may change. Thanks for the writeup but I think it's better to wait a bit until I merge the new API into the repo.
I feel like this a major hurdle to implement apps with complex state, in the style of modern SPAs
Instead of having AppState generate a type AppStateObservable, I think it's a better API to have a Observable<T> type such that Observable<AppState> the observable type corresponding to AppState.
That way, the code is easier to understand for someone that isn't familiar with Sycamore: instead of using a type that seemingly appears from nowhere, it would use a standard "type combinator", that works not unlike Signal<T>. This would enable having the API docs for observables in the documentation of Sycamore itself, instead of being shown only in the generated docs of your own crate.
Under the hood, the macro would still create a type for each observable, but the type name would be #[doc(hidden)] and using the name directly would be discouraged.
This all can be achieved with a trait with an associated type, implemented by your own type (like a HasObservable trait - maybe Observe would be a better name?), and another trait for observables (that I called IsObservable - also needs a better name..), like this:
Sycamore would provide this code:
trait HasObservable: Sized {
type ThisObservable: IsObservable + From<Self>;
}
trait IsObservable {
fn subscribe(&self);
fn subscribe_nested(&self);
}
struct Observable<T: HasObservable> {
inner: <T as HasObservable>::ThisObservable,
}
impl<T: HasObservable> From<T> for Observable<T> {
fn from(x: T) -> Self {
Observable { inner: From::from(x) }
}
}
impl<T: HasObservable> IsObservable for Observable<T> {
fn subscribe(&self) {
self.inner.subscribe();
}
fn subscribe_nested(&self) {
self.inner.subscribe_nested();
}
}
The user would provide this code:
#[derive(HasObservable)]
struct AppState {
field1: u8,
pub field2: u64,
}
And the macro would generate this code:
// this struct shouldn't be used directly
struct AppStateObservable {
field1: Signal<u8>,
pub field2: Signal<u64>,
}
impl From<AppState> for AppStateObservable {
fn from(x: AppState) -> Self {
AppStateObservable {
field1: Signal::new(x.field1),
field2: Signal::new(x.field2),
}
}
}
impl HasObservable for AppState {
type ThisObservable = AppStateObservable;
}
impl IsObservable for AppStateObservable {
fn subscribe(&self) {
self.field1.subscribe();
self.field2.subscribe();
}
fn subscribe_nested(&self) {
// ..
}
}
I wrote this on playground, substituting Signal by Option so it would compile.
The Observable struct can also be written as a type alias:
type Observable<T> = <T as HasObservable>::ThisObservable;
But doing so would make it harder to discover its methods on the API docs (but maybe linking to IsObservable is enough)
I'm going to implement the Observable<T> pattern (courtesy @dlight) in Perseus's reactivity system, which should then be ready to contribute into Sycamore. Does that sound good @lukechu10?
(By putting it in Perseus first, we can take advantage of its examples, which will be able to test the ergonomics of this pattern in more realistic scenarios.)
I'm going to implement the
Observable<T>pattern (courtesy @dlight) in Perseus's reactivity system, which should then be ready to contribute into Sycamore. Does that sound good @lukechu10?(By putting it in Perseus first, we can take advantage of its examples, which will be able to test the ergonomics of this pattern in more realistic scenarios.)
Sounds good!