jotai-state-tree 1.1.0 → 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 +1087 -60
- package/dist/{chunk-XXZK62DD.mjs → chunk-QZSMSMOP.mjs} +4 -0
- package/dist/index.d.mts +2 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +9 -1
- package/dist/index.mjs +6 -2
- package/dist/react.js +4 -0
- package/dist/react.mjs +1 -1
- package/package.json +1 -1
- package/src/model.ts +5 -1
- package/src/tree.ts +5 -0
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
|
-
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
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
|
|
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
|
-
|
|
729
|
+
### Observer Component
|
|
83
730
|
|
|
84
|
-
|
|
731
|
+
```tsx
|
|
732
|
+
import { Observer } from 'jotai-state-tree/react';
|
|
85
733
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
135
|
-
|
|
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(
|
|
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
|
|
@@ -829,6 +829,10 @@ var init_tree = __esm({
|
|
|
829
829
|
);
|
|
830
830
|
this.notifySnapshotChange();
|
|
831
831
|
}
|
|
832
|
+
/** Notify about a volatile state change (triggers snapshot listeners without patches) */
|
|
833
|
+
notifyVolatileChange() {
|
|
834
|
+
this.notifySnapshotChange();
|
|
835
|
+
}
|
|
832
836
|
/** Get root node */
|
|
833
837
|
getRoot() {
|
|
834
838
|
let node = this;
|
package/dist/index.d.mts
CHANGED
|
@@ -713,6 +713,8 @@ declare class StateTreeNode implements IStateTreeNode {
|
|
|
713
713
|
private notifySnapshotChange;
|
|
714
714
|
/** Notify about a property change (for use by model proxy) */
|
|
715
715
|
notifyPropertyChange(propName: string, newValue: unknown, oldValue: unknown): void;
|
|
716
|
+
/** Notify about a volatile state change (triggers snapshot listeners without patches) */
|
|
717
|
+
notifyVolatileChange(): void;
|
|
716
718
|
/** Get root node */
|
|
717
719
|
getRoot(): StateTreeNode;
|
|
718
720
|
/** Destroy this node and all children */
|
package/dist/index.d.ts
CHANGED
|
@@ -713,6 +713,8 @@ declare class StateTreeNode implements IStateTreeNode {
|
|
|
713
713
|
private notifySnapshotChange;
|
|
714
714
|
/** Notify about a property change (for use by model proxy) */
|
|
715
715
|
notifyPropertyChange(propName: string, newValue: unknown, oldValue: unknown): void;
|
|
716
|
+
/** Notify about a volatile state change (triggers snapshot listeners without patches) */
|
|
717
|
+
notifyVolatileChange(): void;
|
|
716
718
|
/** Get root node */
|
|
717
719
|
getRoot(): StateTreeNode;
|
|
718
720
|
/** Destroy this node and all children */
|
package/dist/index.js
CHANGED
|
@@ -830,6 +830,10 @@ var init_tree = __esm({
|
|
|
830
830
|
);
|
|
831
831
|
this.notifySnapshotChange();
|
|
832
832
|
}
|
|
833
|
+
/** Notify about a volatile state change (triggers snapshot listeners without patches) */
|
|
834
|
+
notifyVolatileChange() {
|
|
835
|
+
this.notifySnapshotChange();
|
|
836
|
+
}
|
|
833
837
|
/** Get root node */
|
|
834
838
|
getRoot() {
|
|
835
839
|
let node = this;
|
|
@@ -1485,7 +1489,11 @@ var ModelType = class _ModelType {
|
|
|
1485
1489
|
return true;
|
|
1486
1490
|
}
|
|
1487
1491
|
if (propStr in node.volatileState) {
|
|
1488
|
-
node.volatileState[propStr]
|
|
1492
|
+
const oldValue = node.volatileState[propStr];
|
|
1493
|
+
if (oldValue !== value) {
|
|
1494
|
+
node.volatileState[propStr] = value;
|
|
1495
|
+
node.notifyVolatileChange();
|
|
1496
|
+
}
|
|
1489
1497
|
return true;
|
|
1490
1498
|
}
|
|
1491
1499
|
return false;
|
package/dist/index.mjs
CHANGED
|
@@ -55,7 +55,7 @@ import {
|
|
|
55
55
|
tryResolve,
|
|
56
56
|
unfreeze,
|
|
57
57
|
walk
|
|
58
|
-
} from "./chunk-
|
|
58
|
+
} from "./chunk-QZSMSMOP.mjs";
|
|
59
59
|
|
|
60
60
|
// src/primitives.ts
|
|
61
61
|
function createSimpleType(name, validator, defaultValue) {
|
|
@@ -530,7 +530,11 @@ var ModelType = class _ModelType {
|
|
|
530
530
|
return true;
|
|
531
531
|
}
|
|
532
532
|
if (propStr in node.volatileState) {
|
|
533
|
-
node.volatileState[propStr]
|
|
533
|
+
const oldValue = node.volatileState[propStr];
|
|
534
|
+
if (oldValue !== value) {
|
|
535
|
+
node.volatileState[propStr] = value;
|
|
536
|
+
node.notifyVolatileChange();
|
|
537
|
+
}
|
|
534
538
|
return true;
|
|
535
539
|
}
|
|
536
540
|
return false;
|
package/dist/react.js
CHANGED
|
@@ -840,6 +840,10 @@ var init_tree = __esm({
|
|
|
840
840
|
);
|
|
841
841
|
this.notifySnapshotChange();
|
|
842
842
|
}
|
|
843
|
+
/** Notify about a volatile state change (triggers snapshot listeners without patches) */
|
|
844
|
+
notifyVolatileChange() {
|
|
845
|
+
this.notifySnapshotChange();
|
|
846
|
+
}
|
|
843
847
|
/** Get root node */
|
|
844
848
|
getRoot() {
|
|
845
849
|
let node = this;
|
package/dist/react.mjs
CHANGED
package/package.json
CHANGED
package/src/model.ts
CHANGED
|
@@ -426,7 +426,11 @@ class ModelType<
|
|
|
426
426
|
|
|
427
427
|
// Check if it's volatile state
|
|
428
428
|
if (propStr in node.volatileState) {
|
|
429
|
-
node.volatileState[propStr]
|
|
429
|
+
const oldValue = node.volatileState[propStr];
|
|
430
|
+
if (oldValue !== value) {
|
|
431
|
+
node.volatileState[propStr] = value;
|
|
432
|
+
node.notifyVolatileChange();
|
|
433
|
+
}
|
|
430
434
|
return true;
|
|
431
435
|
}
|
|
432
436
|
|
package/src/tree.ts
CHANGED
|
@@ -360,6 +360,11 @@ export class StateTreeNode implements IStateTreeNode {
|
|
|
360
360
|
this.notifySnapshotChange();
|
|
361
361
|
}
|
|
362
362
|
|
|
363
|
+
/** Notify about a volatile state change (triggers snapshot listeners without patches) */
|
|
364
|
+
notifyVolatileChange() {
|
|
365
|
+
this.notifySnapshotChange();
|
|
366
|
+
}
|
|
367
|
+
|
|
363
368
|
/** Get root node */
|
|
364
369
|
getRoot(): StateTreeNode {
|
|
365
370
|
let node: StateTreeNode = this;
|