viridia / quill_v1

Reactive UI framework for Bevy game engine
MIT License
60 stars 7 forks source link

Explore fine-grained reactivity #9

Closed viridia closed 9 months ago

viridia commented 9 months ago

One thing I'd like to do is explore various different flavors of reactivity in Quill. Tweaking the reactive algorithms in Quill should be relatively easy, because reactivity is actually a fairly small portion of the library.

Right now, reactive update granularity is modeled after React.js. The reactive tracking context is tightly coupled with the presenter state. This means is that a "reaction" updates an entire presenter state. That is, any data source that is referenced within the body of a presenter function is tracked, and when that data notifies of a change, the entire presenter is re-run.

A different kind of reactive granularity worth exploring is the one used by Solid.js. In this model, reactive updates don't cause the entire widget to re-render; instead you could have individual Views, or even individual attributes of views, be separate reactions.

This solves a bunch of problems but also creates a bunch of new ones.

A downside of the React strategy is that the presenter is always run, and the view hierarchy rebuilt, each time there's a change notification - even if the data didn't actually change. (Change notifications can happen via .get_mut() for example.)

Consider memoization (such as React's useMemo hook). In order to compute the memoized result, we need to run the memo computation - which can only be done by re-running the presenter function which contains that hook. Even if the memo reports that the data is the same as before, you still have to re-run the whole presenter.

Solid's memos work differently: A memo function can re-run independently of its enclosing function, because memo functions are long-lived. This means that you can first decide if the data actually changed, and only then decide if you need to re-render.

Solid also has fewer restrictions on nesting: you can have effects inside of effects, memos inside of effects and so on. That's because each effect and each memo has its own reactive tracking context, which is not tied to a UI component.

However, in order to make this work you need reactive tracking contexts that are not tied to UI widgets. And these tracking contexts need an ownership and lifetime model that allows them to be disposed when they are no longer needed. This is a hard problem in Bevy because long-lived objects have to live in the ECS world.

Also, the Solid approach is much more reliant on closures. In the React model, you can use data sources as regular local variables, because you know that the whole function is going to get re-run, which means the local variables will get re-bound to new values. In Solid, this is not true: because we're not re-running the function every time the data changes, local variables will not be bound to new values - so those local variables need to be getters/setters rather than just plain variables. Making them getters/setters allows the updated values to be accessed without rebinding the local variables.

This in turn means that we now have to pass around getters/setters everywhere, which is easy in a GC language but hard in Rust.

viridia commented 9 months ago

Punting on this for now, the current model seems to work well enough.