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
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { useRef, useEffect } from 'react';
|
|
2
|
+
import type { DependencyList } from 'react';
|
|
3
|
+
import type { Subscribable, Disposable } from '../types';
|
|
4
|
+
import { isSubscribable, isSubscribeOnly, isInitializable } from './guards';
|
|
5
|
+
import { useInstance } from './use-instance';
|
|
6
|
+
import { useSubscribeOnly } from './use-subscribe-only';
|
|
7
|
+
import type { StateOf } from './types';
|
|
8
|
+
|
|
9
|
+
function depsChanged(prev: DependencyList | undefined, next: DependencyList): boolean {
|
|
10
|
+
if (prev === undefined) return false;
|
|
11
|
+
if (prev.length !== next.length) return true;
|
|
12
|
+
for (let i = 0; i < prev.length; i++) {
|
|
13
|
+
if (!Object.is(prev[i], next[i])) return true;
|
|
14
|
+
}
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// ── With deps (class + initialState + deps) ────────────────────────
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Create component-scoped Subscribable instance, auto-disposed on unmount.
|
|
22
|
+
* Disposes and recreates when deps change.
|
|
23
|
+
* Returns [state, instance] tuple.
|
|
24
|
+
*/
|
|
25
|
+
export function useLocal<T extends Subscribable<any> & Disposable>(
|
|
26
|
+
Class: new (initialState: StateOf<T>) => T,
|
|
27
|
+
initialState: StateOf<T>,
|
|
28
|
+
deps: DependencyList,
|
|
29
|
+
): [StateOf<T>, T];
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Create component-scoped Subscribable instance via factory, auto-disposed on unmount.
|
|
33
|
+
* Disposes and recreates when deps change.
|
|
34
|
+
* Returns [state, instance] tuple.
|
|
35
|
+
*/
|
|
36
|
+
export function useLocal<T extends Subscribable<S> & Disposable, S = StateOf<T>>(
|
|
37
|
+
factory: () => T,
|
|
38
|
+
deps: DependencyList,
|
|
39
|
+
): [S, T];
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Create component-scoped Disposable instance via factory (non-Subscribable), auto-disposed on unmount.
|
|
43
|
+
* Disposes and recreates when deps change.
|
|
44
|
+
* Returns the instance directly.
|
|
45
|
+
*/
|
|
46
|
+
export function useLocal<T extends Disposable>(
|
|
47
|
+
factory: () => T,
|
|
48
|
+
deps: DependencyList,
|
|
49
|
+
): T;
|
|
50
|
+
|
|
51
|
+
// ── Without deps (existing overloads, unchanged) ───────────────────
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Create component-scoped Subscribable instance, auto-disposed on unmount.
|
|
55
|
+
* Returns [state, instance] tuple.
|
|
56
|
+
*/
|
|
57
|
+
export function useLocal<
|
|
58
|
+
T extends Subscribable<S> & Disposable,
|
|
59
|
+
S = StateOf<T>,
|
|
60
|
+
Args extends unknown[] = unknown[]
|
|
61
|
+
>(
|
|
62
|
+
Class: new (...args: Args) => T,
|
|
63
|
+
...args: Args
|
|
64
|
+
): [S, T];
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Create component-scoped Disposable instance (non-Subscribable), auto-disposed on unmount.
|
|
68
|
+
* Returns the instance directly.
|
|
69
|
+
*/
|
|
70
|
+
export function useLocal<T extends Disposable, Args extends unknown[] = unknown[]>(
|
|
71
|
+
Class: new (...args: Args) => T,
|
|
72
|
+
...args: Args
|
|
73
|
+
): T;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Create component-scoped Subscribable instance via factory, auto-disposed on unmount.
|
|
77
|
+
* Returns [state, instance] tuple.
|
|
78
|
+
*/
|
|
79
|
+
export function useLocal<T extends Subscribable<S> & Disposable, S = StateOf<T>>(
|
|
80
|
+
factory: () => T
|
|
81
|
+
): [S, T];
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Create component-scoped Disposable instance via factory (non-Subscribable), auto-disposed on unmount.
|
|
85
|
+
* Returns the instance directly.
|
|
86
|
+
*/
|
|
87
|
+
export function useLocal<T extends Disposable>(factory: () => T): T;
|
|
88
|
+
|
|
89
|
+
// ── Implementation ─────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
export function useLocal<T extends Disposable, S = StateOf<T>>(
|
|
92
|
+
classOrFactory: (new (...args: unknown[]) => T) | (() => T),
|
|
93
|
+
...rest: unknown[]
|
|
94
|
+
): [S, T] | T {
|
|
95
|
+
// ── Detect deps: last arg is an array → treat as deps ──
|
|
96
|
+
let args: unknown[];
|
|
97
|
+
let deps: DependencyList | undefined;
|
|
98
|
+
|
|
99
|
+
if (rest.length > 0 && Array.isArray(rest[rest.length - 1])) {
|
|
100
|
+
deps = rest[rest.length - 1] as DependencyList;
|
|
101
|
+
args = rest.slice(0, -1);
|
|
102
|
+
} else {
|
|
103
|
+
args = rest;
|
|
104
|
+
deps = undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const instanceRef = useRef<T | null>(null);
|
|
108
|
+
const mountedRef = useRef(false);
|
|
109
|
+
const prevDepsRef = useRef<DependencyList | undefined>(undefined);
|
|
110
|
+
|
|
111
|
+
// ── Render phase: dep-change detection ──
|
|
112
|
+
if (deps !== undefined && depsChanged(prevDepsRef.current, deps)) {
|
|
113
|
+
instanceRef.current?.dispose();
|
|
114
|
+
instanceRef.current = null;
|
|
115
|
+
}
|
|
116
|
+
if (deps !== undefined) {
|
|
117
|
+
prevDepsRef.current = deps;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── Create instance if needed ──
|
|
121
|
+
if (!instanceRef.current || instanceRef.current.disposed) {
|
|
122
|
+
const isClass =
|
|
123
|
+
typeof classOrFactory === 'function' &&
|
|
124
|
+
classOrFactory.prototype &&
|
|
125
|
+
classOrFactory.prototype.constructor === classOrFactory;
|
|
126
|
+
|
|
127
|
+
if (isClass) {
|
|
128
|
+
instanceRef.current = new (classOrFactory as new (...a: unknown[]) => T)(...args);
|
|
129
|
+
} else {
|
|
130
|
+
instanceRef.current = (classOrFactory as () => T)();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── Effect: init + deferred cleanup ──
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
const instance = instanceRef.current!; // capture for cleanup closure
|
|
137
|
+
mountedRef.current = true;
|
|
138
|
+
if (isInitializable(instance)) {
|
|
139
|
+
instance.init();
|
|
140
|
+
}
|
|
141
|
+
return () => {
|
|
142
|
+
mountedRef.current = false;
|
|
143
|
+
setTimeout(() => {
|
|
144
|
+
if (!mountedRef.current) {
|
|
145
|
+
instance.dispose();
|
|
146
|
+
}
|
|
147
|
+
}, 0);
|
|
148
|
+
};
|
|
149
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
150
|
+
}, deps ?? []);
|
|
151
|
+
|
|
152
|
+
// ── Subscribe to state if Subscribable ──
|
|
153
|
+
if (isSubscribable(instanceRef.current)) {
|
|
154
|
+
const state = useInstance(instanceRef.current as unknown as Subscribable<S>);
|
|
155
|
+
return [state, instanceRef.current];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ── Subscribe for re-renders if subscribe-only (e.g., Trackable) ──
|
|
159
|
+
if (isSubscribeOnly(instanceRef.current)) {
|
|
160
|
+
useSubscribeOnly(instanceRef.current);
|
|
161
|
+
return instanceRef.current;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return instanceRef.current;
|
|
165
|
+
}
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
# useModel
|
|
2
|
+
|
|
3
|
+
Bind a [Model](../Model.md) to a React component with automatic lifecycle management. `useModel` creates (or receives) a Model instance, auto-initializes on mount, auto-disposes on unmount, and subscribes to state changes so the component re-renders on every update.
|
|
4
|
+
|
|
5
|
+
Also exports `useModelRef` (lifecycle-only, no subscription) and `useField` (surgical per-field subscriptions).
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Signature
|
|
10
|
+
|
|
11
|
+
```typescript
|
|
12
|
+
function useModel<M extends Model<any>>(factory: () => M): ModelHandle<StateOf<M>, M>;
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
| Parameter | Type | Description |
|
|
16
|
+
|---|---|---|
|
|
17
|
+
| `factory` | `() => M` | Factory that returns a Model instance. Called once on mount (or if the previous instance was disposed). |
|
|
18
|
+
|
|
19
|
+
### ModelHandle
|
|
20
|
+
|
|
21
|
+
The return value exposes everything a component needs to render and interact with the form:
|
|
22
|
+
|
|
23
|
+
| Property | Type | Description |
|
|
24
|
+
|---|---|---|
|
|
25
|
+
| `state` | `S` | Current frozen state — read raw field values here. |
|
|
26
|
+
| `errors` | `ValidationErrors<S>` | Validation errors keyed by field name. Empty object when valid. |
|
|
27
|
+
| `valid` | `boolean` | `true` when `errors` has no keys — all fields pass validation. |
|
|
28
|
+
| `dirty` | `boolean` | `true` when `state` differs from the committed baseline (shallow equality). |
|
|
29
|
+
| `model` | `M` | The Model instance itself — call setter methods on this. |
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Lifecycle
|
|
34
|
+
|
|
35
|
+
### Mount
|
|
36
|
+
|
|
37
|
+
1. The `factory` is called during render to create the Model instance.
|
|
38
|
+
2. On mount (inside `useEffect`), `init()` is called automatically if the instance has an `init` method.
|
|
39
|
+
3. `useSyncExternalStore` subscribes to the Model — any `set()` call triggers a React re-render.
|
|
40
|
+
|
|
41
|
+
### Re-render
|
|
42
|
+
|
|
43
|
+
On subsequent renders, the existing instance is reused. The factory is **not** called again. `init()` is **not** called again — it is idempotent.
|
|
44
|
+
|
|
45
|
+
### Unmount
|
|
46
|
+
|
|
47
|
+
On unmount, `dispose()` is called after a deferred `setTimeout(0)`. The defer handles React Strict Mode's mount/unmount/remount cycle — if the component remounts immediately, dispose is cancelled.
|
|
48
|
+
|
|
49
|
+
After dispose:
|
|
50
|
+
- `set()` throws
|
|
51
|
+
- `commit()` / `rollback()` throw
|
|
52
|
+
- `subscribe()` returns a no-op
|
|
53
|
+
|
|
54
|
+
### Passing an External Model
|
|
55
|
+
|
|
56
|
+
When a ViewModel owns the Model (the typical form-page pattern), pass it through the factory:
|
|
57
|
+
|
|
58
|
+
```tsx
|
|
59
|
+
function EditUserForm({ model }: { model: UserFormModel }) {
|
|
60
|
+
const { state, errors, valid, dirty } = useModel(() => model);
|
|
61
|
+
// ...
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The hook subscribes to whichever instance the factory returns. Lifecycle (`init`/`dispose`) still applies — the hook auto-initializes and auto-disposes the instance it holds.
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## Basic Usage
|
|
70
|
+
|
|
71
|
+
### Define a Model
|
|
72
|
+
|
|
73
|
+
```typescript
|
|
74
|
+
import { Model } from 'mvc-kit';
|
|
75
|
+
import type { ValidationErrors } from 'mvc-kit';
|
|
76
|
+
|
|
77
|
+
interface FormState {
|
|
78
|
+
name: string;
|
|
79
|
+
email: string;
|
|
80
|
+
age: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
class FormModel extends Model<FormState> {
|
|
84
|
+
setName(name: string) { this.set({ name }); }
|
|
85
|
+
setEmail(email: string) { this.set({ email }); }
|
|
86
|
+
setAge(age: number) { this.set({ age }); }
|
|
87
|
+
|
|
88
|
+
protected validate(state: Readonly<FormState>): ValidationErrors<FormState> {
|
|
89
|
+
const errors: ValidationErrors<FormState> = {};
|
|
90
|
+
if (!state.name) errors.name = 'Name is required';
|
|
91
|
+
if (!state.email.includes('@')) errors.email = 'Invalid email';
|
|
92
|
+
if (state.age < 0) errors.age = 'Age must be positive';
|
|
93
|
+
return errors;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Bind to a Component
|
|
99
|
+
|
|
100
|
+
```tsx
|
|
101
|
+
import { useModel } from 'mvc-kit/react';
|
|
102
|
+
|
|
103
|
+
function UserForm() {
|
|
104
|
+
const { state, errors, valid, dirty, model } = useModel(
|
|
105
|
+
() => new FormModel({ name: '', email: '', age: 0 })
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<form>
|
|
110
|
+
<input value={state.name} onChange={e => model.setName(e.target.value)} />
|
|
111
|
+
{errors.name && <span className="error">{errors.name}</span>}
|
|
112
|
+
|
|
113
|
+
<input value={state.email} onChange={e => model.setEmail(e.target.value)} />
|
|
114
|
+
{errors.email && <span className="error">{errors.email}</span>}
|
|
115
|
+
|
|
116
|
+
<button disabled={!valid || !dirty}>Save</button>
|
|
117
|
+
</form>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
`state.x` for raw values. `errors.x` for field errors. `model.setX()` for mutations. `valid` and `dirty` for button/form state.
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## Model Inside a ViewModel
|
|
127
|
+
|
|
128
|
+
The typical form-page pattern: a [ViewModel](../ViewModel.md) handles async operations (load, save, delete) and coordination, while the Model handles the editing state. The ViewModel owns the Model and disposes it in `onDispose()`.
|
|
129
|
+
|
|
130
|
+
```typescript
|
|
131
|
+
interface EditState {
|
|
132
|
+
draft: UserState | null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
interface EditEvents {
|
|
136
|
+
saved: { id: string };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
class EditUserViewModel extends ViewModel<EditState, EditEvents> {
|
|
140
|
+
public model!: UserFormModel;
|
|
141
|
+
private service = singleton(UserService);
|
|
142
|
+
|
|
143
|
+
protected async onInit() {
|
|
144
|
+
const user = await this.service.getById(this.userId, this.disposeSignal);
|
|
145
|
+
this.model = new UserFormModel(user);
|
|
146
|
+
this.set({ draft: user });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async save() {
|
|
150
|
+
if (!this.model.valid) return;
|
|
151
|
+
await this.service.update(this.userId, this.model.state, this.disposeSignal);
|
|
152
|
+
this.model.commit();
|
|
153
|
+
this.emit('saved', { id: this.userId });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
protected onDispose() {
|
|
157
|
+
this.model?.dispose();
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
```tsx
|
|
163
|
+
function EditUserPage() {
|
|
164
|
+
const [state, vm] = useLocal(EditUserViewModel, { draft: null });
|
|
165
|
+
const loadState = vm.async.onInit;
|
|
166
|
+
const saveState = vm.async.save;
|
|
167
|
+
|
|
168
|
+
if (loadState.loading || !vm.model) return <Spinner />;
|
|
169
|
+
if (loadState.error) return <ErrorBanner message={loadState.error} />;
|
|
170
|
+
|
|
171
|
+
return <EditUserForm model={vm.model} onSave={() => vm.save()} saving={saveState.loading} />;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function EditUserForm({ model, onSave, saving }: Props) {
|
|
175
|
+
const { state, errors, valid, dirty } = useModel(() => model);
|
|
176
|
+
|
|
177
|
+
return (
|
|
178
|
+
<form onSubmit={e => { e.preventDefault(); onSave(); }}>
|
|
179
|
+
<input value={state.name} onChange={e => model.setName(e.target.value)} />
|
|
180
|
+
{errors.name && <span>{errors.name}</span>}
|
|
181
|
+
<button disabled={!valid || !dirty || saving}>
|
|
182
|
+
{saving ? 'Saving...' : 'Save'}
|
|
183
|
+
</button>
|
|
184
|
+
</form>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
---
|
|
190
|
+
|
|
191
|
+
## useModelRef
|
|
192
|
+
|
|
193
|
+
Create a component-scoped Model with lifecycle management (init + dispose) but **no state subscription**. The parent component never re-renders from model state changes.
|
|
194
|
+
|
|
195
|
+
Designed for the per-field isolation pattern: parent creates the model via `useModelRef`, children subscribe to individual fields via `useField`.
|
|
196
|
+
|
|
197
|
+
### Signature
|
|
198
|
+
|
|
199
|
+
```typescript
|
|
200
|
+
function useModelRef<M extends Model<any>>(factory: () => M): M;
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
| Parameter | Type | Description |
|
|
204
|
+
|---|---|---|
|
|
205
|
+
| `factory` | `() => M` | Factory that returns a Model instance. Called once on mount (or if the previous instance was disposed). |
|
|
206
|
+
|
|
207
|
+
**Returns:** `M` — the Model instance directly. No `state`, `errors`, `valid`, or `dirty` — read those in child components via `useField` or `useInstance`.
|
|
208
|
+
|
|
209
|
+
### Lifecycle
|
|
210
|
+
|
|
211
|
+
Same as `useModel`: auto-calls `init()` on mount, auto-calls `dispose()` on unmount (deferred for StrictMode safety). The only difference is **no `useSyncExternalStore` subscription** — the parent never re-renders from model changes.
|
|
212
|
+
|
|
213
|
+
### Usage — Per-Field Form
|
|
214
|
+
|
|
215
|
+
```tsx
|
|
216
|
+
import { Model } from 'mvc-kit';
|
|
217
|
+
import { useModelRef, useField, useInstance } from 'mvc-kit/react';
|
|
218
|
+
|
|
219
|
+
class FormModel extends Model<FormState> {
|
|
220
|
+
protected validate(state: FormState): ValidationErrors<FormState> {
|
|
221
|
+
const errors: ValidationErrors<FormState> = {};
|
|
222
|
+
if (!state.name) errors.name = 'Name is required';
|
|
223
|
+
if (!state.email.includes('@')) errors.email = 'Invalid email';
|
|
224
|
+
return errors;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Parent — creates model, never re-renders from field changes
|
|
229
|
+
function UserForm() {
|
|
230
|
+
const model = useModelRef(() => new FormModel({ name: '', email: '' }));
|
|
231
|
+
return (
|
|
232
|
+
<form>
|
|
233
|
+
<NameField model={model} />
|
|
234
|
+
<EmailField model={model} />
|
|
235
|
+
<FormActions model={model} />
|
|
236
|
+
</form>
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Per-field child — only re-renders when its field changes
|
|
241
|
+
function NameField({ model }: { model: FormModel }) {
|
|
242
|
+
const { value, error, set } = useField(model, 'name');
|
|
243
|
+
return (
|
|
244
|
+
<div>
|
|
245
|
+
<input value={value} onChange={e => set(e.target.value)} />
|
|
246
|
+
{error && <span className="error">{error}</span>}
|
|
247
|
+
</div>
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Submit button — subscribes to full model for valid/dirty
|
|
252
|
+
function FormActions({ model }: { model: FormModel }) {
|
|
253
|
+
useInstance(model);
|
|
254
|
+
return <button disabled={!model.valid || !model.dirty}>Save</button>;
|
|
255
|
+
}
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
### When to Use useModelRef vs useModel
|
|
259
|
+
|
|
260
|
+
| Scenario | Hook | Why |
|
|
261
|
+
|---|---|---|
|
|
262
|
+
| Simple form (few fields, one component) | `useModel` | Component needs `state`, `errors`, `valid`, `dirty` directly |
|
|
263
|
+
| Large form (many fields, per-field components) | `useModelRef` | Parent doesn't subscribe — only changed fields re-render |
|
|
264
|
+
| Submit button / form actions | `useInstance(model)` | Subscribe to full model without owning lifecycle |
|
|
265
|
+
|
|
266
|
+
> **Important:** Don't use `useModel` in the parent when children use `useField` — `useModel` subscribes to all state changes, which defeats per-field isolation. The parent would re-render on every keystroke, negating the benefit.
|
|
267
|
+
|
|
268
|
+
---
|
|
269
|
+
|
|
270
|
+
## useField
|
|
271
|
+
|
|
272
|
+
Subscribe to a **single field** with surgical re-renders. The component only re-renders when that specific field's value or error changes — other fields updating on the same Model are ignored.
|
|
273
|
+
|
|
274
|
+
### Signature
|
|
275
|
+
|
|
276
|
+
```typescript
|
|
277
|
+
function useField<S extends object, K extends keyof S>(
|
|
278
|
+
model: Model<S>,
|
|
279
|
+
field: K
|
|
280
|
+
): FieldHandle<S[K]>;
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
| Parameter | Type | Description |
|
|
284
|
+
|---|---|---|
|
|
285
|
+
| `model` | `Model<S>` | The Model instance (must already be created — typically via `useModelRef`, `useModel`, or a ViewModel). |
|
|
286
|
+
| `field` | `K` | The state key to subscribe to. Type-safe — invalid keys produce a compile-time error. |
|
|
287
|
+
|
|
288
|
+
### FieldHandle
|
|
289
|
+
|
|
290
|
+
| Property | Type | Description |
|
|
291
|
+
|---|---|---|
|
|
292
|
+
| `value` | `S[K]` | Current value of the field. |
|
|
293
|
+
| `error` | `string \| undefined` | Validation error for this field, or `undefined` if valid. |
|
|
294
|
+
| `set` | `(value: S[K]) => void` | Update the field value directly via `model.set({ [field]: value })`. |
|
|
295
|
+
|
|
296
|
+
### Usage
|
|
297
|
+
|
|
298
|
+
```tsx
|
|
299
|
+
import { useField } from 'mvc-kit/react';
|
|
300
|
+
|
|
301
|
+
function NameField({ model }: { model: FormModel }) {
|
|
302
|
+
const { value, error, set } = useField(model, 'name');
|
|
303
|
+
|
|
304
|
+
return (
|
|
305
|
+
<div>
|
|
306
|
+
<input value={value} onChange={e => set(e.target.value)} />
|
|
307
|
+
{error && <span className="error">{error}</span>}
|
|
308
|
+
</div>
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
### When to Use useField vs useModel vs useModelRef
|
|
314
|
+
|
|
315
|
+
| Scenario | Hook |
|
|
316
|
+
|---|---|
|
|
317
|
+
| Simple form with a few fields | `useModel` — one component renders everything |
|
|
318
|
+
| Large form with many fields | `useModelRef` in parent + `useField` in children — only changed fields re-render |
|
|
319
|
+
| Reading `valid` / `dirty` for submit button state | `useInstance(model)` in a leaf component — subscribe without owning lifecycle |
|
|
320
|
+
| Individual field input + error display | `useField` — surgical re-renders |
|
|
321
|
+
|
|
322
|
+
### Selective Re-renders
|
|
323
|
+
|
|
324
|
+
`useField` compares field value and error by reference (`!==`) on every Model notification. If a different field changes, the comparison short-circuits and no re-render occurs:
|
|
325
|
+
|
|
326
|
+
```tsx
|
|
327
|
+
// Only re-renders when `name` value or `name` error changes.
|
|
328
|
+
// Changing `email` or `age` on the same model does NOT trigger a re-render here.
|
|
329
|
+
const { value, error, set } = useField(model, 'name');
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
### Type Safety
|
|
333
|
+
|
|
334
|
+
`useField` rejects invalid field names at compile time:
|
|
335
|
+
|
|
336
|
+
```typescript
|
|
337
|
+
useField(model, 'name'); // OK
|
|
338
|
+
useField(model, 'email'); // OK
|
|
339
|
+
useField(model, 'invalidField'); // Compile error — 'invalidField' is not a key of FormState
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
---
|
|
343
|
+
|
|
344
|
+
## Best Practices
|
|
345
|
+
|
|
346
|
+
**Use named setters on the Model, not `set()` directly.** `set()` is protected. Expose intent-based setters (`setName`, `setEmail`) on your Model subclass.
|
|
347
|
+
|
|
348
|
+
**Read `state.x` for values, `errors.x` for errors.** Don't derive validation in the component — the Model handles it automatically via `validate()`.
|
|
349
|
+
|
|
350
|
+
**Use `valid` and `dirty` for button state.** Disable submit when `!valid || !dirty`. After a successful save, call `model.commit()` to reset `dirty` to `false`.
|
|
351
|
+
|
|
352
|
+
**Split large forms into per-field components with `useField`.** Each field component only re-renders when its own value or error changes.
|
|
353
|
+
|
|
354
|
+
**Let the ViewModel own the Model for form pages.** The ViewModel handles load/save/delete. The Model handles editing. The component just renders. See [Model inside a ViewModel](#model-inside-a-viewmodel).
|
|
355
|
+
|
|
356
|
+
**Don't put async logic in a Model.** Models are for synchronous editing state (values, validation, dirty tracking). Async operations (save, load, delete) belong in a [ViewModel](../ViewModel.md) that owns the Model.
|
|
357
|
+
|
|
358
|
+
---
|
|
359
|
+
|
|
360
|
+
## Related
|
|
361
|
+
|
|
362
|
+
- [Model](../Model.md) — the base class: validation, dirty tracking, commit/rollback
|
|
363
|
+
- [ViewModel](../ViewModel.md) — async tracking, computed getters, events
|
|
364
|
+
- [useLocal](./use-local.ts) — component-scoped ViewModel hook (the ViewModel equivalent of `useModel`)
|