r-state-tree 0.4.3 → 0.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +321 -11
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -18,6 +18,30 @@ This library uses [TC39 Stage 3 Decorators](https://github.com/tc39/proposal-dec
|
|
|
18
18
|
|
|
19
19
|
The library includes a decorator metadata polyfill for runtimes that don't yet natively support `Symbol.metadata`.
|
|
20
20
|
|
|
21
|
+
## TypeScript config (Stage 3 decorators)
|
|
22
|
+
|
|
23
|
+
TypeScript 5+ supports TC39 Stage 3 decorators.
|
|
24
|
+
|
|
25
|
+
```json
|
|
26
|
+
{
|
|
27
|
+
"compilerOptions": {
|
|
28
|
+
"target": "es2022",
|
|
29
|
+
"module": "esnext",
|
|
30
|
+
"moduleResolution": "bundler",
|
|
31
|
+
"strict": true,
|
|
32
|
+
"experimentalDecorators": false,
|
|
33
|
+
"useDefineForClassFields": true,
|
|
34
|
+
"lib": ["es2022", "dom"],
|
|
35
|
+
"jsx": "react-jsx",
|
|
36
|
+
"noEmit": true
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
- `experimentalDecorators` must be `false` for Stage 3 decorators.
|
|
42
|
+
- `useDefineForClassFields: true` is recommended with modern toolchains targeting ES2022.
|
|
43
|
+
- The library includes a `Symbol.metadata` polyfill via `@tsmetadata/polyfill`.
|
|
44
|
+
|
|
21
45
|
## Core concepts
|
|
22
46
|
|
|
23
47
|
- Stores: application/view state containers. Create with `createStore()`, attach with `mount()`. Compose with `@child` (single or arrays, stable via `{ key }`). React to changes with `effect`/`reaction` and store lifecycles (`storeDidMount`/`storeWillUnmount`). Update reactive `props` via `updateStore()`.
|
|
@@ -25,6 +49,26 @@ The library includes a decorator metadata polyfill for runtimes that don't yet n
|
|
|
25
49
|
- Context: pass data through Store/Model trees without prop drilling using `createContext<T>()`, `[Context.provide]`, and `Context.consume(this)`. Context is reactive and can be overridden by descendants.
|
|
26
50
|
- Reactivity: powered by signals. Use `@observable`, `@computed`, `effect`, `reaction`, `batch`, and `untracked` for precise updates.
|
|
27
51
|
|
|
52
|
+
## Separation of concerns
|
|
53
|
+
|
|
54
|
+
- Models: domain state + domain logic. Keep invariants, domain mutations (in-place updates), and computed/derived getters here. Models are serializable; mutate arrays/maps/sets in place and expose methods to add/remove/upsert. Derive values via getters.
|
|
55
|
+
- Stores: application/view state + orchestration. Coordinate routing, timers, reactions, and I/O. Stores call model methods to perform domain changes. Avoid embedding domain rules in stores.
|
|
56
|
+
|
|
57
|
+
### Why Stores and Models (motivation)
|
|
58
|
+
|
|
59
|
+
- What is a Model? Persistent domain state plus domain rules. It holds identifiers, references, invariants, and exposes pure domain mutations and derived getters. It is serializable (snapshots), re-hydratable, and safe to reuse across views.
|
|
60
|
+
- What is a Store? Application/view state and orchestration. It wires effects (reactions, timers, I/O), reacts to user intent, and delegates domain changes to Models. Stores are not snapshotted.
|
|
61
|
+
- Why separate?
|
|
62
|
+
- Snapshots/undo/redo work cleanly when only domain lives in Models.
|
|
63
|
+
- Views stay simple: UI reads derived getters, calls Store methods; Stores call Model methods.
|
|
64
|
+
- Reuse: one Model can back multiple Stores/views without UI coupling.
|
|
65
|
+
- Testability: Models are deterministic and easy to unit test; Stores are thin orchestrators.
|
|
66
|
+
- Performance/identity: Models mutate in place; Stores manage child identity with `key`.
|
|
67
|
+
- Quick rule of thumb:
|
|
68
|
+
- If it should be in a snapshot or referenced by id, put it in a Model (`@state`, `@child`, `@id`, `@modelRef`).
|
|
69
|
+
- If it is ephemeral UI/app state or side-effect orchestration, put it in a Store.
|
|
70
|
+
- Components should read from one Store; if a component needs multiple sources, compose them into a higher-level Store.
|
|
71
|
+
|
|
28
72
|
## Stores
|
|
29
73
|
|
|
30
74
|
Stores describe reactive state containers composed into a tree.
|
|
@@ -32,9 +76,7 @@ Stores describe reactive state containers composed into a tree.
|
|
|
32
76
|
```ts
|
|
33
77
|
import { Store, createStore, mount, child } from "r-state-tree";
|
|
34
78
|
|
|
35
|
-
class TodoStore extends Store {
|
|
36
|
-
props = { title: "" };
|
|
37
|
-
|
|
79
|
+
class TodoStore extends Store<{ title: string }> {
|
|
38
80
|
get title() {
|
|
39
81
|
return this.props.title;
|
|
40
82
|
}
|
|
@@ -50,6 +92,26 @@ const app = mount(createStore(AppStore));
|
|
|
50
92
|
app.todo.title; // "Write docs"
|
|
51
93
|
```
|
|
52
94
|
|
|
95
|
+
### Store creation, props, and typing
|
|
96
|
+
|
|
97
|
+
- Always create with `createStore()` and attach with `mount()`. Stores cannot be constructed with `new` directly.
|
|
98
|
+
- Prefer no custom constructor. Type stores as `Store<Props>` and access props via `this.props`.
|
|
99
|
+
- If a constructor is necessary, call `super(props)` exactly once and keep it minimal; put effects in `storeDidMount`.
|
|
100
|
+
- Do not shadow or re-declare `props` as a class field; `props` is read-only. Use the generic `Store<{ ... }>` for typing.
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
class ItemStore extends Store<{ id: number; title?: string }> {
|
|
104
|
+
get id() {
|
|
105
|
+
return this.props.id;
|
|
106
|
+
}
|
|
107
|
+
get title() {
|
|
108
|
+
return this.props.title ?? "";
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const root = mount(createStore(ItemStore, { id: 1, title: "Hello" }));
|
|
113
|
+
```
|
|
114
|
+
|
|
53
115
|
### Updating props
|
|
54
116
|
|
|
55
117
|
Stores receive reactive `props` objects. Use `updateStore` to change them.
|
|
@@ -78,6 +140,26 @@ class ListStore extends Store {
|
|
|
78
140
|
}
|
|
79
141
|
```
|
|
80
142
|
|
|
143
|
+
#### Child store keys and identity
|
|
144
|
+
|
|
145
|
+
- Pass a stable `key` (e.g., an id) when creating child stores to preserve identity across reorders.
|
|
146
|
+
- `@child` must decorate a getter; child stores are derived from current state on access, and identity is preserved by keys.
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
class ItemsStore extends Store {
|
|
150
|
+
items = [
|
|
151
|
+
{ id: 1, title: "A" },
|
|
152
|
+
{ id: 2, title: "B" },
|
|
153
|
+
];
|
|
154
|
+
|
|
155
|
+
@child get itemStores() {
|
|
156
|
+
return this.items.map((it) =>
|
|
157
|
+
createStore(TodoStore, { key: it.id, title: it.title })
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
81
163
|
### Lifecycle hooks
|
|
82
164
|
|
|
83
165
|
Stores support lifecycle methods:
|
|
@@ -154,8 +236,39 @@ batch(() => {
|
|
|
154
236
|
});
|
|
155
237
|
```
|
|
156
238
|
|
|
239
|
+
### Models injection (@model)
|
|
240
|
+
|
|
241
|
+
Inject domain models into stores via the `models` creation prop and consume them with `@model` on the store. `@model` fields are read-only references.
|
|
242
|
+
|
|
243
|
+
```ts
|
|
244
|
+
import { Model, Store, model, createStore, mount } from "r-state-tree";
|
|
245
|
+
|
|
246
|
+
class User extends Model {
|
|
247
|
+
@id id = 0;
|
|
248
|
+
@state name = "";
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
class ProfileStore extends Store {
|
|
252
|
+
@model user!: User;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const user = User.create({ id: 1, name: "Ada" });
|
|
256
|
+
const profile = mount(createStore(ProfileStore, { models: { user } }));
|
|
257
|
+
profile.user.name; // "Ada"
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
Type stores as `Store<Props>` and explicitly type `@model` fields for clarity. The `models` prop may also provide arrays of models.
|
|
261
|
+
|
|
157
262
|
## Modeling guide
|
|
158
263
|
|
|
264
|
+
### Store ↔ Component mapping (quick guide)
|
|
265
|
+
|
|
266
|
+
- Pair each container/screen component with one owning Store; components should read from a single Store.
|
|
267
|
+
- The Store tree overlays the component tree: parents map to parent Stores; children map to `@child` Stores; lists map to arrays of child Stores with stable `key`s.
|
|
268
|
+
- A Store can power multiple components (header/body/sidebar), but a component shouldn’t pull from multiple Stores. If it needs to, introduce a parent/adapter Store that composes and exposes exactly what the component needs.
|
|
269
|
+
- Use Context for cross‑cutting concerns (theme, auth) instead of coupling components to multiple Stores.
|
|
270
|
+
- Keep domain logic in Models; Stores orchestrate and delegate to Model methods, and expose derived getters for the UI.
|
|
271
|
+
|
|
159
272
|
- Root store: mount a single root Store that composes the application via `@child` properties.
|
|
160
273
|
- View stores: create one Store per view/route/tab. Views render from stores; stores drive view transitions.
|
|
161
274
|
- Keyed children: pass `{ key }` when creating child stores to preserve identity across reorders.
|
|
@@ -177,21 +290,58 @@ class RootStore extends Store {
|
|
|
177
290
|
const root = mount(createStore(RootStore));
|
|
178
291
|
```
|
|
179
292
|
|
|
180
|
-
|
|
293
|
+
### Patterns: deriving Stores from Models
|
|
294
|
+
|
|
295
|
+
Derive child stores directly from model arrays with stable keys; delegate mutations to model methods.
|
|
181
296
|
|
|
182
297
|
```ts
|
|
183
|
-
class
|
|
184
|
-
|
|
298
|
+
class ItemModel extends Model {
|
|
299
|
+
@id id = 0;
|
|
300
|
+
@state title = "";
|
|
185
301
|
}
|
|
186
302
|
|
|
187
|
-
class
|
|
188
|
-
@
|
|
303
|
+
class ListModel extends Model {
|
|
304
|
+
@child(ItemModel) items: ItemModel[] = [];
|
|
305
|
+
|
|
306
|
+
add(id: number, title: string) {
|
|
307
|
+
this.items.push(ItemModel.create({ id, title }));
|
|
308
|
+
}
|
|
309
|
+
remove(id: number) {
|
|
310
|
+
const i = this.items.findIndex((m) => m.id === id);
|
|
311
|
+
if (i >= 0) this.items.splice(i, 1);
|
|
312
|
+
}
|
|
313
|
+
get titles() {
|
|
314
|
+
return this.items.map((m) => m.title);
|
|
315
|
+
}
|
|
189
316
|
}
|
|
190
317
|
|
|
191
|
-
|
|
192
|
-
|
|
318
|
+
class ItemStore extends Store {
|
|
319
|
+
@model item!: ItemModel;
|
|
320
|
+
get title() {
|
|
321
|
+
return this.item.title;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
class ListStore extends Store {
|
|
326
|
+
@model list!: ListModel;
|
|
327
|
+
|
|
328
|
+
@child get items() {
|
|
329
|
+
return this.list.items.map((item) =>
|
|
330
|
+
createStore(ItemStore, { key: item.id, models: { item } })
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
addItem(id: number, title: string) {
|
|
335
|
+
this.list.add(id, title); // delegate to domain
|
|
336
|
+
}
|
|
337
|
+
}
|
|
193
338
|
```
|
|
194
339
|
|
|
340
|
+
## Mutability rules
|
|
341
|
+
|
|
342
|
+
- Models: prefer in-place mutation for arrays/maps/sets (`push`, `splice`, `set`, etc.). Replace the whole structure only when you intentionally want to swap the instance.
|
|
343
|
+
- Stores: store fields are reactive; in-place mutation is fine. Reassign the entire structure only if you need identity replacement semantics.
|
|
344
|
+
|
|
195
345
|
## Observable classes
|
|
196
346
|
|
|
197
347
|
For reactive class instances outside the Model/Store system, use `@observable` and `@computed` decorators:
|
|
@@ -225,7 +375,7 @@ counter.increment(); // Triggers the effect
|
|
|
225
375
|
|
|
226
376
|
### Plain objects (implicit reactivity)
|
|
227
377
|
|
|
228
|
-
Plain objects wrapped with `observable()` use implicit reactivity
|
|
378
|
+
Plain objects wrapped with `observable()` use implicit reactivity:
|
|
229
379
|
|
|
230
380
|
```ts
|
|
231
381
|
import { observable, effect } from "r-state-tree";
|
|
@@ -324,6 +474,27 @@ todo.title = "Learn r-state-tree";
|
|
|
324
474
|
stop();
|
|
325
475
|
```
|
|
326
476
|
|
|
477
|
+
### Snapshots and persistence
|
|
478
|
+
|
|
479
|
+
- Snapshots capture Models (not Stores).
|
|
480
|
+
- Hydrate/persist with `applySnapshot` and `onSnapshot`:
|
|
481
|
+
|
|
482
|
+
```ts
|
|
483
|
+
const STORAGE_KEY = "list";
|
|
484
|
+
|
|
485
|
+
// hydrate
|
|
486
|
+
const list = ListModel.create();
|
|
487
|
+
const saved = localStorage.getItem(STORAGE_KEY);
|
|
488
|
+
if (saved) applySnapshot(list, JSON.parse(saved));
|
|
489
|
+
|
|
490
|
+
// persist
|
|
491
|
+
const off = onSnapshot(list, (snap) => {
|
|
492
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(snap));
|
|
493
|
+
});
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
Mutate Models through domain methods and let snapshots record changes automatically.
|
|
497
|
+
|
|
327
498
|
### Model lifecycle hooks
|
|
328
499
|
|
|
329
500
|
Models support lifecycle methods:
|
|
@@ -350,6 +521,12 @@ class TodoModel extends Model {
|
|
|
350
521
|
}
|
|
351
522
|
```
|
|
352
523
|
|
|
524
|
+
When to use each:
|
|
525
|
+
|
|
526
|
+
- `modelDidInit`: initialize/normalize data based on the initial snapshot.
|
|
527
|
+
- `modelDidAttach`: link to other models or read context after the model is part of a tree.
|
|
528
|
+
- `modelWillDetach`: cleanup before the model is removed or replaced.
|
|
529
|
+
|
|
353
530
|
### Model decorators
|
|
354
531
|
|
|
355
532
|
Use decorators to configure model properties:
|
|
@@ -477,6 +654,27 @@ s1.value = 1;
|
|
|
477
654
|
state.count = 2;
|
|
478
655
|
```
|
|
479
656
|
|
|
657
|
+
#### React / Preact usage
|
|
658
|
+
|
|
659
|
+
- Preact: use `@preact/signals`. Reading `signal.value` inside JSX is reactive; components re-render automatically.
|
|
660
|
+
- React: use `@preact/signals-react`. Call `useSignals()` in a component and read `signal.value` in render; updates re-render the component.
|
|
661
|
+
|
|
662
|
+
```ts
|
|
663
|
+
// Preact
|
|
664
|
+
function TodoView({ store }: { store: TodoStore }) {
|
|
665
|
+
return <h1>{store.$title.value}</h1>;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// React
|
|
669
|
+
import { useSignals } from "@preact/signals-react/runtime";
|
|
670
|
+
function TodoView({ store }: { store: TodoStore }) {
|
|
671
|
+
useSignals();
|
|
672
|
+
return <h1>{store.$title.value}</h1>;
|
|
673
|
+
}
|
|
674
|
+
```
|
|
675
|
+
|
|
676
|
+
You can also use `getSignal(store, "title")` instead of `$title`. Use the observers/renderers provided by the signals bindings for your UI library; r-state-tree will participate automatically because Stores/Models are signal-backed.
|
|
677
|
+
|
|
480
678
|
### Identifier and reference rules
|
|
481
679
|
|
|
482
680
|
- `@id` values are unique within a tree. They cannot be cleared to `undefined` after assignment.
|
|
@@ -530,6 +728,118 @@ class ProjectModel extends Model {
|
|
|
530
728
|
}
|
|
531
729
|
```
|
|
532
730
|
|
|
731
|
+
## Do/Don’t guide
|
|
732
|
+
|
|
733
|
+
Do:
|
|
734
|
+
|
|
735
|
+
- Keep domain operations in Models
|
|
736
|
+
- Delegate from Stores to Models for domain changes
|
|
737
|
+
- Use `@child` for child stores (getter-based)
|
|
738
|
+
- Use stable `key` values for child stores
|
|
739
|
+
- Mutate model arrays/maps/sets in place
|
|
740
|
+
|
|
741
|
+
Don’t:
|
|
742
|
+
|
|
743
|
+
- Shadow or re-declare `props` on stores
|
|
744
|
+
- Instantiate Stores with `new`
|
|
745
|
+
- Perform effectful work in constructors
|
|
746
|
+
- Manually “sync” store state into Models (call model methods instead)
|
|
747
|
+
|
|
748
|
+
## Compact code samples
|
|
749
|
+
|
|
750
|
+
Store without a constructor:
|
|
751
|
+
|
|
752
|
+
```ts
|
|
753
|
+
class ViewStore extends Store<{ q?: string }> {
|
|
754
|
+
get q() {
|
|
755
|
+
return this.props.q ?? "";
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
```
|
|
759
|
+
|
|
760
|
+
Store with an injected `@model`:
|
|
761
|
+
|
|
762
|
+
```ts
|
|
763
|
+
class ItemStore extends Store {
|
|
764
|
+
@model item!: ItemModel;
|
|
765
|
+
}
|
|
766
|
+
const item = ItemModel.create({ id: 1, title: "X" });
|
|
767
|
+
const s = mount(createStore(ItemStore, { models: { item } }));
|
|
768
|
+
```
|
|
769
|
+
|
|
770
|
+
`@child` mapping from a model array (stable keys):
|
|
771
|
+
|
|
772
|
+
```ts
|
|
773
|
+
class ListStore extends Store {
|
|
774
|
+
@model list!: ListModel;
|
|
775
|
+
@child get items() {
|
|
776
|
+
return this.list.items.map((item) =>
|
|
777
|
+
createStore(ItemStore, { key: item.id, models: { item } })
|
|
778
|
+
);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
```
|
|
782
|
+
|
|
783
|
+
Model with in-place mutations and a derived getter:
|
|
784
|
+
|
|
785
|
+
```ts
|
|
786
|
+
class ListModel extends Model {
|
|
787
|
+
@child(ItemModel) items: ItemModel[] = [];
|
|
788
|
+
add(m: ItemModel) {
|
|
789
|
+
this.items.push(m);
|
|
790
|
+
}
|
|
791
|
+
get count() {
|
|
792
|
+
return this.items.length;
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
```
|
|
796
|
+
|
|
797
|
+
Lifecycle hooks:
|
|
798
|
+
|
|
799
|
+
```ts
|
|
800
|
+
class M extends Model {
|
|
801
|
+
modelDidInit() {}
|
|
802
|
+
modelDidAttach() {}
|
|
803
|
+
modelWillDetach() {}
|
|
804
|
+
}
|
|
805
|
+
class S extends Store {
|
|
806
|
+
storeDidMount() {}
|
|
807
|
+
storeWillUnmount() {}
|
|
808
|
+
}
|
|
809
|
+
```
|
|
810
|
+
|
|
811
|
+
Snapshot hydrate/persist:
|
|
812
|
+
|
|
813
|
+
```ts
|
|
814
|
+
const m = ListModel.create();
|
|
815
|
+
const saved = localStorage.getItem("m");
|
|
816
|
+
if (saved) applySnapshot(m, JSON.parse(saved));
|
|
817
|
+
const off = onSnapshot(m, (snap) =>
|
|
818
|
+
localStorage.setItem("m", JSON.stringify(snap))
|
|
819
|
+
);
|
|
820
|
+
```
|
|
821
|
+
|
|
822
|
+
## Common pitfalls
|
|
823
|
+
|
|
824
|
+
- Forgetting stable keys for `@child` arrays causes identity churn.
|
|
825
|
+
- Assuming deep reactivity on undecorated fields of plain classes; use `@observable` for `Observable` classes, or use Stores/Models.
|
|
826
|
+
- Creating child stores in constructors: `@child` must be on getters so identity and lifecycle can be managed by the framework.
|
|
827
|
+
|
|
828
|
+
## Typing recipes
|
|
829
|
+
|
|
830
|
+
- Type stores as `Store<Props>`; read `this.props` inside methods/getters.
|
|
831
|
+
- Explicitly type `@model` fields on stores, and pass matching values via the `models` creation prop.
|
|
832
|
+
- When a store has no props, use `class X extends Store {}`.
|
|
833
|
+
|
|
834
|
+
## Cheat sheet
|
|
835
|
+
|
|
836
|
+
- Decorators (Models): `@state`, `@id`, `@child`, `@modelRef`
|
|
837
|
+
- Decorators (Stores): `@child`, `@model`
|
|
838
|
+
- Core: `createStore`, `mount`, `unmount`, `updateStore`
|
|
839
|
+
- Snapshots: `onSnapshot`, `toSnapshot`, `applySnapshot`, `onSnapshotDiff`
|
|
840
|
+
- Lifecycle: `storeDidMount`, `storeWillUnmount`, `modelDidInit`, `modelDidAttach`, `modelWillDetach`
|
|
841
|
+
- Best practices: domain in Models; delegate from Stores; stable keys for `@child`; in-place mutations in Models; no effectful constructors; don’t shadow `props`.
|
|
842
|
+
|
|
533
843
|
## Testing
|
|
534
844
|
|
|
535
845
|
The repository ships with Vitest suites covering stores, models, containers, and observable primitives.
|