Skip to content

Deriving stocks

#[derive(Stock)] generates one accessor per field, so you can drill into nested state and get a reactive handle on just that part.

This is the struct the book’s examples drill into. It is the real one (the live demos compile against it), and the Serialize / Deserialize derives are there only because it also crosses the bridge.

lib.rs
#[derive(Stock, Serialize, Deserialize)]
pub struct State {
count: i32,
items: Vec<i32>,
}
let state = Stock::new(State { count: 0, items: vec![] });
let count = state.count(); // Stock<i32>
let items = state.items(); // Stock<Vec<i32>>

Each accessor is itself a stock. You can read it, write it, memo it, or turn it into a hail.

Accessors work on every stock kind and keep its nature: on a Stock they hand out writable stocks, on a ReadStock read-only ones. A step that might miss (a collection get, an enum variant) always yields the Opt counterpart.

Vec and HashMap stocks get get, which returns an opt stock: the entry may not exist.

let first = state.items().get(0); // OptStock<i32>
let apples = state.fruits().get("apple"); // OptStock<u32>

On an enum, accessors are generated per variant. A variant is only active some of the time, so these are opt stocks too.

#[derive(Stock)]
enum Shape {
Circle(f32),
Rect { w: f32, h: f32 },
}
let radius = shape.circle(); // OptStock<f32>: None unless it is a Circle

Accessor names are the variant name in snake case, so Rect becomes rect().

This is the part worth understanding.

Derivation is path-selective. Writing items[0] notifies whatever is watching items[0], and its ancestors: items, and the root. It does not notify items[1].

*state.items().get(0).write() = 5;
  • a hail on Item(0) → recomputed
  • a hail on Items → recomputed (an ancestor)
  • a hail on Item(1) → untouched
  • a hail on Count → untouched

So a list of a thousand rows does not rerender because one row changed. You get that without writing any comparison logic.

Notification is per path. Borrowing is per root, and mixing the two up is the easiest mistake to make here.

let first = state.items().get(0).read();
*state.count().write() += 1; // BorrowConflict: same root, overlapping guards

Those are different paths, and the write notifies nothing the read is watching, yet the guards still collide. Two reads are fine; a write needs the other guard already dropped:

let first = *state.items().get(0).read().unwrap(); // dropped here
*state.count().write() += first;

Worth knowing before you reach for a memo that updates a counter while reading other state: put that counter in its own Stock, and the question does not come up. See when an access can fail.

On the Rust side of this demo, one effect watches items[0] and another watches items[1]. Each counts how often it actually ran.

Paths.tsx
import { usePier } from "../../setup/solid/bridge";
export default function PathsDemo() {
const pier = usePier();
const [first, setFirst] = pier.hail({ Item: 0 }); // writable, path-derived
const [second, setSecond] = pier.hail({ Item: 1 });
const watch0 = pier.readHail("Watch0Runs"); // effect watching items[0]
const watch1 = pier.readHail("Watch1Runs"); // effect watching items[1]
return (
<div class="demo">
<p>
items[0]: <b id="item0">{first()}</b> · its watcher ran{" "}
<b id="w0">{watch0()}</b> times
</p>
<p>
items[1]: <b id="item1">{second()}</b> · its watcher ran{" "}
<b id="w1">{watch1()}</b> times
</p>
<button id="bump0" onClick={() => setFirst((first() ?? 0) + 1)}>
+1 on items[0]
</button>
<button id="bump1" onClick={() => setSecond((second() ?? 0) + 1)}>
+1 on items[1]
</button>
</div>
);
}

Running now, in your browser

Bump items[0] and only its watcher moves. The two paths share a Vec, a struct, and a root stock, yet still do not wake each other up.

#[stock] attaches methods to Stock<YourType> through an extension trait.

#[derive(Stock)]
struct Pair {
x: u32,
y: u32,
}
#[stock]
impl Stock<Pair> {
fn sum(&self) -> u32 {
*self.x().peek() + *self.y().peek()
}
fn swap(&self) {
let x = *self.x().peek();
let y = *self.y().peek();
self.x().set(y);
self.y().set(x);
}
}
pair.sum();
pair.swap();

Use it to keep state logic next to the state instead of scattered across runners.

Pass a name if you want to control the generated trait:

#[stock(PointOps)]
impl Stock<Point> { /* ... */ }

Mark a field or variant to leave it out of the generated accessors:

#[derive(Stock)]
struct State {
count: i32,
#[stock(skip)]
scratch: Vec<u8>,
}

Accessors give you the values. Memo and Effect is how you react to them.