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 +1087 -60
- package/dist/{chunk-XXZK62DD.mjs → chunk-QZSMSMOP.mjs} +4 -0
- package/dist/index.d.mts +104 -1
- package/dist/index.d.ts +104 -1
- package/dist/index.js +89 -2
- package/dist/index.mjs +85 -3
- package/dist/react.js +4 -0
- package/dist/react.mjs +1 -1
- package/package.json +1 -1
- package/src/__tests__/index.test.ts +824 -341
- package/src/index.ts +9 -1
- package/src/model.ts +186 -3
- package/src/tree.ts +5 -0
- package/src/types.ts +98 -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
|