3 unstable releases

0.2.2 Jul 10, 2022
0.2.0 May 19, 2022
0.1.0 May 16, 2022

#685 in Rust patterns

MIT license

37KB
1K SLoC

rsconnect

Rust crate for fine-grained reactivity

⚠️ Experimental

This crate is in active early development. There may be undiscovered bugs and issues. It is possible there will be breaking changes to API and how it works under the hood.

Goal

This crate allows you to structure change propagation based on graph of data nodes that depend on each other in automatically tracked dependency graph.

Suppose in our system we have data on the number of produced items, the cost of production for each, the number of items we sold, and how much each item is priced for. We would like to calculate total cost and revenue, and then profit out of those. The dependency graph would look like this:

items_produced  item_cost  items_sold  item_price
             ⬁         ⬀    ⬁        ⬀
              total_cost       revenue
                       ⬁     ⬀
                        profit

Every single time items_produced, item_cost, items_sold or item_price is updated, we'd like to recalculate total_cost, revenue and, then, profit. In addition, when profit is updated, we would like to print it with a simple println!. This is what the code for this would look like with rsconnect:

let mut c = Connect::new();
let mut my_effects: Vec<DynComputedNode> = Vec::new();

let items_produced = c.observed(25);
let items_sold = c.observed(20);
let item_cost = c.observed(10.0);
let item_price = c.observed(13.0);
let revenue = c.computed(
    clone!(move |c| *c.get(&item_price) * *c.get(&items_sold) as f32)
);
let total_cost = c.computed(
    clone!(move |c| *c.get(&item_cost) * *c.get(&items_produced) as f32)
);
let profit = c.computed(
    clone!(move |c| *c.get(&revenue) - *c.get(&total_cost))
);

my_effects.push(
    c.effected(
        clone!(move |c| *c.get(&profit)),
        |profit| {
            println!("Profit: {}", profit)
        }
    )
);

This immediately prints:

Profit: 10

In order to change value of any data node and thus trigger change propagation all the way to the underlying effects, one can simply call .set on any of the data nodes:

c.set(&items_sold, 25);

This propagates changes to the correct computed and effected nodes and prints:

Profit: 75

Multiple changes may also be batched together so that recalculations only happen once, after the entire batch of nodes change:

c.batch(|c| {
    c.set(&items_produced, 60);
    c.set(&items_sold, 55);
});

Which will print:

Profit: 115

Effected

Effected node consist of 2 parts, computed part and an effect:

c.effected(
    clone!(move |c| *c.get(&profit)), // computed part
    |profit| { // effect
        println!("Profit: {}", profit)
    }
)

Computed part subscribes to its dependencies, just like a computed node. Effect is called immediately after recalculation and does not play part in the dependency graph, so it can read values in the graph without being subscribed to them. The result from the computed node is passed to the effect.

Additional notes

  • Every node in the graph is reference counted in order to automatically manage memory cleanup and because nodes are meant to reference each other in many-to-many fashion.

  • clone!(...) is a helpful macro for automatically cloning reference counted node pointers into computed closures. It is available with "macros" feature and is strongly recommended. Every connected node inside c.get(...) will be automatically cloned into closure.

  • Make sure you don't immediately drop your effected nodes. If effected is dropped, it will not run. This happens because in rsconnect, every computed node stores its dependencies as strong references, and all derived nodes - as weak references; so since usually no nodes depend on effected nodes, they're only weakly referenced by other nodes. The easiest way to handle this is to store your effects in a more global vec until such a time as you'd like to purge them.

  • In order to optimize updates, new values are compared to old ones to determine whether further updates should be triggered. For that reason, the value of computed, observed and effected nodes is required to implement std::cmp::PartialEq trait. This is preferred because, when PartialEq values are used, there won't be unexpected updates. However, it's possible to create nodes that, when recalculated / set, will always be considered changed - for that, use .observed_any, .computed_any and .effected_any instead of .observed, .computed and .effected.

TODO

  • Add #[derive(Connect)] macros
  • Additional configuration options
    • Custom update schedule function
    • Custom effect schedule function?
  • Write proper documentation
    • for rsconnect crate
    • for rsconnect_macros crate
  • Stabilize

Dependencies

~205KB