Expand description
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
- Make your structs and enums trackable by putting
#[derive(Trackable)]
and[track(deep)]
on them. See documentation for the Trackable macro.// 👇 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 } }
- 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);
- Make Paths.
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();
- 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.
Modules
Structs
- A Path that is made from borrowing an owned path.
- A store is where your “state” data lives. It is in essence a big RefCell. There are also supporting mechanisms to enable subscriptions and mutation notifications.
Traits
- For converting a PathBuilder into the Path inside it.
- A Path object identifies a piece of the data store.
- An extension trait with methods for Path.
- Implemented for Path objects that will always be able to obtain their data piece.
- Allow building Paths into parts of this data type.
Type Definitions
- The PathBuilder pointing to the root data type in the store itself.
Derive Macros
- Macro to allows building paths to fields inside a struct/enum.