r-state-tree 0.4.3 → 0.4.5

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 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
- Passing models into stores:
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 User extends Model {
184
- // ... @id, @state, etc.
298
+ class ItemModel extends Model {
299
+ @id id = 0;
300
+ @state title = "";
185
301
  }
186
302
 
187
- class ProfileStore extends Store {
188
- @model user: User; // injected model
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
- const user = User.create({ id: 1, name: "Alice" });
192
- const profile = mount(createStore(ProfileStore, { models: { user } }));
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 for backward compatibility:
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.
@@ -4,7 +4,7 @@ type CreateStoreProps<T extends Props> = {
4
4
  [K in keyof T as undefined extends T[K] ? K : never]?: T[K] extends infer U ? U extends undefined ? never : undefined extends U ? U | undefined : U : never;
5
5
  } & {
6
6
  [K in keyof T as undefined extends T[K] ? never : K]?: T[K];
7
- } & Pick<Props, 'key'>;
7
+ } & Pick<Props, "key" | "models"> & Partial<Record<string, unknown>>;
8
8
  export declare function createStore<K extends Store<T>, T extends Props>(Type: new (props: T) => K, props?: CreateStoreProps<T>): K;
9
9
  export declare function updateStore<K extends Store<T>, T extends Props>(store: K, props: CreateStoreProps<T>): K;
10
10
  export declare function types<T extends Store>(config: Partial<StoreConfiguration<T>>): Partial<StoreConfiguration<T>>;
package/package.json CHANGED
@@ -1,59 +1,58 @@
1
1
  {
2
- "name": "r-state-tree",
3
- "version": "0.4.3",
4
- "description": "reactive state management library",
5
- "main": "dist/r-state-tree.cjs",
6
- "module": "dist/r-state-tree.js",
7
- "types": "dist/index.d.ts",
8
- "exports": {
9
- ".": {
10
- "types": "./dist/index.d.ts",
11
- "import": "./dist/r-state-tree.js",
12
- "require": "./dist/r-state-tree.cjs"
13
- }
14
- },
15
- "files": [
16
- "dist/*",
17
- "README.md",
18
- "LICENSE"
19
- ],
20
- "scripts": {
21
- "test": "vitest run",
22
- "test:watch": "vitest",
23
- "test:cover": "vitest run --coverage",
24
- "build": "vite build && pnpm run build:types",
25
- "build:types": "tsc --emitDeclarationOnly",
26
- "lint": "eslint ./src ./tests --ext .ts",
27
- "format": "prettier --cache --write \"src/**/*.ts\" \"tests/**/*.ts\"",
28
- "prepublishOnly": "pnpm run build"
29
- },
30
- "repository": {
31
- "type": "git",
32
- "url": "git+https://github.com/melnikov-s/r-state-tree.git"
33
- },
34
- "author": "Sergey Melnikov",
35
- "license": "MIT",
36
- "bugs": {
37
- "url": "https://github.com/melnikov-s/r-state-tree/issues"
38
- },
39
- "homepage": "https://github.com/melnikov-s/r-state-tree#readme",
40
- "devDependencies": {
41
- "@tsmetadata/polyfill": "^1.1.3",
42
- "@typescript-eslint/eslint-plugin": "^5.8.1",
43
- "@typescript-eslint/parser": "^5.8.1",
44
- "eslint": "^8.5.0",
45
- "eslint-config-airbnb": "^19.0.4",
46
- "eslint-config-prettier": "^8.3.0",
47
- "eslint-import-resolver-typescript": "^2.5.0",
48
- "eslint-plugin-import": "^2.25.3",
49
- "eslint-plugin-prettier": "^4.0.0",
50
- "prettier": "^2.8.4",
51
- "tslib": "^2.5.0",
52
- "typescript": "5.9.3",
53
- "vite": "7.1.9",
54
- "vitest": "3.2.4"
55
- },
56
- "dependencies": {
57
- "@preact/signals-core": "^1.12.1"
58
- }
59
- }
2
+ "name": "r-state-tree",
3
+ "version": "0.4.5",
4
+ "description": "reactive state management library",
5
+ "main": "dist/r-state-tree.cjs",
6
+ "module": "dist/r-state-tree.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/r-state-tree.js",
12
+ "require": "./dist/r-state-tree.cjs"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist/*",
17
+ "README.md",
18
+ "LICENSE"
19
+ ],
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/melnikov-s/r-state-tree.git"
23
+ },
24
+ "author": "Sergey Melnikov",
25
+ "license": "MIT",
26
+ "bugs": {
27
+ "url": "https://github.com/melnikov-s/r-state-tree/issues"
28
+ },
29
+ "homepage": "https://github.com/melnikov-s/r-state-tree#readme",
30
+ "devDependencies": {
31
+ "@tsmetadata/polyfill": "^1.1.3",
32
+ "@typescript-eslint/eslint-plugin": "^5.8.1",
33
+ "@typescript-eslint/parser": "^5.8.1",
34
+ "eslint": "^8.5.0",
35
+ "eslint-config-airbnb": "^19.0.4",
36
+ "eslint-config-prettier": "^8.3.0",
37
+ "eslint-import-resolver-typescript": "^2.5.0",
38
+ "eslint-plugin-import": "^2.25.3",
39
+ "eslint-plugin-prettier": "^4.0.0",
40
+ "prettier": "^2.8.4",
41
+ "tslib": "^2.5.0",
42
+ "typescript": "5.9.3",
43
+ "vite": "7.1.9",
44
+ "vitest": "3.2.4"
45
+ },
46
+ "dependencies": {
47
+ "@preact/signals-core": "^1.12.1"
48
+ },
49
+ "scripts": {
50
+ "test": "vitest run",
51
+ "test:watch": "vitest",
52
+ "test:cover": "vitest run --coverage",
53
+ "build": "vite build && pnpm run build:types",
54
+ "build:types": "tsc --emitDeclarationOnly",
55
+ "lint": "eslint ./src ./tests --ext .ts",
56
+ "format": "prettier --cache --write \"src/**/*.ts\" \"tests/**/*.ts\""
57
+ }
58
+ }