mvc-kit 2.12.0 → 2.12.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/agent-config/bin/postinstall.mjs +5 -3
- package/agent-config/bin/setup.mjs +3 -4
- package/agent-config/claude-code/agents/mvc-kit-architect.md +14 -0
- package/agent-config/claude-code/skills/guide/api-reference.md +24 -2
- package/agent-config/lib/install-claude.mjs +10 -33
- package/dist/Model.cjs +9 -1
- package/dist/Model.cjs.map +1 -1
- package/dist/Model.d.ts +1 -1
- package/dist/Model.d.ts.map +1 -1
- package/dist/Model.js +9 -1
- package/dist/Model.js.map +1 -1
- package/dist/ViewModel.cjs +9 -1
- package/dist/ViewModel.cjs.map +1 -1
- package/dist/ViewModel.d.ts +1 -1
- package/dist/ViewModel.d.ts.map +1 -1
- package/dist/ViewModel.js +9 -1
- package/dist/ViewModel.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/mvc-kit.cjs +3 -0
- package/dist/mvc-kit.cjs.map +1 -1
- package/dist/mvc-kit.js +3 -0
- package/dist/mvc-kit.js.map +1 -1
- package/dist/produceDraft.cjs +105 -0
- package/dist/produceDraft.cjs.map +1 -0
- package/dist/produceDraft.d.ts +19 -0
- package/dist/produceDraft.d.ts.map +1 -0
- package/dist/produceDraft.js +105 -0
- package/dist/produceDraft.js.map +1 -0
- package/package.json +4 -2
- package/src/Channel.md +408 -0
- package/src/Channel.test.ts +957 -0
- package/src/Channel.ts +429 -0
- package/src/Collection.md +533 -0
- package/src/Collection.test.ts +1559 -0
- package/src/Collection.ts +653 -0
- package/src/Controller.md +306 -0
- package/src/Controller.test.ts +380 -0
- package/src/Controller.ts +90 -0
- package/src/EventBus.md +308 -0
- package/src/EventBus.test.ts +295 -0
- package/src/EventBus.ts +110 -0
- package/src/Feed.md +218 -0
- package/src/Feed.test.ts +442 -0
- package/src/Feed.ts +101 -0
- package/src/Model.md +524 -0
- package/src/Model.test.ts +642 -0
- package/src/Model.ts +260 -0
- package/src/Pagination.md +168 -0
- package/src/Pagination.test.ts +244 -0
- package/src/Pagination.ts +92 -0
- package/src/Pending.md +380 -0
- package/src/Pending.test.ts +1719 -0
- package/src/Pending.ts +390 -0
- package/src/PersistentCollection.md +183 -0
- package/src/PersistentCollection.test.ts +649 -0
- package/src/PersistentCollection.ts +375 -0
- package/src/Resource.ViewModel.test.ts +503 -0
- package/src/Resource.md +239 -0
- package/src/Resource.test.ts +786 -0
- package/src/Resource.ts +231 -0
- package/src/Selection.md +155 -0
- package/src/Selection.test.ts +326 -0
- package/src/Selection.ts +117 -0
- package/src/Service.md +440 -0
- package/src/Service.test.ts +241 -0
- package/src/Service.ts +72 -0
- package/src/Sorting.md +170 -0
- package/src/Sorting.test.ts +334 -0
- package/src/Sorting.ts +135 -0
- package/src/Trackable.md +166 -0
- package/src/Trackable.test.ts +236 -0
- package/src/Trackable.ts +129 -0
- package/src/ViewModel.async.test.ts +813 -0
- package/src/ViewModel.derived.test.ts +1583 -0
- package/src/ViewModel.md +1111 -0
- package/src/ViewModel.test.ts +1236 -0
- package/src/ViewModel.ts +800 -0
- package/src/bindPublicMethods.test.ts +126 -0
- package/src/bindPublicMethods.ts +48 -0
- package/src/env.d.ts +5 -0
- package/src/errors.test.ts +155 -0
- package/src/errors.ts +133 -0
- package/src/index.ts +49 -0
- package/src/produceDraft.md +90 -0
- package/src/produceDraft.test.ts +394 -0
- package/src/produceDraft.ts +168 -0
- package/src/react/components/CardList.md +97 -0
- package/src/react/components/CardList.test.tsx +142 -0
- package/src/react/components/CardList.tsx +68 -0
- package/src/react/components/DataTable.md +179 -0
- package/src/react/components/DataTable.test.tsx +599 -0
- package/src/react/components/DataTable.tsx +267 -0
- package/src/react/components/InfiniteScroll.md +116 -0
- package/src/react/components/InfiniteScroll.test.tsx +218 -0
- package/src/react/components/InfiniteScroll.tsx +70 -0
- package/src/react/components/types.ts +90 -0
- package/src/react/derived.test.tsx +261 -0
- package/src/react/guards.ts +24 -0
- package/src/react/index.ts +40 -0
- package/src/react/provider.test.tsx +143 -0
- package/src/react/provider.tsx +55 -0
- package/src/react/strict-mode.test.tsx +266 -0
- package/src/react/types.ts +25 -0
- package/src/react/use-event-bus.md +214 -0
- package/src/react/use-event-bus.test.tsx +168 -0
- package/src/react/use-event-bus.ts +40 -0
- package/src/react/use-instance.md +204 -0
- package/src/react/use-instance.test.tsx +350 -0
- package/src/react/use-instance.ts +60 -0
- package/src/react/use-local.md +457 -0
- package/src/react/use-local.rapid-remount.test.tsx +503 -0
- package/src/react/use-local.test.tsx +692 -0
- package/src/react/use-local.ts +165 -0
- package/src/react/use-model.md +364 -0
- package/src/react/use-model.test.tsx +394 -0
- package/src/react/use-model.ts +161 -0
- package/src/react/use-singleton.md +415 -0
- package/src/react/use-singleton.test.tsx +296 -0
- package/src/react/use-singleton.ts +69 -0
- package/src/react/use-subscribe-only.ts +39 -0
- package/src/react/use-teardown.md +169 -0
- package/src/react/use-teardown.test.tsx +86 -0
- package/src/react/use-teardown.ts +27 -0
- package/src/react-native/NativeCollection.test.ts +250 -0
- package/src/react-native/NativeCollection.ts +138 -0
- package/src/react-native/index.ts +1 -0
- package/src/singleton.md +310 -0
- package/src/singleton.test.ts +204 -0
- package/src/singleton.ts +70 -0
- package/src/types.ts +70 -0
- package/src/walkPrototypeChain.ts +22 -0
- package/src/web/IndexedDBCollection.test.ts +235 -0
- package/src/web/IndexedDBCollection.ts +66 -0
- package/src/web/WebStorageCollection.test.ts +214 -0
- package/src/web/WebStorageCollection.ts +116 -0
- package/src/web/idb.ts +184 -0
- package/src/web/index.ts +2 -0
- package/src/wrapAsyncMethods.ts +249 -0
package/src/Model.md
ADDED
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
# Model
|
|
2
|
+
|
|
3
|
+
Reactive entity with field-level validation, dirty tracking, and commit/rollback semantics. Use `Model` for create and edit forms where you need to know which fields are invalid, whether the user has made changes, and the ability to revert or save those changes.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## When to Use Model vs ViewModel
|
|
8
|
+
|
|
9
|
+
| Use a **Model** when... | Use a **ViewModel** when... |
|
|
10
|
+
|---|---|
|
|
11
|
+
| Editing a single entity | Managing UI state, computed properties, and actions for a component |
|
|
12
|
+
| You need field-level validation errors | You need async tracking, derived getters, or collection subscriptions |
|
|
13
|
+
| You need dirty tracking (commit/rollback) | You need imperative events or lifecycle-driven data loading |
|
|
14
|
+
|
|
15
|
+
Models are often **owned by a ViewModel** — the ViewModel handles async operations (save, delete) while the Model handles the form's editing state.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Defining a Model
|
|
20
|
+
|
|
21
|
+
Extend `Model<S>` with your state shape. `Model` is abstract — you must subclass it.
|
|
22
|
+
|
|
23
|
+
```typescript
|
|
24
|
+
import { Model } from 'mvc-kit';
|
|
25
|
+
import type { ValidationErrors } from 'mvc-kit';
|
|
26
|
+
|
|
27
|
+
interface UserFormState {
|
|
28
|
+
name: string;
|
|
29
|
+
email: string;
|
|
30
|
+
age: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
class UserFormModel extends Model<UserFormState> {
|
|
34
|
+
setName(name: string) { this.set({ name }); }
|
|
35
|
+
setEmail(email: string) { this.set({ email }); }
|
|
36
|
+
setAge(age: number) { this.set({ age }); }
|
|
37
|
+
|
|
38
|
+
protected validate(state: Readonly<UserFormState>): ValidationErrors<UserFormState> {
|
|
39
|
+
const errors: ValidationErrors<UserFormState> = {};
|
|
40
|
+
if (!state.name.trim()) errors.name = 'Name is required';
|
|
41
|
+
if (!state.email.includes('@')) errors.email = 'Invalid email';
|
|
42
|
+
if (state.age < 0 || state.age > 150) errors.age = 'Age must be between 0 and 150';
|
|
43
|
+
return errors;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## State
|
|
51
|
+
|
|
52
|
+
### Initialization
|
|
53
|
+
|
|
54
|
+
State is passed to the constructor and frozen immediately. Both `state` and `committed` point to the same frozen snapshot.
|
|
55
|
+
|
|
56
|
+
```typescript
|
|
57
|
+
const model = new UserFormModel({ name: 'Alice', email: 'alice@example.com', age: 30 });
|
|
58
|
+
|
|
59
|
+
model.state; // { name: 'Alice', email: 'alice@example.com', age: 30 }
|
|
60
|
+
model.committed; // same reference as state
|
|
61
|
+
model.dirty; // false
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Updating State
|
|
65
|
+
|
|
66
|
+
Call `set()` (protected) with a partial object, an updater function, or a draft mutator. The method is protected — expose it through named setter methods on your subclass.
|
|
67
|
+
|
|
68
|
+
```typescript
|
|
69
|
+
// Partial object
|
|
70
|
+
this.set({ name: 'Bob' });
|
|
71
|
+
|
|
72
|
+
// Updater function — return a partial
|
|
73
|
+
this.set(prev => ({ age: prev.age + 1 }));
|
|
74
|
+
|
|
75
|
+
// Draft mode — mutate the draft, return nothing
|
|
76
|
+
this.set(draft => { draft.age = draft.age + 1 });
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
When using the function form, the state is wrapped in a copy-on-write draft proxy (see [`produceDraft`](./produceDraft.md)). If the function returns an object, it's used as the partial. If `void`, the draft mutations are applied. Structural sharing is preserved and arrays must be replaced via assignment.
|
|
80
|
+
|
|
81
|
+
`set()` behavior:
|
|
82
|
+
- **Shallow equality check** — if no values actually changed, the update is skipped and no listeners fire.
|
|
83
|
+
- **Immutable** — produces a new frozen object via `Object.freeze({ ...prev, ...partial })`.
|
|
84
|
+
- **Notifies listeners** — all subscribers receive `(nextState, prevState)` after every real change.
|
|
85
|
+
- **Throws after dispose** — calling `set()` on a disposed Model throws `'Cannot set state on disposed Model'`.
|
|
86
|
+
|
|
87
|
+
### Reading State
|
|
88
|
+
|
|
89
|
+
```typescript
|
|
90
|
+
model.state; // current frozen state
|
|
91
|
+
model.committed; // baseline state for dirty comparison
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## Dirty Tracking
|
|
97
|
+
|
|
98
|
+
A Model is **dirty** when its current `state` differs from its `committed` baseline. The comparison is shallow equality across all keys.
|
|
99
|
+
|
|
100
|
+
```typescript
|
|
101
|
+
const model = new UserFormModel({ name: 'Alice', email: 'alice@example.com', age: 30 });
|
|
102
|
+
|
|
103
|
+
model.dirty; // false
|
|
104
|
+
|
|
105
|
+
model.setName('Bob');
|
|
106
|
+
model.dirty; // true — name changed from committed
|
|
107
|
+
|
|
108
|
+
model.setName('Alice');
|
|
109
|
+
model.dirty; // false — back to committed values
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### commit()
|
|
113
|
+
|
|
114
|
+
Marks the current state as the new baseline. After `commit()`, `dirty` becomes `false` and `committed` equals `state`.
|
|
115
|
+
|
|
116
|
+
```typescript
|
|
117
|
+
model.setName('Bob');
|
|
118
|
+
model.dirty; // true
|
|
119
|
+
|
|
120
|
+
model.commit();
|
|
121
|
+
model.dirty; // false
|
|
122
|
+
model.committed.name; // 'Bob'
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Use this after a successful save to mark the persisted state as the new clean baseline.
|
|
126
|
+
|
|
127
|
+
### rollback()
|
|
128
|
+
|
|
129
|
+
Reverts state to the committed baseline. If already at the committed state, it's a no-op (no listeners fire).
|
|
130
|
+
|
|
131
|
+
```typescript
|
|
132
|
+
model.setName('Bob');
|
|
133
|
+
model.setAge(25);
|
|
134
|
+
model.state.name; // 'Bob'
|
|
135
|
+
|
|
136
|
+
model.rollback();
|
|
137
|
+
model.state.name; // 'Alice' (reverted to committed)
|
|
138
|
+
model.dirty; // false
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Rollback **notifies listeners** (unless state was already at committed), making it suitable for a "Discard changes" button.
|
|
142
|
+
|
|
143
|
+
Both `commit()` and `rollback()` throw on a disposed Model.
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
## Validation
|
|
148
|
+
|
|
149
|
+
Override the `validate()` method to provide field-level validation. Return an object mapping field keys to error message strings. An empty object means valid.
|
|
150
|
+
|
|
151
|
+
```typescript
|
|
152
|
+
protected validate(state: Readonly<UserFormState>): ValidationErrors<UserFormState> {
|
|
153
|
+
const errors: ValidationErrors<UserFormState> = {};
|
|
154
|
+
if (!state.name.trim()) errors.name = 'Name is required';
|
|
155
|
+
if (!state.email.includes('@')) errors.email = 'Invalid email';
|
|
156
|
+
return errors;
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Reading Validation
|
|
161
|
+
|
|
162
|
+
```typescript
|
|
163
|
+
model.errors; // { name: 'Name is required', email: 'Invalid email' }
|
|
164
|
+
model.valid; // false (Object.keys(errors).length === 0)
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Both `errors` and `valid` are **computed on access** from the current `state`. When state changes, validation re-evaluates automatically — no manual call needed.
|
|
168
|
+
|
|
169
|
+
If you don't override `validate()`, the default returns `{}` (always valid).
|
|
170
|
+
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
## Lifecycle
|
|
174
|
+
|
|
175
|
+
### init()
|
|
176
|
+
|
|
177
|
+
Calling `init()` sets `initialized` to `true` and invokes the `onInit()` hook. It is **idempotent** — subsequent calls are no-ops. It is also a **no-op after dispose**.
|
|
178
|
+
|
|
179
|
+
```typescript
|
|
180
|
+
class LoadableModel extends Model<UserFormState> {
|
|
181
|
+
protected onInit() {
|
|
182
|
+
// Set initial derived state, subscribe to external sources, etc.
|
|
183
|
+
this.set({ name: 'Initialized' });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const model = new LoadableModel({ name: '', email: '', age: 0 });
|
|
188
|
+
model.initialized; // false
|
|
189
|
+
model.init();
|
|
190
|
+
model.initialized; // true
|
|
191
|
+
model.state.name; // 'Initialized'
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
`onInit()` can be **async** — `init()` returns the promise so callers can await it:
|
|
195
|
+
|
|
196
|
+
```typescript
|
|
197
|
+
protected async onInit() {
|
|
198
|
+
const data = await fetchInitialData(this.disposeSignal);
|
|
199
|
+
this.set(data);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
await model.init();
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### dispose()
|
|
206
|
+
|
|
207
|
+
Tears down the Model in this order:
|
|
208
|
+
1. Sets `disposed` to `true`
|
|
209
|
+
2. Aborts the `disposeSignal` (if it was accessed)
|
|
210
|
+
3. Runs all registered cleanup functions
|
|
211
|
+
4. Calls `onDispose()` hook
|
|
212
|
+
5. Clears all listeners
|
|
213
|
+
|
|
214
|
+
Dispose is **idempotent** — calling it multiple times is safe.
|
|
215
|
+
|
|
216
|
+
```typescript
|
|
217
|
+
model.dispose();
|
|
218
|
+
model.disposed; // true
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
After dispose:
|
|
222
|
+
- `set()` throws
|
|
223
|
+
- `commit()` throws
|
|
224
|
+
- `rollback()` throws
|
|
225
|
+
- `subscribe()` returns a no-op unsubscribe function
|
|
226
|
+
|
|
227
|
+
### onSet(prev, next)
|
|
228
|
+
|
|
229
|
+
Optional hook called after every state change (from both `set()` and `rollback()`). Receives the previous and next state.
|
|
230
|
+
|
|
231
|
+
```typescript
|
|
232
|
+
protected onSet(prev: Readonly<State>, next: Readonly<State>) {
|
|
233
|
+
if (prev.email !== next.email) {
|
|
234
|
+
console.log('Email changed to', next.email);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
---
|
|
240
|
+
|
|
241
|
+
## Subscriptions
|
|
242
|
+
|
|
243
|
+
### subscribe(listener)
|
|
244
|
+
|
|
245
|
+
Register a listener that fires on every state change with `(nextState, prevState)`. Returns an unsubscribe function.
|
|
246
|
+
|
|
247
|
+
```typescript
|
|
248
|
+
const unsub = model.subscribe((next, prev) => {
|
|
249
|
+
console.log('Changed from', prev, 'to', next);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
model.setName('Bob'); // listener fires
|
|
253
|
+
|
|
254
|
+
unsub();
|
|
255
|
+
model.setName('Carol'); // listener does NOT fire
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
Model implements `Subscribable<S>`, so it can be used with `subscribeTo` on ViewModels and other Models.
|
|
259
|
+
|
|
260
|
+
### subscribeTo(source, listener)
|
|
261
|
+
|
|
262
|
+
Subscribe to an external `Subscribable` source (Collection, another Model, Channel, etc.) with automatic cleanup on dispose.
|
|
263
|
+
|
|
264
|
+
```typescript
|
|
265
|
+
class OrderModel extends Model<OrderState> {
|
|
266
|
+
setup(priceCollection: Collection<PriceItem>) {
|
|
267
|
+
this.subscribeTo(priceCollection, (items) => {
|
|
268
|
+
this.set({ prices: items });
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
Also returns an unsubscribe function for manual early cleanup.
|
|
275
|
+
|
|
276
|
+
### listenTo(source, event, handler)
|
|
277
|
+
|
|
278
|
+
Subscribe to a typed event on a Channel or EventBus with automatic cleanup on dispose. The event counterpart to `subscribeTo`.
|
|
279
|
+
|
|
280
|
+
```typescript
|
|
281
|
+
protected onInit() {
|
|
282
|
+
this.listenTo(this.bus, 'fieldUpdate', (update) => {
|
|
283
|
+
this.set({ [update.field]: update.value });
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
### addCleanup(fn)
|
|
289
|
+
|
|
290
|
+
Register a cleanup function that runs on `dispose()`. Used internally by `subscribeTo`, but also available for custom teardown.
|
|
291
|
+
|
|
292
|
+
```typescript
|
|
293
|
+
protected onInit() {
|
|
294
|
+
const interval = setInterval(() => this.tick(), 1000);
|
|
295
|
+
this.addCleanup(() => clearInterval(interval));
|
|
296
|
+
}
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
---
|
|
300
|
+
|
|
301
|
+
## Cancellation with disposeSignal
|
|
302
|
+
|
|
303
|
+
`disposeSignal` is a lazily-created `AbortSignal` that aborts when the Model is disposed. Pass it to `fetch()` or any async API to cancel in-flight operations on teardown.
|
|
304
|
+
|
|
305
|
+
```typescript
|
|
306
|
+
protected async onInit() {
|
|
307
|
+
const res = await fetch('/api/data', { signal: this.disposeSignal });
|
|
308
|
+
this.set({ data: await res.json() });
|
|
309
|
+
}
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
- **Lazy** — the AbortController is only created when `disposeSignal` is first accessed. Zero cost if never used.
|
|
313
|
+
- **Stable** — returns the same signal on every access.
|
|
314
|
+
- **Aborted before onDispose** — by the time `onDispose()` runs, the signal is already aborted.
|
|
315
|
+
|
|
316
|
+
---
|
|
317
|
+
|
|
318
|
+
## React Integration
|
|
319
|
+
|
|
320
|
+
### useModel
|
|
321
|
+
|
|
322
|
+
Binds a Model to a component. Creates the instance from a factory, auto-initializes on mount, auto-disposes on unmount, and subscribes to state changes.
|
|
323
|
+
|
|
324
|
+
```tsx
|
|
325
|
+
import { useModel } from 'mvc-kit/react';
|
|
326
|
+
|
|
327
|
+
function EditUserForm() {
|
|
328
|
+
const { state, errors, valid, dirty, model } = useModel(
|
|
329
|
+
() => new UserFormModel({ name: '', email: '', age: 0 })
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
return (
|
|
333
|
+
<form>
|
|
334
|
+
<input value={state.name} onChange={e => model.setName(e.target.value)} />
|
|
335
|
+
{errors.name && <span className="error">{errors.name}</span>}
|
|
336
|
+
|
|
337
|
+
<input value={state.email} onChange={e => model.setEmail(e.target.value)} />
|
|
338
|
+
{errors.email && <span className="error">{errors.email}</span>}
|
|
339
|
+
|
|
340
|
+
<button disabled={!valid || !dirty}>Save</button>
|
|
341
|
+
</form>
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
The returned `ModelHandle` provides:
|
|
347
|
+
|
|
348
|
+
| Property | Type | Description |
|
|
349
|
+
|---|---|---|
|
|
350
|
+
| `state` | `S` | Current frozen state |
|
|
351
|
+
| `errors` | `ValidationErrors<S>` | Validation errors keyed by field |
|
|
352
|
+
| `valid` | `boolean` | `true` when no validation errors |
|
|
353
|
+
| `dirty` | `boolean` | `true` when state differs from committed |
|
|
354
|
+
| `model` | `M` | The Model instance for calling setters |
|
|
355
|
+
|
|
356
|
+
### useField
|
|
357
|
+
|
|
358
|
+
Subscribes to a **single field** with surgical re-renders — the component only re-renders when that specific field's value or error changes. Use this for forms with many fields to avoid re-rendering the entire form on every keystroke.
|
|
359
|
+
|
|
360
|
+
```tsx
|
|
361
|
+
import { useField } from 'mvc-kit/react';
|
|
362
|
+
|
|
363
|
+
function NameField({ model }: { model: UserFormModel }) {
|
|
364
|
+
const { value, error, set } = useField(model, 'name');
|
|
365
|
+
|
|
366
|
+
return (
|
|
367
|
+
<div>
|
|
368
|
+
<input value={value} onChange={e => set(e.target.value)} />
|
|
369
|
+
{error && <span className="error">{error}</span>}
|
|
370
|
+
</div>
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
The returned `FieldHandle` provides:
|
|
376
|
+
|
|
377
|
+
| Property | Type | Description |
|
|
378
|
+
|---|---|---|
|
|
379
|
+
| `value` | `S[K]` | Current value of the field |
|
|
380
|
+
| `error` | `string \| undefined` | Validation error for the field |
|
|
381
|
+
| `set` | `(value: S[K]) => void` | Update the field value |
|
|
382
|
+
|
|
383
|
+
`useField` is type-safe — invalid field names produce a compile-time error.
|
|
384
|
+
|
|
385
|
+
---
|
|
386
|
+
|
|
387
|
+
## Model Inside a ViewModel
|
|
388
|
+
|
|
389
|
+
The typical pattern for a form page: the ViewModel handles async operations and coordination, the Model handles editing state.
|
|
390
|
+
|
|
391
|
+
```typescript
|
|
392
|
+
interface EditState {
|
|
393
|
+
draft: UserState | null;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
interface EditEvents {
|
|
397
|
+
saved: { id: string };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
class EditUserViewModel extends ViewModel<EditState, EditEvents> {
|
|
401
|
+
public model!: UserFormModel;
|
|
402
|
+
private service = singleton(UserService);
|
|
403
|
+
|
|
404
|
+
protected async onInit() {
|
|
405
|
+
const user = await this.service.getById(this.userId, this.disposeSignal);
|
|
406
|
+
this.model = new UserFormModel(user);
|
|
407
|
+
this.set({ draft: user });
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
async save() {
|
|
411
|
+
if (!this.model.valid) return;
|
|
412
|
+
await this.service.update(this.userId, this.model.state, this.disposeSignal);
|
|
413
|
+
this.model.commit();
|
|
414
|
+
this.emit('saved', { id: this.userId });
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
protected onDispose() {
|
|
418
|
+
this.model?.dispose();
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
The component:
|
|
424
|
+
|
|
425
|
+
```tsx
|
|
426
|
+
function EditUserPage() {
|
|
427
|
+
const [state, vm] = useLocal(EditUserViewModel, { draft: null });
|
|
428
|
+
const loadState = vm.async.onInit;
|
|
429
|
+
const saveState = vm.async.save;
|
|
430
|
+
|
|
431
|
+
if (loadState.loading || !vm.model) return <Spinner />;
|
|
432
|
+
if (loadState.error) return <ErrorBanner message={loadState.error} />;
|
|
433
|
+
|
|
434
|
+
return <EditUserForm model={vm.model} onSave={() => vm.save()} saving={saveState.loading} />;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function EditUserForm({ model, onSave, saving }: Props) {
|
|
438
|
+
const { state, errors, valid, dirty } = useModel(() => model);
|
|
439
|
+
|
|
440
|
+
return (
|
|
441
|
+
<form onSubmit={e => { e.preventDefault(); onSave(); }}>
|
|
442
|
+
<input value={state.name} onChange={e => model.setName(e.target.value)} />
|
|
443
|
+
{errors.name && <span>{errors.name}</span>}
|
|
444
|
+
<button disabled={!valid || !dirty || saving}>
|
|
445
|
+
{saving ? 'Saving...' : 'Save'}
|
|
446
|
+
</button>
|
|
447
|
+
</form>
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
---
|
|
453
|
+
|
|
454
|
+
## Singleton Usage
|
|
455
|
+
|
|
456
|
+
Models can be registered as singletons for app-wide entity state (rare, but supported):
|
|
457
|
+
|
|
458
|
+
```typescript
|
|
459
|
+
import { singleton, teardownAll } from 'mvc-kit';
|
|
460
|
+
|
|
461
|
+
const model = singleton(UserFormModel, { name: 'Alice', email: 'alice@example.com', age: 30 });
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
The initial state is only used on the first call. Subsequent `singleton()` calls return the existing instance regardless of the state argument passed.
|
|
465
|
+
|
|
466
|
+
---
|
|
467
|
+
|
|
468
|
+
## API Reference
|
|
469
|
+
|
|
470
|
+
### Constructor
|
|
471
|
+
|
|
472
|
+
| Signature | Description |
|
|
473
|
+
|---|---|
|
|
474
|
+
| `constructor(initialState: S)` | Creates a new Model with frozen initial state. Sets both `state` and `committed` to the frozen snapshot. |
|
|
475
|
+
|
|
476
|
+
### Properties
|
|
477
|
+
|
|
478
|
+
| Property | Type | Description |
|
|
479
|
+
|---|---|---|
|
|
480
|
+
| `state` | `S` | Current frozen state |
|
|
481
|
+
| `committed` | `S` | Baseline state for dirty comparison |
|
|
482
|
+
| `dirty` | `boolean` | `true` if `state` differs from `committed` (shallow equality) |
|
|
483
|
+
| `errors` | `ValidationErrors<S>` | Validation errors for current state |
|
|
484
|
+
| `valid` | `boolean` | `true` if `errors` is empty |
|
|
485
|
+
| `disposed` | `boolean` | `true` after `dispose()` |
|
|
486
|
+
| `initialized` | `boolean` | `true` after `init()` |
|
|
487
|
+
| `disposeSignal` | `AbortSignal` | Lazily-created signal, aborted on dispose |
|
|
488
|
+
|
|
489
|
+
### Methods
|
|
490
|
+
|
|
491
|
+
| Method | Description |
|
|
492
|
+
|---|---|
|
|
493
|
+
| `init()` | Set `initialized = true` and call `onInit()`. Idempotent. No-op after dispose. Returns `void \| Promise<void>`. |
|
|
494
|
+
| `commit()` | Set `committed = state`. Throws if disposed. |
|
|
495
|
+
| `rollback()` | Revert `state` to `committed`. Notifies listeners. No-op if already at committed. Throws if disposed. |
|
|
496
|
+
| `subscribe(listener)` | Register a `(next, prev) => void` listener. Returns unsubscribe function. Returns no-op if disposed. |
|
|
497
|
+
| `dispose()` | Tear down: abort signal, run cleanups, call `onDispose()`, clear listeners. Idempotent. |
|
|
498
|
+
|
|
499
|
+
### Protected Methods
|
|
500
|
+
|
|
501
|
+
| Method | Description |
|
|
502
|
+
|---|---|
|
|
503
|
+
| `set(partial \| updater \| drafter)` | Update state via object, updater function, or draft mutator. Skips if no values changed. Throws if disposed. |
|
|
504
|
+
| `validate(state)` | Override to return field-level errors. Default returns `{}`. |
|
|
505
|
+
| `addCleanup(fn)` | Register a function to run on dispose. |
|
|
506
|
+
| `subscribeTo(source, listener)` | Subscribe to a `Subscribable` with auto-cleanup. Returns unsubscribe. |
|
|
507
|
+
| `listenTo(source, event, handler)` | Subscribe to a typed event (Channel/EventBus) with auto-cleanup. Returns unsubscribe. |
|
|
508
|
+
|
|
509
|
+
### Lifecycle Hooks
|
|
510
|
+
|
|
511
|
+
| Hook | Description |
|
|
512
|
+
|---|---|
|
|
513
|
+
| `onInit()` | Called by `init()`. Can be async. |
|
|
514
|
+
| `onSet(prev, next)` | Called after every state change. |
|
|
515
|
+
| `onDispose()` | Called during `dispose()`, after signal abort and cleanups. |
|
|
516
|
+
|
|
517
|
+
## Method Binding
|
|
518
|
+
|
|
519
|
+
All public methods are auto-bound in the constructor. You can pass them point-free as callbacks without losing `this` context:
|
|
520
|
+
|
|
521
|
+
```tsx
|
|
522
|
+
const { commit, rollback } = model;
|
|
523
|
+
<button onClick={commit}>Save</button> // point-free works
|
|
524
|
+
```
|