jotai-state-tree 1.0.3 → 1.1.1

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
@@ -4,14 +4,16 @@ A MobX-State-Tree (MST) compatible state management library powered by [Jotai](h
4
4
 
5
5
  ## Features
6
6
 
7
- - 🌳 **MST-Compatible API** - Familiar `types.model`, `types.array`, `types.map` and more
8
- - ⚛️ **Powered by Jotai** - Leverages Jotai's atomic state model for performance
9
- - 🔄 **Snapshots & Patches** - Full support for `getSnapshot`, `applySnapshot`, `onPatch`
10
- - 📍 **Tree Navigation** - `getRoot`, `getParent`, `getPath`, `resolvePath`
11
- - 🔗 **References** - Type-safe references with `types.reference` and `types.safeReference`
12
- - **Undo/Redo** - Built-in undo manager and time-travel debugging
13
- - ⚛️ **React Integration** - `observer` HOC and hooks for React
14
- - 🔒 **TypeScript** - Full type safety with inference
7
+ - **MST-Compatible API** - Familiar `types.model`, `types.array`, `types.map` and more
8
+ - **Powered by Jotai** - Leverages Jotai's atomic state model for performance
9
+ - **Snapshots & Patches** - Full support for `getSnapshot`, `applySnapshot`, `onPatch`
10
+ - **Tree Navigation** - `getRoot`, `getParent`, `getPath`, `resolvePath`
11
+ - **References** - Type-safe references with `types.reference` and `types.safeReference`
12
+ - **Undo/Redo** - Built-in undo manager and time-travel debugging
13
+ - **React Integration** - `observer` HOC and hooks for React
14
+ - **Mixins** - Reusable, type-safe mixins with `types.mixin` and `.apply()`
15
+ - **Model Registry** - Dynamic model registration and resolution
16
+ - **TypeScript** - Full type safety with inference
15
17
 
16
18
  ## Installation
17
19
 
@@ -63,10 +65,655 @@ store.todos[0].toggle();
63
65
  console.log(getSnapshot(store));
64
66
  ```
65
67
 
68
+ ## Table of Contents
69
+
70
+ - [Types](#types)
71
+ - [Primitive Types](#primitive-types)
72
+ - [Identifier Types](#identifier-types)
73
+ - [Collection Types](#collection-types)
74
+ - [Optional & Nullable Types](#optional--nullable-types)
75
+ - [Union & Composition Types](#union--composition-types)
76
+ - [Reference Types](#reference-types)
77
+ - [Other Types](#other-types)
78
+ - [Models](#models)
79
+ - [Defining Models](#defining-models)
80
+ - [Views](#views)
81
+ - [Actions](#actions)
82
+ - [Volatile State](#volatile-state)
83
+ - [Lifecycle Hooks](#lifecycle-hooks)
84
+ - [Extend Method](#extend-method)
85
+ - [Snapshot Processing](#snapshot-processing)
86
+ - [Mixins](#mixins)
87
+ - [Model Composition](#model-composition)
88
+ - [Tree Utilities](#tree-utilities)
89
+ - [React Integration](#react-integration)
90
+ - [Undo/Redo & Time Travel](#undoredo--time-travel)
91
+ - [Model Registry](#model-registry)
92
+ - [Middleware](#middleware)
93
+ - [Flow (Async Actions)](#flow-async-actions)
94
+ - [Type Utilities](#type-utilities)
95
+ - [Migration from MST](#migration-from-mst)
96
+
97
+ ---
98
+
99
+ ## Types
100
+
101
+ ### Primitive Types
102
+
103
+ | Type | Description |
104
+ |------|-------------|
105
+ | `types.string` | String values |
106
+ | `types.number` | Number values (floats) |
107
+ | `types.integer` | Integer values only |
108
+ | `types.boolean` | Boolean values |
109
+ | `types.finite` | Finite numbers (excludes Infinity) |
110
+ | `types.float` | Alias for number |
111
+ | `types.Date` | Date objects (stored as timestamp) |
112
+ | `types.null` | Null values |
113
+ | `types.undefined` | Undefined values |
114
+
115
+ ### Identifier Types
116
+
117
+ ```typescript
118
+ const User = types.model('User', {
119
+ id: types.identifier, // String identifier
120
+ numericId: types.identifierNumber, // Number identifier
121
+ });
122
+ ```
123
+
124
+ ### Collection Types
125
+
126
+ **Array Type:**
127
+
128
+ ```typescript
129
+ const TodoList = types.model('TodoList', {
130
+ items: types.array(Todo),
131
+ });
132
+
133
+ // Array methods
134
+ list.items.push({ id: '1', title: 'New' });
135
+ list.items.replace([...]); // Replace all items
136
+ list.items.clear(); // Remove all items
137
+ list.items.remove(item); // Remove specific item
138
+ ```
139
+
140
+ **Map Type:**
141
+
142
+ ```typescript
143
+ const UserStore = types.model('UserStore', {
144
+ users: types.map(User),
145
+ });
146
+
147
+ // Map methods
148
+ store.users.set('user-1', { id: 'user-1', name: 'John' });
149
+ store.users.put({ id: 'user-2', name: 'Jane' }); // Uses identifier as key
150
+ store.users.merge({ 'user-3': { id: 'user-3', name: 'Bob' } });
151
+ store.users.delete('user-1');
152
+ ```
153
+
154
+ ### Optional & Nullable Types
155
+
156
+ ```typescript
157
+ types.optional(types.string, '') // Default value when undefined
158
+ types.optional(types.number, () => Date.now()) // Factory default
159
+
160
+ types.maybe(types.string) // string | undefined
161
+ types.maybeNull(types.string) // string | null
162
+ ```
163
+
164
+ ### Union & Composition Types
165
+
166
+ ```typescript
167
+ // Union type
168
+ const Status = types.union(
169
+ types.literal('pending'),
170
+ types.literal('done'),
171
+ types.literal('error')
172
+ );
173
+
174
+ // Union with dispatcher
175
+ const Shape = types.union(
176
+ { dispatcher: (snapshot) => snapshot.type === 'circle' ? Circle : Rectangle },
177
+ Circle,
178
+ Rectangle
179
+ );
180
+
181
+ // Late type (for recursive/circular references)
182
+ const TreeNode = types.model('TreeNode', {
183
+ value: types.string,
184
+ children: types.array(types.late(() => TreeNode)),
185
+ });
186
+
187
+ // Refinement type
188
+ const PositiveNumber = types.refinement(
189
+ types.number,
190
+ (value) => value > 0,
191
+ 'Value must be positive'
192
+ );
193
+
194
+ // Literal type
195
+ const Direction = types.literal('north');
196
+
197
+ // Enumeration
198
+ const Color = types.enumeration('Color', ['red', 'green', 'blue']);
199
+ ```
200
+
201
+ ### Reference Types
202
+
203
+ ```typescript
204
+ const Author = types.model('Author', {
205
+ id: types.identifier,
206
+ name: types.string,
207
+ });
208
+
209
+ const Book = types.model('Book', {
210
+ title: types.string,
211
+ author: types.reference(Author), // Throws if not found
212
+ editor: types.safeReference(Author), // Returns undefined if not found
213
+ });
214
+
215
+ // Custom reference options
216
+ const customRef = types.reference(Author, {
217
+ get(identifier, parent) {
218
+ return resolveAuthor(identifier);
219
+ },
220
+ set(author) {
221
+ return author.id;
222
+ },
223
+ onInvalidated({ parent, invalidId, replaceRef, removeRef, cause }) {
224
+ removeRef(); // or replaceRef(newAuthor)
225
+ },
226
+ });
227
+ ```
228
+
229
+ ### Other Types
230
+
231
+ ```typescript
232
+ // Frozen (immutable deep objects)
233
+ const Config = types.model('Config', {
234
+ settings: types.frozen<{ theme: string; debug: boolean }>(),
235
+ });
236
+
237
+ // Custom type
238
+ const CustomDate = types.custom<string, Date>({
239
+ name: 'CustomDate',
240
+ fromSnapshot(value: string) { return new Date(value); },
241
+ toSnapshot(value: Date) { return value.toISOString(); },
242
+ isTargetType(value) { return value instanceof Date; },
243
+ getValidationMessage(value) { return 'Invalid date'; },
244
+ });
245
+
246
+ // Snapshot processor
247
+ const ProcessedModel = types.snapshotProcessor(BaseModel, {
248
+ preProcessor(snapshot) {
249
+ return { ...snapshot, version: snapshot.version ?? 1 };
250
+ },
251
+ postProcessor(snapshot) {
252
+ return { ...snapshot, exported: true };
253
+ },
254
+ });
255
+ ```
256
+
257
+ ---
258
+
259
+ ## Models
260
+
261
+ ### Defining Models
262
+
263
+ ```typescript
264
+ const User = types.model('User', {
265
+ id: types.identifier,
266
+ name: types.string,
267
+ age: types.optional(types.number, 0),
268
+ });
269
+
270
+ // Anonymous model
271
+ const Point = types.model({
272
+ x: types.number,
273
+ y: types.number,
274
+ });
275
+
276
+ // Add properties later
277
+ const ExtendedUser = User.props({
278
+ email: types.string,
279
+ });
280
+ ```
281
+
282
+ ### Views
283
+
284
+ Views are computed properties derived from state:
285
+
286
+ ```typescript
287
+ const User = types
288
+ .model('User', {
289
+ firstName: types.string,
290
+ lastName: types.string,
291
+ })
292
+ .views((self) => ({
293
+ // Getter view
294
+ get fullName() {
295
+ return `${self.firstName} ${self.lastName}`;
296
+ },
297
+ // Method view
298
+ getGreeting(prefix: string) {
299
+ return `${prefix} ${self.fullName}!`;
300
+ },
301
+ }));
302
+ ```
303
+
304
+ ### Actions
305
+
306
+ Actions are methods that modify state:
307
+
308
+ ```typescript
309
+ const Counter = types
310
+ .model('Counter', {
311
+ count: types.optional(types.number, 0),
312
+ })
313
+ .actions((self) => ({
314
+ increment() {
315
+ self.count++;
316
+ },
317
+ decrement() {
318
+ self.count--;
319
+ },
320
+ setCount(value: number) {
321
+ self.count = value;
322
+ },
323
+ }));
324
+ ```
325
+
326
+ ### Volatile State
327
+
328
+ Non-serialized state that doesn't appear in snapshots:
329
+
330
+ ```typescript
331
+ const FormModel = types
332
+ .model('FormModel', {
333
+ data: types.string,
334
+ })
335
+ .volatile(() => ({
336
+ isLoading: false,
337
+ error: null as string | null,
338
+ abortController: null as AbortController | null,
339
+ }))
340
+ .actions((self) => ({
341
+ async fetchData() {
342
+ self.isLoading = true;
343
+ self.abortController = new AbortController();
344
+ try {
345
+ const result = await fetch('/api/data', {
346
+ signal: self.abortController.signal
347
+ });
348
+ self.data = await result.text();
349
+ } catch (e) {
350
+ self.error = e.message;
351
+ } finally {
352
+ self.isLoading = false;
353
+ }
354
+ },
355
+ }));
356
+ ```
357
+
358
+ ### Lifecycle Hooks
359
+
360
+ ```typescript
361
+ const Model = types
362
+ .model('Model', { value: types.string })
363
+ .afterCreate((self) => {
364
+ console.log('Created:', self.value);
365
+ })
366
+ .afterAttach((self) => {
367
+ console.log('Attached to tree');
368
+ })
369
+ .beforeDetach((self) => {
370
+ console.log('About to detach');
371
+ })
372
+ .beforeDestroy((self) => {
373
+ console.log('About to be destroyed');
374
+ });
375
+ ```
376
+
377
+ ### Extend Method
378
+
379
+ Combine views, actions, and volatile in one call with shared closure:
380
+
381
+ ```typescript
382
+ const Counter = types
383
+ .model('Counter', {
384
+ count: types.optional(types.number, 0),
385
+ })
386
+ .extend((self) => {
387
+ // Private closure state
388
+ let lastModified = Date.now();
389
+
390
+ return {
391
+ views: {
392
+ get doubled() {
393
+ return self.count * 2;
394
+ },
395
+ get lastModified() {
396
+ return lastModified;
397
+ },
398
+ },
399
+ actions: {
400
+ increment() {
401
+ self.count++;
402
+ lastModified = Date.now();
403
+ },
404
+ },
405
+ state: {
406
+ isEditing: false,
407
+ },
408
+ };
409
+ });
410
+ ```
411
+
412
+ ### Snapshot Processing
413
+
414
+ Transform snapshots during creation and serialization:
415
+
416
+ ```typescript
417
+ const Model = types
418
+ .model('Model', {
419
+ data: types.string,
420
+ version: types.number,
421
+ })
422
+ .preProcessSnapshot((snapshot) => ({
423
+ ...snapshot,
424
+ version: snapshot.version ?? 1, // Add defaults
425
+ }))
426
+ .postProcessSnapshot((snapshot) => ({
427
+ ...snapshot,
428
+ exportedAt: Date.now(), // Add metadata
429
+ }));
430
+ ```
431
+
432
+ ---
433
+
434
+ ## Mixins
435
+
436
+ Create reusable, type-safe mixins that can be applied to models:
437
+
438
+ ```typescript
439
+ // Define a mixin with requirements
440
+ const Validatable = types.mixin({
441
+ requires: {
442
+ errors: types.array(types.string),
443
+ },
444
+ views: (self) => ({
445
+ get isValid() {
446
+ return self.errors.length === 0;
447
+ },
448
+ get hasErrors() {
449
+ return self.errors.length > 0;
450
+ },
451
+ }),
452
+ actions: (self) => ({
453
+ addError(msg: string) {
454
+ self.errors.push(msg);
455
+ },
456
+ clearErrors() {
457
+ self.errors.clear();
458
+ },
459
+ }),
460
+ volatile: () => ({
461
+ lastValidatedAt: null as number | null,
462
+ }),
463
+ });
464
+
465
+ // Apply mixin to a model
466
+ const Form = types
467
+ .model('Form', {
468
+ name: types.string,
469
+ email: types.string,
470
+ errors: types.array(types.string),
471
+ })
472
+ .apply(Validatable);
473
+
474
+ // Now Form has isValid, hasErrors, addError, clearErrors
475
+ const form = Form.create({ name: '', email: '', errors: [] });
476
+ form.addError('Name is required');
477
+ console.log(form.isValid); // false
478
+ ```
479
+
480
+ **Mixin with empty requirements:**
481
+
482
+ ```typescript
483
+ const Loadable = types.mixin({
484
+ volatile: () => ({
485
+ isLoading: false,
486
+ error: null as Error | null,
487
+ }),
488
+ actions: (self) => ({
489
+ setLoading(loading: boolean) {
490
+ self.isLoading = loading;
491
+ },
492
+ setError(error: Error | null) {
493
+ self.error = error;
494
+ },
495
+ }),
496
+ });
497
+
498
+ // Can be applied to any model
499
+ const DataModel = types
500
+ .model('DataModel', { data: types.string })
501
+ .apply(Loadable);
502
+ ```
503
+
504
+ **Applying multiple mixins:**
505
+
506
+ ```typescript
507
+ const Entity = types
508
+ .model('Entity', {
509
+ id: types.identifier,
510
+ createdAt: types.number,
511
+ errors: types.array(types.string),
512
+ })
513
+ .apply(Identifiable)
514
+ .apply(Timestamped)
515
+ .apply(Validatable);
516
+ ```
517
+
518
+ ---
519
+
520
+ ## Model Composition
521
+
522
+ Compose multiple models into one, merging properties, views, actions, and volatile state:
523
+
524
+ ```typescript
525
+ const Identifiable = types
526
+ .model('Identifiable', {
527
+ id: types.identifier,
528
+ })
529
+ .views((self) => ({
530
+ get shortId() {
531
+ return self.id.substring(0, 8);
532
+ },
533
+ }));
534
+
535
+ const Timestamped = types
536
+ .model('Timestamped', {
537
+ createdAt: types.number,
538
+ updatedAt: types.number,
539
+ })
540
+ .actions((self) => ({
541
+ touch() {
542
+ self.updatedAt = Date.now();
543
+ },
544
+ }));
545
+
546
+ // Compose models
547
+ const Entity = types.compose('Entity', Identifiable, Timestamped);
548
+
549
+ // Entity has: id, createdAt, updatedAt, shortId (view), touch (action)
550
+ const entity = Entity.create({
551
+ id: 'abc123',
552
+ createdAt: Date.now(),
553
+ updatedAt: Date.now(),
554
+ });
555
+ ```
556
+
557
+ ---
558
+
559
+ ## Tree Utilities
560
+
561
+ ### Snapshots
562
+
563
+ ```typescript
564
+ import { getSnapshot, applySnapshot, onSnapshot } from 'jotai-state-tree';
565
+
566
+ // Get current state as plain object
567
+ const snapshot = getSnapshot(store);
568
+
569
+ // Apply snapshot to update state
570
+ applySnapshot(store, { todos: [...] });
571
+
572
+ // Subscribe to snapshot changes
573
+ const dispose = onSnapshot(store, (snapshot) => {
574
+ localStorage.setItem('store', JSON.stringify(snapshot));
575
+ });
576
+ ```
577
+
578
+ ### Patches
579
+
580
+ ```typescript
581
+ import { onPatch, applyPatch, recordPatches } from 'jotai-state-tree';
582
+
583
+ // Subscribe to JSON patches
584
+ const dispose = onPatch(store, (patch, reversePatch) => {
585
+ console.log('Change:', patch);
586
+ // { op: 'replace', path: '/todos/0/done', value: true }
587
+ });
588
+
589
+ // Apply patches
590
+ applyPatch(store, { op: 'replace', path: '/count', value: 5 });
591
+ applyPatch(store, [patch1, patch2, patch3]); // Multiple patches
592
+
593
+ // Record patches for undo
594
+ const recorder = recordPatches(store);
595
+ store.doSomething();
596
+ recorder.stop();
597
+ recorder.undo(); // Reverts changes
598
+ ```
599
+
600
+ ### Tree Navigation
601
+
602
+ ```typescript
603
+ import {
604
+ getRoot,
605
+ getParent,
606
+ tryGetParent,
607
+ hasParent,
608
+ getParentOfType,
609
+ getPath,
610
+ getPathParts,
611
+ getEnv,
612
+ getType,
613
+ getIdentifier,
614
+ isAlive,
615
+ isRoot,
616
+ isStateTreeNode,
617
+ } from 'jotai-state-tree';
618
+
619
+ // Navigation
620
+ const root = getRoot(todo);
621
+ const parent = getParent(todo);
622
+ const maybeParent = tryGetParent(todo); // undefined if no parent
623
+ const store = getParentOfType(todo, TodoStore);
624
+
625
+ // Path information
626
+ const path = getPath(todo); // "/todos/0"
627
+ const parts = getPathParts(todo); // ["todos", "0"]
628
+
629
+ // Metadata
630
+ const env = getEnv(todo); // Environment object
631
+ const type = getType(todo); // TodoModel type
632
+ const id = getIdentifier(todo); // "todo-1" or undefined
633
+
634
+ // Status checks
635
+ if (isAlive(todo)) { /* still exists */ }
636
+ if (isRoot(store)) { /* is root node */ }
637
+ if (isStateTreeNode(value)) { /* is tree node */ }
638
+ ```
639
+
640
+ ### Tree Manipulation
641
+
642
+ ```typescript
643
+ import {
644
+ destroy,
645
+ detach,
646
+ clone,
647
+ cloneDeep,
648
+ walk,
649
+ findAll,
650
+ findFirst,
651
+ freeze,
652
+ isFrozen,
653
+ unfreeze,
654
+ } from 'jotai-state-tree';
655
+
656
+ // Destroy node (removes from tree)
657
+ destroy(todo);
658
+
659
+ // Detach from parent (keeps node alive)
660
+ const detached = detach(todo);
661
+
662
+ // Clone node
663
+ const cloned = clone(todo);
664
+ const deepCloned = cloneDeep(todo);
665
+
666
+ // Walk entire tree
667
+ walk(store, (node) => {
668
+ console.log(getPath(node));
669
+ });
670
+
671
+ // Find nodes
672
+ const allTodos = findAll(store, (node) => getType(node).name === 'Todo');
673
+ const firstDone = findFirst(store, (node) => node.done === true);
674
+
675
+ // Freeze/unfreeze
676
+ freeze(store); // Make read-only
677
+ isFrozen(store); // true
678
+ unfreeze(store); // Make writable again
679
+ ```
680
+
681
+ ### Path Resolution
682
+
683
+ ```typescript
684
+ import {
685
+ resolvePath,
686
+ tryResolve,
687
+ resolveIdentifier,
688
+ getRelativePath,
689
+ isAncestor,
690
+ haveSameRoot,
691
+ } from 'jotai-state-tree';
692
+
693
+ // Resolve path
694
+ const todo = resolvePath(store, '/todos/0');
695
+ const maybeTodo = tryResolve(store, '/todos/0'); // undefined if not found
696
+
697
+ // Resolve by identifier
698
+ const user = resolveIdentifier(User, store, 'user-123');
699
+
700
+ // Relative paths
701
+ const relativePath = getRelativePath(todoA, todoB);
702
+ // "../../todos/1"
703
+
704
+ // Ancestry checks
705
+ isAncestor(store, todo); // true
706
+ haveSameRoot(todoA, todoB); // true
707
+ ```
708
+
709
+ ---
710
+
66
711
  ## React Integration
67
712
 
713
+ ### Observer HOC
714
+
68
715
  ```tsx
69
- import { observer, Provider, useStore } from 'jotai-state-tree/react';
716
+ import { observer } from 'jotai-state-tree/react';
70
717
 
71
718
  const TodoList = observer(({ store }) => (
72
719
  <ul>
@@ -79,78 +726,450 @@ const TodoList = observer(({ store }) => (
79
726
  ));
80
727
  ```
81
728
 
82
- ## API Reference
729
+ ### Observer Component
83
730
 
84
- ### Types
731
+ ```tsx
732
+ import { Observer } from 'jotai-state-tree/react';
85
733
 
86
- | Type | Description |
87
- |------|-------------|
88
- | `types.string` | String values |
89
- | `types.number` | Number values |
90
- | `types.boolean` | Boolean values |
91
- | `types.integer` | Integer values |
92
- | `types.Date` | Date objects |
93
- | `types.identifier` | String identifier |
94
- | `types.identifierNumber` | Number identifier |
95
- | `types.model(name, props)` | Model type |
96
- | `types.array(type)` | Observable array |
97
- | `types.map(type)` | Observable map |
98
- | `types.optional(type, default)` | Optional with default |
99
- | `types.maybe(type)` | Type or undefined |
100
- | `types.maybeNull(type)` | Type or null |
101
- | `types.union(...types)` | Union type |
102
- | `types.literal(value)` | Literal type |
103
- | `types.enumeration(values)` | Enumeration |
104
- | `types.frozen<T>()` | Frozen immutable |
105
- | `types.late(() => type)` | Lazy/recursive type |
106
- | `types.reference(type)` | Reference to model |
107
- | `types.safeReference(type)` | Safe reference |
108
- | `types.refinement(type, predicate)` | Refined type |
109
-
110
- ### Tree Utilities
111
-
112
- ```typescript
113
- getSnapshot(node) // Get snapshot
114
- applySnapshot(node, snap) // Apply snapshot
115
- onSnapshot(node, listener) // Listen to changes
116
- onPatch(node, listener) // Listen to patches
117
- applyPatch(node, patch) // Apply patch
118
- getRoot(node) // Get root
119
- getParent(node) // Get parent
120
- getPath(node) // Get path string
121
- getEnv(node) // Get environment
122
- isAlive(node) // Check if alive
123
- destroy(node) // Destroy node
124
- clone(node) // Clone node
125
- walk(node, visitor) // Walk tree
126
- ```
127
-
128
- ### Undo/Redo
734
+ function App({ store }) {
735
+ return (
736
+ <div>
737
+ <Observer>
738
+ {() => <span>Count: {store.count}</span>}
739
+ </Observer>
740
+ </div>
741
+ );
742
+ }
743
+ ```
744
+
745
+ ### Store Context (Recommended)
746
+
747
+ ```tsx
748
+ import { createStoreContext } from 'jotai-state-tree/react';
749
+
750
+ // Create typed context
751
+ const { Provider, useStore, useStoreSnapshot, useIsAlive } = createStoreContext<typeof TodoStore>();
752
+
753
+ function App() {
754
+ const store = TodoStore.create({ todos: [] });
755
+
756
+ return (
757
+ <Provider value={store}>
758
+ <TodoList />
759
+ </Provider>
760
+ );
761
+ }
762
+
763
+ function TodoList() {
764
+ const store = useStore();
765
+ const snapshot = useStoreSnapshot((s) => s.todos);
766
+ const isAlive = useIsAlive();
767
+
768
+ return (
769
+ <ul>
770
+ {store.todos.map((todo) => (
771
+ <TodoItem key={todo.id} todo={todo} />
772
+ ))}
773
+ </ul>
774
+ );
775
+ }
776
+ ```
777
+
778
+ ### Hooks
779
+
780
+ ```tsx
781
+ import {
782
+ useSnapshot,
783
+ useWatchPath,
784
+ usePatches,
785
+ useAction,
786
+ useActions,
787
+ useLocalObservable,
788
+ useObserver,
789
+ } from 'jotai-state-tree/react';
790
+
791
+ function Component({ store }) {
792
+ // Subscribe to snapshot
793
+ const snapshot = useSnapshot(store);
794
+
795
+ // Watch specific path
796
+ const count = useWatchPath(store, 'count', 0);
797
+
798
+ // Subscribe to patches
799
+ usePatches(store, (patch) => {
800
+ console.log('Change:', patch);
801
+ });
802
+
803
+ // Memoized actions
804
+ const increment = useAction(store.increment);
805
+ const { add, remove } = useActions({
806
+ add: store.add,
807
+ remove: store.remove,
808
+ });
809
+
810
+ // Local observable state
811
+ const localStore = useLocalObservable(() => ({
812
+ count: 0,
813
+ increment() { this.count++; },
814
+ }));
815
+
816
+ // Manual observation
817
+ const view = useObserver(() => (
818
+ <span>{store.count}</span>
819
+ ));
820
+ }
821
+ ```
822
+
823
+ ### Batching Updates
824
+
825
+ ```tsx
826
+ import { batch } from 'jotai-state-tree/react';
827
+
828
+ function handleBulkUpdate() {
829
+ batch(() => {
830
+ store.item1.update();
831
+ store.item2.update();
832
+ store.item3.update();
833
+ // Single re-render after all updates
834
+ });
835
+ }
836
+ ```
837
+
838
+ ---
839
+
840
+ ## Undo/Redo & Time Travel
841
+
842
+ ### Undo Manager
129
843
 
130
844
  ```typescript
131
845
  import { createUndoManager } from 'jotai-state-tree';
132
846
 
133
- const undoManager = createUndoManager(store);
134
- undoManager.undo();
135
- undoManager.redo();
847
+ const undoManager = createUndoManager(store, {
848
+ maxHistoryLength: 100,
849
+ groupByTime: true,
850
+ groupingWindow: 200, // ms
851
+ });
852
+
853
+ // Undo/redo
854
+ store.increment();
855
+ store.increment();
856
+ undoManager.undo(); // count = 1
857
+ undoManager.redo(); // count = 2
858
+
859
+ // Check capabilities
860
+ undoManager.canUndo; // boolean
861
+ undoManager.canRedo; // boolean
862
+ undoManager.undoLevels; // number
863
+ undoManager.redoLevels; // number
864
+
865
+ // Group changes
136
866
  undoManager.startGroup();
867
+ store.increment();
868
+ store.increment();
869
+ store.increment();
137
870
  undoManager.endGroup();
871
+ // All three increments undo as one
872
+
873
+ // Execute without recording
874
+ undoManager.withoutUndo(() => {
875
+ store.resetToDefaults();
876
+ });
877
+
878
+ // Clear history
138
879
  undoManager.clear();
880
+
881
+ // Cleanup
139
882
  undoManager.dispose();
140
883
  ```
141
884
 
142
- ### Time Travel
885
+ ### Time Travel Manager
143
886
 
144
887
  ```typescript
145
888
  import { createTimeTravelManager } from 'jotai-state-tree';
146
889
 
147
- const timeTravel = createTimeTravelManager(store);
890
+ const timeTravel = createTimeTravelManager(store, {
891
+ maxSnapshots: 50,
892
+ });
893
+
894
+ // Record snapshots manually
895
+ store.doSomething();
148
896
  timeTravel.record();
897
+
898
+ store.doSomethingElse();
899
+ timeTravel.record();
900
+
901
+ // Navigate history
149
902
  timeTravel.goBack();
150
903
  timeTravel.goForward();
151
- timeTravel.goTo(index);
904
+ timeTravel.goTo(0); // Go to first snapshot
905
+
906
+ // Inspect
907
+ timeTravel.currentIndex; // Current position
908
+ timeTravel.snapshotCount; // Total snapshots
909
+ timeTravel.canGoBack;
910
+ timeTravel.canGoForward;
911
+ timeTravel.getSnapshot(2); // Get specific snapshot
912
+
913
+ // Cleanup
914
+ timeTravel.dispose();
152
915
  ```
153
916
 
917
+ ### Action Recorder
918
+
919
+ ```typescript
920
+ import { createActionRecorder } from 'jotai-state-tree';
921
+
922
+ const recorder = createActionRecorder(store);
923
+
924
+ // Record actions
925
+ recorder.start();
926
+ store.addTodo('Task 1');
927
+ store.addTodo('Task 2');
928
+ store.todos[0].toggle();
929
+ recorder.stop();
930
+
931
+ // Get recorded actions
932
+ console.log(recorder.actions);
933
+ // [{ name: 'addTodo', args: ['Task 1'] }, ...]
934
+
935
+ // Replay on another store
936
+ const newStore = TodoStore.create({ todos: [] });
937
+ recorder.replay(newStore);
938
+
939
+ // Export/import
940
+ const json = recorder.export();
941
+ recorder.import(json);
942
+
943
+ // Cleanup
944
+ recorder.dispose();
945
+ ```
946
+
947
+ ---
948
+
949
+ ## Model Registry
950
+
951
+ Dynamic model registration for plugin architectures and code splitting:
952
+
953
+ ```typescript
954
+ import {
955
+ registerModel,
956
+ unregisterModel,
957
+ resolveModel,
958
+ tryResolveModel,
959
+ resolveModelAsync,
960
+ isModelRegistered,
961
+ getRegisteredModelNames,
962
+ onModelRegistered,
963
+ lateModel,
964
+ dynamicReference,
965
+ safeDynamicReference,
966
+ } from 'jotai-state-tree';
967
+
968
+ // Register models
969
+ registerModel('User', UserModel, { version: '1.0' });
970
+ registerModel('Post', PostModel);
971
+
972
+ // Check registration
973
+ isModelRegistered('User'); // true
974
+ getRegisteredModelNames(); // ['User', 'Post']
975
+
976
+ // Resolve models
977
+ const User = resolveModel('User');
978
+ const MaybePost = tryResolveModel('Post');
979
+
980
+ // Async resolution (waits for registration)
981
+ const Model = await resolveModelAsync('LazyModel', 5000);
982
+
983
+ // Listen for registrations
984
+ const dispose = onModelRegistered((name, type, metadata) => {
985
+ console.log(`Model registered: ${name}`);
986
+ });
987
+
988
+ // Late-resolving model type
989
+ const Comment = types.model('Comment', {
990
+ author: lateModel('User'), // Resolved from registry
991
+ });
992
+
993
+ // Dynamic references
994
+ const Post = types.model('Post', {
995
+ author: dynamicReference('User'),
996
+ editor: safeDynamicReference('User'),
997
+ });
998
+
999
+ // Unregister
1000
+ unregisterModel('User');
1001
+ ```
1002
+
1003
+ ---
1004
+
1005
+ ## Middleware
1006
+
1007
+ Intercept and control action execution:
1008
+
1009
+ ```typescript
1010
+ import { addMiddleware, protect, unprotect, isProtected } from 'jotai-state-tree';
1011
+
1012
+ // Add middleware
1013
+ const dispose = addMiddleware(store, (call, next, abort) => {
1014
+ console.log(`Action: ${call.name}`, call.args);
1015
+
1016
+ // Validate
1017
+ if (call.name === 'delete' && !canDelete()) {
1018
+ return abort('Not authorized');
1019
+ }
1020
+
1021
+ // Proceed
1022
+ const result = next(call);
1023
+
1024
+ console.log(`Result:`, result);
1025
+ return result;
1026
+ });
1027
+
1028
+ // Protection (prevent direct mutations)
1029
+ protect(store);
1030
+ store.count = 5; // Throws error!
1031
+ store.increment(); // OK - through action
1032
+
1033
+ unprotect(store);
1034
+ store.count = 5; // OK now
1035
+
1036
+ isProtected(store); // false
1037
+ ```
1038
+
1039
+ ### Action Tracking
1040
+
1041
+ ```typescript
1042
+ import { onAction, recordActions, applyAction } from 'jotai-state-tree';
1043
+
1044
+ // Subscribe to actions
1045
+ const dispose = onAction(store, (call) => {
1046
+ console.log(`${call.name}(${call.args.join(', ')})`);
1047
+ });
1048
+
1049
+ // Record actions
1050
+ const recorder = recordActions(store);
1051
+ store.addTodo('Task 1');
1052
+ store.todos[0].toggle();
1053
+ const actions = recorder.actions;
1054
+ recorder.stop();
1055
+
1056
+ // Replay actions
1057
+ recorder.replay(anotherStore);
1058
+
1059
+ // Apply single action
1060
+ applyAction(store, { name: 'addTodo', args: ['New Task'] });
1061
+ ```
1062
+
1063
+ ---
1064
+
1065
+ ## Flow (Async Actions)
1066
+
1067
+ ```typescript
1068
+ import { types, flow } from 'jotai-state-tree';
1069
+
1070
+ const UserStore = types
1071
+ .model('UserStore', {
1072
+ users: types.array(User),
1073
+ isLoading: false,
1074
+ })
1075
+ .actions((self) => ({
1076
+ fetchUsers: flow(function* () {
1077
+ self.isLoading = true;
1078
+ try {
1079
+ const response = yield fetch('/api/users');
1080
+ const data = yield response.json();
1081
+ self.users.replace(data);
1082
+ } catch (error) {
1083
+ console.error('Failed to fetch users:', error);
1084
+ } finally {
1085
+ self.isLoading = false;
1086
+ }
1087
+ }),
1088
+ }));
1089
+
1090
+ // Usage
1091
+ await store.fetchUsers();
1092
+ ```
1093
+
1094
+ ---
1095
+
1096
+ ## Type Utilities
1097
+
1098
+ ### Type Extraction
1099
+
1100
+ ```typescript
1101
+ import type {
1102
+ Instance,
1103
+ SnapshotIn,
1104
+ SnapshotOut,
1105
+ ModelSelf,
1106
+ } from 'jotai-state-tree';
1107
+
1108
+ const Todo = types.model('Todo', { ... }).views(...).actions(...);
1109
+
1110
+ type TodoInstance = Instance<typeof Todo>;
1111
+ type TodoSnapshot = SnapshotIn<typeof Todo>;
1112
+ type TodoOutput = SnapshotOut<typeof Todo>;
1113
+ type TodoSelf = ModelSelf<typeof Todo>; // Full self type with views/actions
1114
+ ```
1115
+
1116
+ ### Type Checking Functions
1117
+
1118
+ ```typescript
1119
+ import {
1120
+ isType,
1121
+ isPrimitiveType,
1122
+ isModelType,
1123
+ isArrayType,
1124
+ isMapType,
1125
+ isReferenceType,
1126
+ isUnionType,
1127
+ isOptionalType,
1128
+ isLateType,
1129
+ isFrozenType,
1130
+ isLiteralType,
1131
+ isIdentifierType,
1132
+ getTypeName,
1133
+ typecheck,
1134
+ } from 'jotai-state-tree';
1135
+
1136
+ // Check type kinds
1137
+ isModelType(Todo); // true
1138
+ isArrayType(types.array(types.string)); // true
1139
+ getTypeName(Todo); // 'Todo'
1140
+
1141
+ // Runtime type checking
1142
+ typecheck(Todo, value); // Throws if invalid
1143
+ ```
1144
+
1145
+ ### Validation
1146
+
1147
+ ```typescript
1148
+ import { isValidSnapshot, getValidationError } from 'jotai-state-tree';
1149
+
1150
+ if (isValidSnapshot(Todo, data)) {
1151
+ const todo = Todo.create(data);
1152
+ }
1153
+
1154
+ const error = getValidationError(Todo, invalidData);
1155
+ if (error) {
1156
+ console.error(error);
1157
+ }
1158
+ ```
1159
+
1160
+ ### Casting Utilities
1161
+
1162
+ ```typescript
1163
+ import { cast, castToSnapshot, castToReferenceSnapshot } from 'jotai-state-tree';
1164
+
1165
+ // Type casting helpers
1166
+ const value = cast<Todo>(unknownValue);
1167
+ const snapshot = castToSnapshot(todo);
1168
+ const refId = castToReferenceSnapshot(user); // Gets identifier
1169
+ ```
1170
+
1171
+ ---
1172
+
154
1173
  ## Migration from MST
155
1174
 
156
1175
  ```typescript
@@ -163,6 +1182,14 @@ import { types } from 'jotai-state-tree';
163
1182
  import { observer } from 'jotai-state-tree/react';
164
1183
  ```
165
1184
 
1185
+ Most MST code works with minimal changes. Key differences:
1186
+
1187
+ 1. Import from `jotai-state-tree` instead of `mobx-state-tree`
1188
+ 2. React bindings from `jotai-state-tree/react` instead of `mobx-react-lite`
1189
+ 3. Uses Jotai atoms internally instead of MobX observables
1190
+
1191
+ ---
1192
+
166
1193
  ## License
167
1194
 
168
1195
  MIT