# Bindings and change signals
Zero-copy access to store state: read and write through the shared mutex, project
fields with keypaths (SwiftUI [`Binding` dynamic-member
lookup](https://developer.apple.com/documentation/swiftui/binding/subscript(dynamicmember:))),
and subscribe to **which fields changed** without cloning the whole tree.
For dispatch, scoping, and the legacy snapshot subscriber, see [store.md](./store.md).
---
## 1. Why bindings?
The classic store APIs clone state:
| `store.state()` | deep clone of `S` every call |
| `subscribe_state()` | clone at subscribe + clone on every `next()` |
| `ScopedStateSubscriber` | clone parent, then clone focused child |
For large root state (shop catalog, payment trees, identified collections) that is
the hot cost. Bindings and change subscriptions fix it:
```
┌─────────────────────────────────────────────────────────────────┐
│ Arc<Mutex<S>> ← single copy in the runtime │
│ ▲ │
│ │ lock (shared) │
│ StateBinding ──project(kp)──► ProjectedBinding ──► field │
│ ▲ │
│ │ same Arc │
│ ChangeSubscriber ──► ChangeSet { paths: [Count, Label] } │
│ (hashes only — no Arc<S>, no clone) │
└─────────────────────────────────────────────────────────────────┘
```
---
## 2. `StateBinding` — root access without clone
```rust
let binding = store.binding();
// read under lock
// write under lock
binding.with_mut(|app| app.count += 1);
```
- Holds `Arc<Mutex<S>>` — same mutex the reducer uses.
- `Clone` is cheap (Arc bump only).
- Never clones `S`.
`ScopedStore::binding()` returns a `ProjectedBinding` focused on the child slice.
---
## 3. Keypath projection (dynamic member lookup)
Project a property into its own binding. Reads and writes go through the **original**
store mutex — like SwiftUI projecting `currentPosition` and `isFavorite` from an
`Episode` binding into controls that each accept a narrower binding type.
```rust
use key_paths_derive::{FieldDiff, Kp};
#[derive(Kp, Hash, FieldDiff, Clone)]
struct Episode {
current_position: i32,
is_favorite: bool,
}
let root = store.binding();
let position = root.project(episode_position_kp()); // fn-pointer or derived Kp
let favorite = root.project(episode_favorite_kp());
`ProjectedBinding::with` / `with_mut` and `ComposedBinding::with` / `with_mut` return
`Option<R>`. When the keypath misses (`None` in an `Option` field, wrong enum case, etc.)
they return `None` instead of panicking:
```rust
} else {
render_empty();
}
```
Root `StateBinding::with` / `with_mut` always succeed — the whole `S` is present.
---
## 4. `subscribe_changes` — signals, not snapshots
Requires `S: FieldDiff + Hash`. Derive field tracking alongside keypaths:
```rust
#[derive(Kp, Hash, FieldDiff, Clone, PartialEq)]
struct App {
count: i32,
label: String,
}
// generates AppField::{Count, Label}
```
Subscribe:
```rust
let mut sub = store.subscribe_changes();
store.dispatch(Action::Inc);
if let Some(change) = sub.wait_next(Duration::from_secs(1)) {
for path in change.paths {
match path {
AppField::Count => {
if let Some(n) = sub.binding().with(|s| s.count) {
render_count(n);
}
}
AppField::Label => { /* read label via sub.binding() */ }
}
}
}
```
### What the subscriber stores
| `last_hash` | `Option<u64>` | root hash dedup |
| `last_fields` | `Vec<(Path, u64)>` | one u64 per field |
| `state` | `Arc<Mutex<S>>` | read on demand via `sub.binding()` |
No `Arc<S>`. No `state.lock().clone()`.
### Dedup logic
1. Coalesce channel ticks (same as `StateSubscriber`).
2. Hash root + each field under the lock.
3. If root hash unchanged → skip.
4. Emit only paths whose field hash changed.
### Scoped child — `ScopedStore::subscribe_changes`
Hashes the **focused child** under the parent lock (`CS: FieldDiff`). If the child is
absent at subscribe time (e.g. `Option::None`), the subscriber starts with empty field
tracking and begins emitting once the child appears — same safe pattern as
`ScopedStateSubscriber` skipping missing snapshots.
```rust
let cart = store.scope(ShopState::cart(), ShopAction::cart_cp());
let mut sub = cart.subscribe_changes();
cart.dispatch(CartAction::AddItem(id));
if let Some(change) = sub.wait_next(timeout) {
for path in change.paths { /* CartField::* */ }
sub.binding().with(|cart| render(cart));
}
```
---
## 5. `FieldDiff` trait
Defined in `key-paths-core`, derived with `#[derive(FieldDiff)]`:
```rust
pub trait FieldDiff: Hash {
type Path: Copy + Eq + Hash + Send + Sync + 'static;
fn field_hashes(&self, out: &mut Vec<(Self::Path, u64)>);
}
```
Each field is hashed independently via FNV-1a (`key_paths_core::hash_value`). Field
types must implement `Hash` (use integer ids instead of `f64` where needed).
Pair with `#[derive(Kp)]` so views can focus changed paths with the same keypath
accessors used for scoping and reducers.
---
## 6. Migration guide
| `store.state().count` | `store.binding().with(\|s\| s.count)` |
| `subscribe_state()` + render whole tree | `subscribe_changes()` + render changed paths |
| `scoped.child_state()` | `scoped.binding().with(\|c\| ...)` |
| Compare `PartialEq` on full `S` | Compare per-field u64 hashes |
Keep `subscribe_state()` when you genuinely need an owned snapshot (tests, diffing whole
trees, sending state across threads without holding the lock). For UI loops and hot
readers, prefer bindings + change signals.
---
## 7. Architecture diagram
```mermaid
flowchart TB
subgraph Runtime
Mutex["Arc<Mutex<S>>"]
Reducer["reducer thread"]
Listeners["state_listeners: Vec<Sender<()>>"]
end
Reducer -->|"lock + mutate"| Mutex
Reducer -->|"notify_state()"| Listeners
Listeners -->|"tick ()"| ChangeSub["ChangeSubscriber"]
Listeners -->|"tick ()"| StateSub["StateSubscriber (legacy)"]
ChangeSub -->|"hash under lock"| Mutex
ChangeSub -->|"ChangeSet { paths }"| View["view / worker"]
StateSub -->|"clone S (legacy)"| View2["view needing Arc<S>"]
Binding["StateBinding"] -->|"with / with_mut"| Mutex
Binding -->|"project(kp)"| Proj["ProjectedBinding"]
Proj -->|"with → Option"| Mutex
View --> Binding
```
---
## 8. Quick reference
| Zero-copy root read/write | `store.binding()` → `with` / `with_mut` |
| Project field | `binding.project(kp)` → `ProjectedBinding` |
| Nested project | `projected.project(sub_kp)` → `ComposedBinding` |
| Safe optional field | `projected.with(\|v\| ...)` → `Option<R>` |
| Field change subscription | `store.subscribe_changes()` → `next()` / `wait_next()` |
| Read after change signal | `sub.binding().with(...)` |
| Scoped binding | `scoped.binding()` |
| Scoped change subscription | `scoped.subscribe_changes()` |
| Derive field paths | `#[derive(Kp, Hash, FieldDiff)]` |
| Legacy full snapshot | `store.subscribe_state()` (see [store.md](./store.md)) |
### RwLock (`RwStore`)
Same projection model on `Arc<RwLock<S>>`. Hold one [`RwStore`](./rw_ecommerce.md); call
`store.read_store()` for reader threads:
| Root read (many threads) | `store.read_store().with_read(f)` |
| Root read/write binding | `store.binding()` → `RwStateBinding` |
| Projected read | `read_store.read_binding().project(kp)` → `ReadProjectedBinding` |
| Projected write | `store.binding().project(kp)` → `RwProjectedBinding` |
See [rw_ecommerce.md](./rw_ecommerce.md) for the shop example.
### ArcSwap (`SwapStore`, `arc-swap` feature)
Lock-free snapshot reads via [`ArcSwap`](https://docs.rs/arc-swap). Hold one [`SwapStore`](./swap_ecommerce.md); call
`store.snapshot_store()` for reader threads:
| Root read (many threads) | `store.snapshot_store().with_snapshot(f)` or `.load()` |
| Root snapshot binding | `store.snapshot_store().snapshot_binding()` → `SnapshotStateBinding` |
| Projected read | `snapshot_binding().project(kp)` → `SnapshotProjectedBinding` |
| Scoped snapshot binding | `scoped.snapshot_binding()` |
Readers never take `Mutex` / `RwLock` on `S`. The reducer clones the current `Arc<S>`, reduces, and atomically publishes a new snapshot — readers may observe slightly stale data until the swap completes.
See [swap_ecommerce.md](./swap_ecommerce.md) for the shop example and trade-offs vs `RwStore`.
---
## 9. Related
| [store.md](./store.md) | Dispatch, scoping, `subscribe_state`, `StoreTask` |
| [safe_reducer.md](./safe_reducer.md) | `safe_reduce_update`, `CatchReducer`, rollback |
| [architecture.md](./architecture.md) | Runtime threading, reducer loop |
| [validation.md](./validation.md) | Keypath field validation |
| [`tests/store_changes_integration.rs`](../tests/store_changes_integration.rs) | Zero-clone tests with `panic_on_state_clone!` |
| [`src/runtime/binding.rs`](../src/runtime/binding.rs) | `StateBinding`, `ProjectedBinding`, `ComposedBinding`, `ReadStateBinding`, `RwStateBinding`, `SnapshotStateBinding` |
| [`src/runtime/rw_store.rs`](../src/runtime/rw_store.rs) | `RwStore`, `ReadStore` |
| [`src/runtime/swap_store.rs`](../src/runtime/swap_store.rs) | `SwapStore`, `SnapshotStore` |
| [`src/runtime/store.rs`](../src/runtime/store.rs) | `ChangeSubscriber`, `ChangeSet`, `ScopedChangeSubscriber` |