2 unstable releases

0.2.0 Jul 27, 2023
0.1.0 Oct 4, 2022

#2157 in Rust patterns

MPL-2.0 license

91KB
1.5K SLoC

X-Bow: precise state management

X-Bow is a state management library aimed for use in UI programming. It let you...

  • keep your data in a centralized store.
  • build "paths" that point to parts of the store.
  • borrow and mutate data at those paths.
  • subscribe to mutations at those paths through async API.

Quick Example

// Derive `Trackable` to allow parts of the struct to be tracked.
#[derive(Default, Trackable)]
#[track(deep)] // `deep` option is useful if the fields themselves are structs
struct MyStruct {
    field_1: i32,
    field_2: u64,
    child_struct: AnotherStruct
}

// Create a centralized store with the data.
let store = Store::new(MyStruct::default());

// Build a path to the `i32` at `field_1` in the `MyStruct`.
let path = store.build_path().field_1();

// This implements the `Stream` trait. You can do `stream.next().await`, etc.
let stream = path.until_change();

// Mutably borrow the `i32` of the path, and increment it.
// This will cause the `stream` we created to fire.
*path.borrow_mut() += 1;

Concepts

Store

The store is where the application state lives. It is a big RefCell.

Paths

A path identifies a piece of data in your store. It implements [PathExt], which contains most of the methods you will interact with.

Paths are usually wrapped in PathBuilder objects. These objects each dereference to the path object it wraps.

Path Builders

A path builder wraps a path object and derefs to the path object.

It also provides methods that to "continue" the path; if the path points to T, the PathBuilder will let you convert it to a path that points to some part inside T.

For example: the path builder that wraps a path to Vec<T> has an index(idx: usize) method that returns a path to T.

To convert from a PathBuilder to a Path, use IntoPath::into_path. To convert from a Path to a PathBuilder, use PathExt::build_path.

Trackable Types

Types that implements [Trackable] have their corresponding PathBuilder type. To be Trackable, types should have #[derive(Trackable)] on them.

Usage

Steps

  1. Make your structs and enums trackable by putting #[derive(Trackable)] and [track(deep)] on them. See documentation for the [Trackable macro][derive@Trackable].
    // 👇 Derive `Trackable` to allow parts of the struct to be tracked.
    #[derive(Trackable)]
    #[track(deep)]
    struct MyStruct {
        field_1: i32,
        field_2: u64,
        child_enum: MyEnum
    }
    // 👇 Derive `Trackable` to allow parts of the enum to be tracked.
    #[derive(Trackable)]
    #[track(deep)]
    enum MyEnum {
        Variant1(i32),
        Variant2 {
            data: String
        }
    }
    
  2. Put your data in a [Store].
    #
    let my_data = MyStruct {
        field_1: 42,
        field_2: 123,
        child_enum: MyEnum::Variant2 { data: "Hello".to_string() }
    };
    let store = Store::new(my_data);
    
  3. Make [Path]s.
    #
    let my_data = MyStruct {
        field_1: 42,
        field_2: 123,
        child_enum: MyEnum::Variant2 { data: "Hello".to_string() }
    };
    let path_to_field_1 = store.build_path().field_1();
    let path_to_data = store.build_path().child_enum().Variant2_data();
    
  4. Use the Paths you made. See [PathExt] and [PathExtGuaranteed] for available APIs.

Tracking through Vec and HashMap

You can track through [Vec] using the index(_) method.

let store = Store::new(vec![1, 2, 3]);
let path = store.build_path().index(1); // 👈 path to the second element in the vec

You can track through HashMap using the key(_) method.

let store = Store::new(HashMap::<u32, String>::new());
let path = store.build_path().key(555); // 👈 path to the String at key 555 in the hashmap.

Design

Borrowing and Paths

The design of this library is a hybrid between simple RefCell and the "lens" concept prevalent in the functional programming world.

The centralized data store of X-Bow is a RefCell. The library provides RefCell-like APIs like borrow and borrow_mut. Mutation is well supported and immutable data structures are not needed.

The library uses Paths to identify parts of the data in the store. Paths are made from composing segments. Each segment is like a "lens", knowing how to project into some substructure of a type. Composed together, these segments become a path that knows how to project from the root data in the store to the part that it identifies.

The difference between Paths and Lens/Optics is just that our paths work mutably, while Lens/Optics are often associated with immutable/functional design.

Notification and Subscription

Another important aspect of the library design is the notification/subscription functionality provided through until_change and until_bubbling_change.

Change listening is done based on Paths' hashes. We have a map associating each hash to a version number and a list of listening Wakers. The until_change method registers wakers at the hash of its target path and all prefix paths (when some piece of data encompassing the target data is changed, we assume the target data is changed too).

Hash Collision

If two paths end up with the same hash, wake notification to one would wake listeners to the other too. Thus, the until_change stream may fire spuriously.

Keep in mind that the probability of u64 hash collision is extremely low; with 10,000 distinct paths in a store, collision probability can be calculated to be less than 1E-11 (0.000000001%).

To further minimize the impact of hash collisions, X-Bow saves the length of paths along with their hashes. This increases collision probability, but it ensures that paths of different lengths never collide; modifying some data deep in the state tree would never result in the entire tree being woken.

Dependencies

~305–770KB
~18K SLoC