#reactive #store #reactivity #field #state #signals #nested

reactive_stores

Stores for holding deeply-nested reactive state while maintaining fine-grained reactive tracking

7 releases

0.1.0 Nov 30, 2024
0.1.0-rc3 Nov 28, 2024
0.1.0-rc0 Oct 22, 2024

#152 in Data structures

Download history 154/week @ 2024-10-03 191/week @ 2024-10-10 593/week @ 2024-10-17 802/week @ 2024-10-24 1406/week @ 2024-10-31 1400/week @ 2024-11-07 817/week @ 2024-11-14 904/week @ 2024-11-21 1984/week @ 2024-11-28 1986/week @ 2024-12-05

5,842 downloads per month
Used in 27 crates (via tachys)

MIT license

560KB
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

  1. work with plain Rust data types, and
  2. 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,
});

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:

  1. An Arc<RwLock<T>> that holds the actual value
  2. 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.5–4.5MB
~84K SLoC