13 releases
new 0.1.7 | Feb 12, 2025 |
---|---|
0.1.6 | Feb 12, 2025 |
0.1.5 | Jan 31, 2025 |
0.1.2 | Dec 21, 2024 |
0.1.0-rc0 | Oct 22, 2024 |
#202 in Data structures
23,754 downloads per month
Used in 76 crates
(2 directly)
605KB
13K
SLoC
Stores
Stores are a data structure for nested reactivity.
The reactive_graph
crate provides primitives for fine-grained reactivity
via signals, memos, and effects.
This crate extends that reactivity to support reactive access to nested dested, without the need to create nested signals.
Using the #[derive(Store)]
macro on a struct creates a series of getters that allow accessing each field. Individual fields
can then be read as if they were signals. Changes to parents will notify their children, but changing one sibling field will
not notify any of the others, nor will it require diffing those sibling fields (unlike earlier solutions using memoized “slices”).
This is published for use with the Leptos framework but can be used in any scenario where reactive_graph
is being used
for reactivity.
lib.rs
:
Stores are a primitive for creating deeply-nested reactive state, based on reactive_graph
.
Reactive signals allow you to define atomic units of reactive state. However, signals are imperfect as a mechanism for tracking reactive change in structs or collections, because they do not allow you to track access to individual struct fields or individual items in a collection, rather than the struct as a whole or the collection as a whole. Reactivity for individual fields can be achieved by creating a struct of signals, but this has issues; it means that a struct is no longer a plain data structure, but requires wrappers on each field.
Stores attempt to solve this problem by allowing arbitrarily-deep access to the fields of some data structure, while still maintaining fine-grained reactivity.
The Store
macro adds getters and setters for the fields of a struct. Call those getters or
setters on a reactive Store
or ArcStore
, or to a subfield, gives you
access to a reactive subfield. This value of this field can be accessed via the ordinary signal
traits (Get
, Set
, and so on).
The Patch
macro allows you to annotate a struct such that stores and fields have a
.patch()
method, which allows you to provide an entirely new value, but only
notify fields that have changed.
Updating a field will notify its parents and children, but not its siblings.
Stores can therefore
- work with plain Rust data types, and
- provide reactive access to individual fields
Example
use reactive_graph::{
effect::Effect,
traits::{Read, Write},
};
use reactive_stores::{Patch, Store};
#[derive(Debug, Store, Patch, Default)]
struct Todos {
user: String,
todos: Vec<Todo>,
}
#[derive(Debug, Store, Patch, Default)]
struct Todo {
label: String,
completed: bool,
}
let store = Store::new(Todos {
user: "Alice".to_string(),
todos: Vec::new(),
});
Effect::new(move |_| {
// you can access individual store withs field a getter
println!("todos: {:?}", &*store.todos().read());
});
// won't notify the effect that listen to `todos`
store.todos().write().push(Todo {
label: "Test".to_string(),
completed: false,
});
Generated traits
The Store
macro generates traits for each struct
to which it is applied. When working
within a single file more module, this is not an issue. However, when working with multiple modules
or files, one needs to use
the generated traits. The general pattern is that for each struct
named Foo
, the macro generates a trait named FooStoreFields
. For example:
pub mod foo {
use reactive_stores::Store;
#[derive(Store)]
pub struct Foo {
field: i32,
}
}
pub mod user {
use leptos::prelude::*;
use reactive_stores::Field;
// Using FooStore fields here.
use crate::foo::{ Foo, FooStoreFields };
#[component]
pub fn UseFoo(foo: Field<Foo>) {
// Without FooStoreFields, foo.field() would fail to compile.
println!("field: {}", foo.field().read());
}
}
Additional field types
Most of the time, your structs will have fields as in the example above: the struct is comprised of primitive types, builtin types like [String], or other structs that implement Store or [Field]. However, there are some special cases that require some additional understanding.
Option
Option<T>
behaves pretty much as you would expect, utilizing .is_some()
and .is_none() to check the value and .unwrap() method to access the inner value. The [OptionStoreExt]
trait is required to use the .unwrap() method. Here is a quick example:
// Including the trait OptionStoreExt here is required to use unwrap()
use reactive_stores::{OptionStoreExt, Store};
use reactive_graph::traits::{Get, Read};
#[derive(Store)]
struct StructWithOption {
opt_field: Option<i32>,
}
fn describe(store: &Store<StructWithOption>) -> String {
if store.opt_field().read().is_some() {
// Note here we need to use OptionStoreExt or unwrap() would not compile
format!("store has a value {}", store.opt_field().unwrap().get())
} else {
format!("store has no value")
}
}
let none_store = Store::new(StructWithOption { opt_field: None });
let some_store = Store::new(StructWithOption { opt_field: Some(42)});
assert_eq!(describe(&none_store), "store has no value");
assert_eq!(describe(&some_store), "store has a value 42");
Vec
Vec<T>
requires some special treatment when trying to access
elements of the vector directly. Use the [StoreFieldIterator::at_unkeyed()] method to
access a particular value in a [struct@Store] or [Field] for a std::vec::Vec. For example:
// Needed to use at_unkeyed() on Vec
use reactive_stores::StoreFieldIter;
use crate::reactive_stores::StoreFieldIterator;
use reactive_graph::traits::Read;
use reactive_graph::traits::Get;
#[derive(Store)]
struct StructWithVec {
vec_field: Vec<i32>,
}
let store = Store::new(StructWithVec { vec_field: vec![1, 2, 3] });
assert_eq!(store.vec_field().at_unkeyed(0).get(), 1);
assert_eq!(store.vec_field().at_unkeyed(1).get(), 2);
assert_eq!(store.vec_field().at_unkeyed(2).get(), 3);
Enum
Enumerated types behave a bit differently as the Store
macro builds underlying traits instead of alternate
enumerated structures. Each element in an Enum
generates methods to access it in the store: a
method with the name of the field gives a boolean if the Enum
is that variant, and possible accessor
methods for anonymous fields of that variant. For example:
use reactive_stores::Store;
use reactive_graph::traits::{Read, Get};
#[derive(Store)]
enum Choices {
First,
Second(String),
}
let choice_one = Store::new(Choices::First);
let choice_two = Store::new(Choices::Second("hello".to_string()));
assert!(choice_one.first());
assert!(!choice_one.second());
// Note the use of the accessor method here .second_0()
assert_eq!(choice_two.second_0().unwrap().get(), "hello");
Box
Box<T>
also requires some special treatment in how you dereference elements of the Box, especially
when trying to build a recursive data structure. DerefField provides a .deref_value() method to access
the inner value. For example:
// Note here we need to use DerefField to use deref_field() and OptionStoreExt to use unwrap()
use reactive_stores::{Store, DerefField, OptionStoreExt};
use reactive_graph::traits::{ Read, Get };
#[derive(Store)]
struct List {
value: i32,
#[store]
child: Option<Box<List>>,
}
let tree = Store::new(List {
value: 1,
child: Some(Box::new(List { value: 2, child: None })),
});
assert_eq!(tree.child().unwrap().deref_field().value().get(), 2);
Implementation Notes
Every struct field can be understood as an index. For example, given the following definition
#[derive(Debug, Store, Patch, Default)]
struct Name {
first: String,
last: String,
}
We can think of first
as 0
and last
as 1
. This means that any deeply-nested field of a
struct can be described as a path of indices. So, for example:
#[derive(Debug, Store, Patch, Default)]
struct User {
user: Name,
}
#[derive(Debug, Store, Patch, Default)]
struct Name {
first: String,
last: String,
}
Here, given a User
, first
can be understood as [0
, 0
] and last
is [0
, 1
].
This means we can implement a store as the combination of two things:
- An
Arc<RwLock<T>>
that holds the actual value - A map from field paths to reactive "triggers," which are signals that have no value but track reactivity
Accessing a field via its getters returns an iterator-like data structure that describes how to
get to that subfield. Calling .read()
returns a guard that dereferences to the value of that
field in the signal inner Arc<RwLock<_>>
, and tracks the trigger that corresponds with its
path; calling .write()
returns a writeable guard, and notifies that same trigger.
Dependencies
~2.6–4.5MB
~85K SLoC