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

reactive_stores

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

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

Download history 681/week @ 2024-10-26 1805/week @ 2024-11-02 1032/week @ 2024-11-09 937/week @ 2024-11-16 982/week @ 2024-11-23 2218/week @ 2024-11-30 2649/week @ 2024-12-07 3235/week @ 2024-12-14 2988/week @ 2024-12-21 3094/week @ 2024-12-28 4812/week @ 2025-01-04 5683/week @ 2025-01-11 5128/week @ 2025-01-18 4731/week @ 2025-01-25 6035/week @ 2025-02-01 6953/week @ 2025-02-08

23,754 downloads per month
Used in 76 crates (2 directly)

MIT license

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

  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,
});

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:

  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.6–4.5MB
~85K SLoC