mobx-mantle 0.6.1 → 0.7.0
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 +1556 -1498
- package/dist/index.cjs +115 -48
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +39 -2
- package/dist/index.d.ts +39 -2
- package/dist/index.js +73 -6
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,1500 +1,1558 @@
|
|
|
1
|
-
# MobX Mantle
|
|
2
|
-
|
|
3
|
-
A lightweight library for building React components with a familiar class-based API and MobX reactivity built in. Get full access to the React ecosystem, with better access to vanilla JS libraries, and simpler overall DX for both.
|
|
4
|
-
|
|
5
|
-
## Why
|
|
6
|
-
|
|
7
|
-
If you're using MobX for state management, React hooks often add complexity without benefit. React hooks solve real problems: stale closures, dependency tracking, memoization. But when using MobX reactivity, many of those problems are already solved.
|
|
8
|
-
|
|
9
|
-
The goal is to give React developers a way to build components using patterns common outside the React world: mutable state, stable references, computed getters, direct method calls. Patterns familiar to developers from game development, mobile frameworks, and other web frameworks. This makes it easier to use excellent vanilla JS libraries while still accessing the massive React ecosystem.
|
|
10
|
-
|
|
11
|
-
## Installation
|
|
12
|
-
|
|
13
|
-
```bash
|
|
14
|
-
npm install mobx-mantle
|
|
15
|
-
```
|
|
16
|
-
|
|
17
|
-
Requires React 18+ and MobX 6+. (No other dependencies — Mantle ships its own observer implementation.)
|
|
18
|
-
|
|
19
|
-
## Take it for a spin
|
|
20
|
-
|
|
21
|
-
Want to try Mantle before adding it to a project? Clone the repo and run the
|
|
22
|
-
playground — a live Vite app with example components you can edit and watch
|
|
23
|
-
update instantly:
|
|
24
|
-
|
|
25
|
-
```bash
|
|
26
|
-
git clone https://github.com/craigthings/mobx-mantle
|
|
27
|
-
cd mobx-mantle
|
|
28
|
-
npm install
|
|
29
|
-
npm run dev
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
Open the URL Vite prints (usually http://localhost:5173).
|
|
33
|
-
|
|
34
|
-
- The playground lives in [`playground/`](playground/); `main.tsx` renders the
|
|
35
|
-
demos (`Greeting`, `Todo`, and a `Counter` you can wire in).
|
|
36
|
-
- The demos import Mantle straight from `src/`, so editing **either a demo or the
|
|
37
|
-
library source** hot-reloads in the browser — a fast loop for poking at how it
|
|
38
|
-
works.
|
|
39
|
-
- To change something: edit any file in `playground/`, or drop your own
|
|
40
|
-
component into `App` in `playground/main.tsx`. It runs in React StrictMode, so
|
|
41
|
-
you'll see the mount/remount behavior too.
|
|
42
|
-
|
|
43
|
-
Curious how it works under the hood? See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
|
44
|
-
|
|
45
|
-
## Basic Example
|
|
46
|
-
|
|
47
|
-
```tsx
|
|
48
|
-
import { Component, createComponent } from 'mobx-mantle';
|
|
49
|
-
|
|
50
|
-
interface CounterProps {
|
|
51
|
-
initial: number;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
class Counter extends Component<CounterProps> {
|
|
55
|
-
count = this.props.initial;
|
|
56
|
-
|
|
57
|
-
increment() {
|
|
58
|
-
this.count++;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
render() {
|
|
62
|
-
return (
|
|
63
|
-
<button onClick={this.increment}>
|
|
64
|
-
Count: {this.count}
|
|
65
|
-
</button>
|
|
66
|
-
);
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export default createComponent(Counter);
|
|
71
|
-
```
|
|
72
|
-
|
|
73
|
-
**Everything is reactive by default.** All properties become observable, getters become computed, and methods become auto-bound. A method called inside a render, computed, or reaction keeps its observable reads tracked; elsewhere its synchronous mutations are batched as an action. No annotations needed.
|
|
74
|
-
|
|
75
|
-
> Want explicit control? See [Decorators](#decorators) below to opt into manual annotations.
|
|
76
|
-
|
|
77
|
-
## What You Get
|
|
78
|
-
|
|
79
|
-
**Direct mutation:**
|
|
80
|
-
```tsx
|
|
81
|
-
this.items.push(item); // not setItems(prev => [...prev, item])
|
|
82
|
-
```
|
|
83
|
-
|
|
84
|
-
**Computed values via getters:**
|
|
85
|
-
```tsx
|
|
86
|
-
get completed() { // not useMemo(() => items.filter(...), [items])
|
|
87
|
-
return this.items.filter(i => i.done);
|
|
88
|
-
}
|
|
89
|
-
```
|
|
90
|
-
|
|
91
|
-
**Stable methods (auto-bound):**
|
|
92
|
-
```tsx
|
|
93
|
-
toggle(id: number) { // automatically bound to this
|
|
94
|
-
const item = this.items.find(i => i.id === id);
|
|
95
|
-
if (item) item.done = !item.done;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// use directly, no wrapper needed
|
|
99
|
-
<button onClick={this.toggle} />
|
|
100
|
-
```
|
|
101
|
-
|
|
102
|
-
**React to changes explicitly:**
|
|
103
|
-
```tsx
|
|
104
|
-
onMount() {
|
|
105
|
-
this.watch(
|
|
106
|
-
() => this.props.filter,
|
|
107
|
-
(filter) => this.applyFilter(filter)
|
|
108
|
-
);
|
|
109
|
-
}
|
|
110
|
-
```
|
|
111
|
-
|
|
112
|
-
## Lifecycle
|
|
113
|
-
|
|
114
|
-
| Method | When |
|
|
115
|
-
|--------|------|
|
|
116
|
-
| `onCreate()` | Instance created, props available, before first render. Derive initial state here (synchronous only). |
|
|
117
|
-
| `onLayoutMount()` | DOM ready, before paint. Return a cleanup function (optional). |
|
|
118
|
-
| `onMount()` | Component mounted, after paint. The default home for watchers, effects, and resource acquisition. Return a cleanup function (optional). |
|
|
119
|
-
| `onUpdate()` | After every render (via `useEffect`). |
|
|
120
|
-
| `onUnmount()` | Component unmounting. Called after cleanups (optional). |
|
|
121
|
-
| `render()` | On mount and updates. Return JSX. |
|
|
122
|
-
|
|
123
|
-
**The rule of thumb:** everything goes in `onMount()`; derive initial state in `onCreate()`. In development, React StrictMode may clean up and re-run `onLayoutMount`/`onMount` while retaining the same component model and DOM refs. Pre-mount `watch`/`effect` declarations are automatically re-created during that effect replay. Plain `addCleanup` registrations are not; Mantle warns if one is registered before mount. This is different from a real removal (`{show && <Thing />}`, a changed `key`, navigation), which discards the model and constructs a new one if it is rendered again.
|
|
124
|
-
|
|
125
|
-
**Resource ownership:** acquire and release an external resource through the same mechanism. If `onLayoutMount`/`onMount` creates an observer, listener, or timer, return its cleanup from that same method. If a callback ref acquires the resource, the callback must release it when React passes `null`. Do not acquire in a ref callback and disconnect from a mount cleanup: StrictMode can replay the cleanup without replaying the ref callback.
|
|
126
|
-
|
|
127
|
-
### Initial State From Props
|
|
128
|
-
|
|
129
|
-
`this.props` is available during class field initialization. Use this for simple initial state derived from props:
|
|
130
|
-
|
|
131
|
-
```tsx
|
|
132
|
-
class Editor extends Component<{ defaultValue: string }> {
|
|
133
|
-
value = this.props.defaultValue;
|
|
134
|
-
}
|
|
135
|
-
```
|
|
136
|
-
|
|
137
|
-
Use `onCreate()` for more complex conditional setup needed before the first render:
|
|
138
|
-
|
|
139
|
-
```tsx
|
|
140
|
-
class Editor extends Component<{ defaultValue?: string; mode: 'plain' | 'rich' }> {
|
|
141
|
-
value = '';
|
|
142
|
-
|
|
143
|
-
onCreate() {
|
|
144
|
-
if (this.props.mode === 'rich') {
|
|
145
|
-
this.value = normalizeRichText(this.props.defaultValue ?? '');
|
|
146
|
-
} else {
|
|
147
|
-
this.value = this.props.defaultValue ?? '';
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
```
|
|
152
|
-
|
|
153
|
-
Most components should not define a constructor. If you do, call `super(props)` before accessing `this.props`.
|
|
154
|
-
|
|
155
|
-
### Watching State
|
|
156
|
-
|
|
157
|
-
Use `this.watch` to react to state changes. Declare watchers in `onMount()` — they're automatically disposed on unmount, and `onMount` re-runs if the component remounts, so the contract is React-native and StrictMode-safe.
|
|
158
|
-
|
|
159
|
-
Coming from Vue or Svelte, you may reach for `onCreate()` instead — that works too: watchers declared there are automatically re-created on remount. The only behavioral difference is timing: `onCreate` watchers come alive when the component commits, just before the first paint. `fireImmediately` and `effect`'s initial pass run at that point — after the first render, before the user sees anything. (Registrations are recorded during construction but not started, so renders React throws away — Suspense, interrupted transitions — never leak watchers. It also means the first render cannot depend on state a watcher or effect sets; for deriving state, prefer a computed getter.)
|
|
160
|
-
|
|
161
|
-
```tsx
|
|
162
|
-
this.watch(
|
|
163
|
-
() => this.query, // expression to track
|
|
164
|
-
(query, prev) => this.search(query), // runs when result changes
|
|
165
|
-
{ delay: 300, fireImmediately: true } // wait 300ms before running + run on setup
|
|
166
|
-
);
|
|
167
|
-
```
|
|
168
|
-
|
|
169
|
-
**Options:**
|
|
170
|
-
|
|
171
|
-
| Option | Type | Default | Description |
|
|
172
|
-
|--------|------|---------|-------------|
|
|
173
|
-
| `delay` | `number` | — | Wait N ms after a change before running once; changes in between are batched (MobX `reaction` delay, not a debounce — use `withDebounce` for that) |
|
|
174
|
-
| `fireImmediately` | `boolean` | `false` | Run callback immediately with current value |
|
|
175
|
-
|
|
176
|
-
**Basic example:**
|
|
177
|
-
|
|
178
|
-
```tsx
|
|
179
|
-
class Search extends Component<Props> {
|
|
180
|
-
query = '';
|
|
181
|
-
results: string[] = [];
|
|
182
|
-
|
|
183
|
-
onMount() {
|
|
184
|
-
this.watch(
|
|
185
|
-
() => this.query,
|
|
186
|
-
async (query) => {
|
|
187
|
-
if (query.length > 2) {
|
|
188
|
-
this.results = await searchApi(query);
|
|
189
|
-
}
|
|
190
|
-
},
|
|
191
|
-
{ delay: 300 }
|
|
192
|
-
);
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
```
|
|
196
|
-
|
|
197
|
-
Call `this.watch` as many times as you need — each watcher is tracked and disposed independently.
|
|
198
|
-
|
|
199
|
-
**Early disposal** (works the same for `effect`):
|
|
200
|
-
|
|
201
|
-
```tsx
|
|
202
|
-
onMount() {
|
|
203
|
-
const stop = this.watch(() => this.props.token, (token) => {
|
|
204
|
-
this.authenticate(token);
|
|
205
|
-
stop(); // only needed once
|
|
206
|
-
});
|
|
207
|
-
}
|
|
208
|
-
```
|
|
209
|
-
|
|
210
|
-
`this.watch` wraps MobX's `reaction` with automatic lifecycle disposal and remount-safe re-creation. For advanced MobX patterns (`when`, custom schedulers), use MobX directly and return a dispose function from `onMount`.
|
|
211
|
-
|
|
212
|
-
### Effects
|
|
213
|
-
|
|
214
|
-
Use `this.effect` to run a side effect that auto-tracks dependencies. It runs immediately and re-runs whenever any accessed observable changes.
|
|
215
|
-
|
|
216
|
-
```tsx
|
|
217
|
-
this.effect(() => {
|
|
218
|
-
document.title = `${this.count} items`; // auto-tracks this.count
|
|
219
|
-
return () => { /* cleanup */ }; // optional, runs before each re-run
|
|
220
|
-
}, { delay: 100 }); // optional delay: batches changes within 100ms
|
|
221
|
-
```
|
|
222
|
-
|
|
223
|
-
**When to use which:**
|
|
224
|
-
|
|
225
|
-
| Method | Best for |
|
|
226
|
-
|--------|----------|
|
|
227
|
-
| `effect(fn)` | Simple sync: DOM updates, logging, derived side effects |
|
|
228
|
-
| `watch(expr, fn)` | Complex side effects with explicit triggers: API calls, debounced actions |
|
|
229
|
-
|
|
230
|
-
`effect` auto-tracks all accessed state, which can lead to unexpected re-runs in complex scenarios. For side effects where you want explicit control over what triggers re-runs, prefer `watch`.
|
|
231
|
-
|
|
232
|
-
**Example:**
|
|
233
|
-
|
|
234
|
-
```tsx
|
|
235
|
-
class Counter extends Component<Props> {
|
|
236
|
-
count = 0;
|
|
237
|
-
|
|
238
|
-
onMount() {
|
|
239
|
-
// Runs immediately, re-runs when this.count changes
|
|
240
|
-
this.effect(() => {
|
|
241
|
-
const handler = () => console.log('clicked at count:', this.count);
|
|
242
|
-
window.addEventListener('click', handler);
|
|
243
|
-
|
|
244
|
-
// Cleanup runs before each re-run and on unmount
|
|
245
|
-
return () => window.removeEventListener('click', handler);
|
|
246
|
-
});
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
```
|
|
250
|
-
|
|
251
|
-
Early disposal works the same as `watch` — the returned `stop()` function tears the effect down early.
|
|
252
|
-
|
|
253
|
-
### Cleanup
|
|
254
|
-
|
|
255
|
-
Use `this.addCleanup` to register any cleanup function that should run automatically on unmount. This is useful for subscriptions, event listeners, timers, observers, and third-party libraries.
|
|
256
|
-
|
|
257
|
-
```tsx
|
|
258
|
-
onMount() {
|
|
259
|
-
this.addCleanup(store.subscribe(this.handleChange));
|
|
260
|
-
|
|
261
|
-
window.addEventListener('resize', this.handleResize);
|
|
262
|
-
this.addCleanup(() => window.removeEventListener('resize', this.handleResize));
|
|
263
|
-
|
|
264
|
-
const id = setInterval(this.tick, 1000);
|
|
265
|
-
this.addCleanup(() => clearInterval(id));
|
|
266
|
-
}
|
|
267
|
-
```
|
|
268
|
-
|
|
269
|
-
`addCleanup` returns a function you can call for early cleanup. `watch()` and `effect()` use the same automatic cleanup behavior internally.
|
|
270
|
-
|
|
271
|
-
> **Note:** `addCleanup` registrations are one-shot — unlike `watch`/`effect`, they are not re-created if the component remounts. Acquire resources in `onMount()` (which re-runs on remount), or use `this.effect()` when setup must happen at creation — its setup/teardown pair is remount-safe. Mantle warns in development if `addCleanup` is called before mount.
|
|
272
|
-
|
|
273
|
-
### Props Reactivity
|
|
274
|
-
|
|
275
|
-
`this.props` is reactive: your component re-renders when accessed props change.
|
|
276
|
-
|
|
277
|
-
**Option 1: `this.watch`** — the recommended way to react to state changes:
|
|
278
|
-
|
|
279
|
-
```tsx
|
|
280
|
-
onMount() {
|
|
281
|
-
this.watch(
|
|
282
|
-
() => this.props.filter,
|
|
283
|
-
(filter) => this.applyFilter(filter)
|
|
284
|
-
);
|
|
285
|
-
}
|
|
286
|
-
```
|
|
287
|
-
|
|
288
|
-
Watchers are automatically disposed on unmount and re-created on remount. No cleanup needed.
|
|
289
|
-
|
|
290
|
-
**Option 2: raw MobX** — for advanced patterns (`autorun`, `when`, custom schedulers), call MobX directly in `onMount` and return the disposer.
|
|
291
|
-
|
|
292
|
-
**Option 3: `onUpdate`** — imperative hook after each render; requires manual dirty-checking against the previous value.
|
|
293
|
-
|
|
294
|
-
Or access props directly in `render()` and MobX handles re-renders when they change.
|
|
295
|
-
|
|
296
|
-
> **Performance note:** a prop change costs exactly one render when `render()` reads props directly. A computed getter *over props* read in `render()` can add one extra render before paint — what's painted is always correct. If a hot component renders twice per prop change, that's the first thing to check.
|
|
297
|
-
|
|
298
|
-
## Patterns
|
|
299
|
-
|
|
300
|
-
### Combined (default)
|
|
301
|
-
|
|
302
|
-
State, logic, and template in one class:
|
|
303
|
-
|
|
304
|
-
```tsx
|
|
305
|
-
class Todo extends Component<Props> {
|
|
306
|
-
todos: TodoItem[] = [];
|
|
307
|
-
input = '';
|
|
308
|
-
|
|
309
|
-
add() {
|
|
310
|
-
this.todos.push({ id: Date.now(), text: this.input, done: false });
|
|
311
|
-
this.input = '';
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
setInput(e: React.ChangeEvent<HTMLInputElement>) {
|
|
315
|
-
this.input = e.target.value;
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
render() {
|
|
319
|
-
return (
|
|
320
|
-
<div>
|
|
321
|
-
<input value={this.input} onChange={this.setInput} />
|
|
322
|
-
<button onClick={this.add}>Add</button>
|
|
323
|
-
<ul>{this.todos.map(t => <li key={t.id}>{t.text}</li>)}</ul>
|
|
324
|
-
</div>
|
|
325
|
-
);
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
export default createComponent(Todo);
|
|
330
|
-
```
|
|
331
|
-
|
|
332
|
-
### Separated
|
|
333
|
-
|
|
334
|
-
ViewModel and template separate:
|
|
335
|
-
|
|
336
|
-
```tsx
|
|
337
|
-
import { ViewModel, createComponent } from 'mobx-mantle';
|
|
338
|
-
|
|
339
|
-
class Todo extends ViewModel<Props> {
|
|
340
|
-
todos: TodoItem[] = [];
|
|
341
|
-
input = '';
|
|
342
|
-
|
|
343
|
-
add() {
|
|
344
|
-
this.todos.push({ id: Date.now(), text: this.input, done: false });
|
|
345
|
-
this.input = '';
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
setInput(e: React.ChangeEvent<HTMLInputElement>) {
|
|
349
|
-
this.input = e.target.value;
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
export default createComponent(Todo, (vm) => (
|
|
354
|
-
<div>
|
|
355
|
-
<input value={vm.input} onChange={vm.setInput} />
|
|
356
|
-
<button onClick={vm.add}>Add</button>
|
|
357
|
-
<ul>{vm.todos.map(t => <li key={t.id}>{t.text}</li>)}</ul>
|
|
358
|
-
</div>
|
|
359
|
-
));
|
|
360
|
-
```
|
|
361
|
-
|
|
362
|
-
##
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
```
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
this.
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
1
|
+
# MobX Mantle
|
|
2
|
+
|
|
3
|
+
A lightweight library for building React components with a familiar class-based API and MobX reactivity built in. Get full access to the React ecosystem, with better access to vanilla JS libraries, and simpler overall DX for both.
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
If you're using MobX for state management, React hooks often add complexity without benefit. React hooks solve real problems: stale closures, dependency tracking, memoization. But when using MobX reactivity, many of those problems are already solved.
|
|
8
|
+
|
|
9
|
+
The goal is to give React developers a way to build components using patterns common outside the React world: mutable state, stable references, computed getters, direct method calls. Patterns familiar to developers from game development, mobile frameworks, and other web frameworks. This makes it easier to use excellent vanilla JS libraries while still accessing the massive React ecosystem.
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install mobx-mantle
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Requires React 18+ and MobX 6+. (No other dependencies — Mantle ships its own observer implementation.)
|
|
18
|
+
|
|
19
|
+
## Take it for a spin
|
|
20
|
+
|
|
21
|
+
Want to try Mantle before adding it to a project? Clone the repo and run the
|
|
22
|
+
playground — a live Vite app with example components you can edit and watch
|
|
23
|
+
update instantly:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
git clone https://github.com/craigthings/mobx-mantle
|
|
27
|
+
cd mobx-mantle
|
|
28
|
+
npm install
|
|
29
|
+
npm run dev
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Open the URL Vite prints (usually http://localhost:5173).
|
|
33
|
+
|
|
34
|
+
- The playground lives in [`playground/`](playground/); `main.tsx` renders the
|
|
35
|
+
demos (`Greeting`, `Todo`, and a `Counter` you can wire in).
|
|
36
|
+
- The demos import Mantle straight from `src/`, so editing **either a demo or the
|
|
37
|
+
library source** hot-reloads in the browser — a fast loop for poking at how it
|
|
38
|
+
works.
|
|
39
|
+
- To change something: edit any file in `playground/`, or drop your own
|
|
40
|
+
component into `App` in `playground/main.tsx`. It runs in React StrictMode, so
|
|
41
|
+
you'll see the mount/remount behavior too.
|
|
42
|
+
|
|
43
|
+
Curious how it works under the hood? See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
|
44
|
+
|
|
45
|
+
## Basic Example
|
|
46
|
+
|
|
47
|
+
```tsx
|
|
48
|
+
import { Component, createComponent } from 'mobx-mantle';
|
|
49
|
+
|
|
50
|
+
interface CounterProps {
|
|
51
|
+
initial: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
class Counter extends Component<CounterProps> {
|
|
55
|
+
count = this.props.initial;
|
|
56
|
+
|
|
57
|
+
increment() {
|
|
58
|
+
this.count++;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
render() {
|
|
62
|
+
return (
|
|
63
|
+
<button onClick={this.increment}>
|
|
64
|
+
Count: {this.count}
|
|
65
|
+
</button>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export default createComponent(Counter);
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
**Everything is reactive by default.** All properties become observable, getters become computed, and methods become auto-bound. A method called inside a render, computed, or reaction keeps its observable reads tracked; elsewhere its synchronous mutations are batched as an action. No annotations needed.
|
|
74
|
+
|
|
75
|
+
> Want explicit control? See [Decorators](#decorators) below to opt into manual annotations.
|
|
76
|
+
|
|
77
|
+
## What You Get
|
|
78
|
+
|
|
79
|
+
**Direct mutation:**
|
|
80
|
+
```tsx
|
|
81
|
+
this.items.push(item); // not setItems(prev => [...prev, item])
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
**Computed values via getters:**
|
|
85
|
+
```tsx
|
|
86
|
+
get completed() { // not useMemo(() => items.filter(...), [items])
|
|
87
|
+
return this.items.filter(i => i.done);
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
**Stable methods (auto-bound):**
|
|
92
|
+
```tsx
|
|
93
|
+
toggle(id: number) { // automatically bound to this
|
|
94
|
+
const item = this.items.find(i => i.id === id);
|
|
95
|
+
if (item) item.done = !item.done;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// use directly, no wrapper needed
|
|
99
|
+
<button onClick={this.toggle} />
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
**React to changes explicitly:**
|
|
103
|
+
```tsx
|
|
104
|
+
onMount() {
|
|
105
|
+
this.watch(
|
|
106
|
+
() => this.props.filter,
|
|
107
|
+
(filter) => this.applyFilter(filter)
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Lifecycle
|
|
113
|
+
|
|
114
|
+
| Method | When |
|
|
115
|
+
|--------|------|
|
|
116
|
+
| `onCreate()` | Instance created, props available, before first render. Derive initial state here (synchronous only). |
|
|
117
|
+
| `onLayoutMount()` | DOM ready, before paint. Return a cleanup function (optional). |
|
|
118
|
+
| `onMount()` | Component mounted, after paint. The default home for watchers, effects, and resource acquisition. Return a cleanup function (optional). |
|
|
119
|
+
| `onUpdate()` | After every render (via `useEffect`). |
|
|
120
|
+
| `onUnmount()` | Component unmounting. Called after cleanups (optional). |
|
|
121
|
+
| `render()` | On mount and updates. Return JSX. |
|
|
122
|
+
|
|
123
|
+
**The rule of thumb:** everything goes in `onMount()`; derive initial state in `onCreate()`. In development, React StrictMode may clean up and re-run `onLayoutMount`/`onMount` while retaining the same component model and DOM refs. Pre-mount `watch`/`effect` declarations are automatically re-created during that effect replay. Plain `addCleanup` registrations are not; Mantle warns if one is registered before mount. This is different from a real removal (`{show && <Thing />}`, a changed `key`, navigation), which discards the model and constructs a new one if it is rendered again.
|
|
124
|
+
|
|
125
|
+
**Resource ownership:** acquire and release an external resource through the same mechanism. If `onLayoutMount`/`onMount` creates an observer, listener, or timer, return its cleanup from that same method. If a callback ref acquires the resource, the callback must release it when React passes `null`. Do not acquire in a ref callback and disconnect from a mount cleanup: StrictMode can replay the cleanup without replaying the ref callback.
|
|
126
|
+
|
|
127
|
+
### Initial State From Props
|
|
128
|
+
|
|
129
|
+
`this.props` is available during class field initialization. Use this for simple initial state derived from props:
|
|
130
|
+
|
|
131
|
+
```tsx
|
|
132
|
+
class Editor extends Component<{ defaultValue: string }> {
|
|
133
|
+
value = this.props.defaultValue;
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Use `onCreate()` for more complex conditional setup needed before the first render:
|
|
138
|
+
|
|
139
|
+
```tsx
|
|
140
|
+
class Editor extends Component<{ defaultValue?: string; mode: 'plain' | 'rich' }> {
|
|
141
|
+
value = '';
|
|
142
|
+
|
|
143
|
+
onCreate() {
|
|
144
|
+
if (this.props.mode === 'rich') {
|
|
145
|
+
this.value = normalizeRichText(this.props.defaultValue ?? '');
|
|
146
|
+
} else {
|
|
147
|
+
this.value = this.props.defaultValue ?? '';
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Most components should not define a constructor. If you do, call `super(props)` before accessing `this.props`.
|
|
154
|
+
|
|
155
|
+
### Watching State
|
|
156
|
+
|
|
157
|
+
Use `this.watch` to react to state changes. Declare watchers in `onMount()` — they're automatically disposed on unmount, and `onMount` re-runs if the component remounts, so the contract is React-native and StrictMode-safe.
|
|
158
|
+
|
|
159
|
+
Coming from Vue or Svelte, you may reach for `onCreate()` instead — that works too: watchers declared there are automatically re-created on remount. The only behavioral difference is timing: `onCreate` watchers come alive when the component commits, just before the first paint. `fireImmediately` and `effect`'s initial pass run at that point — after the first render, before the user sees anything. (Registrations are recorded during construction but not started, so renders React throws away — Suspense, interrupted transitions — never leak watchers. It also means the first render cannot depend on state a watcher or effect sets; for deriving state, prefer a computed getter.)
|
|
160
|
+
|
|
161
|
+
```tsx
|
|
162
|
+
this.watch(
|
|
163
|
+
() => this.query, // expression to track
|
|
164
|
+
(query, prev) => this.search(query), // runs when result changes
|
|
165
|
+
{ delay: 300, fireImmediately: true } // wait 300ms before running + run on setup
|
|
166
|
+
);
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
**Options:**
|
|
170
|
+
|
|
171
|
+
| Option | Type | Default | Description |
|
|
172
|
+
|--------|------|---------|-------------|
|
|
173
|
+
| `delay` | `number` | — | Wait N ms after a change before running once; changes in between are batched (MobX `reaction` delay, not a debounce — use `withDebounce` for that) |
|
|
174
|
+
| `fireImmediately` | `boolean` | `false` | Run callback immediately with current value |
|
|
175
|
+
|
|
176
|
+
**Basic example:**
|
|
177
|
+
|
|
178
|
+
```tsx
|
|
179
|
+
class Search extends Component<Props> {
|
|
180
|
+
query = '';
|
|
181
|
+
results: string[] = [];
|
|
182
|
+
|
|
183
|
+
onMount() {
|
|
184
|
+
this.watch(
|
|
185
|
+
() => this.query,
|
|
186
|
+
async (query) => {
|
|
187
|
+
if (query.length > 2) {
|
|
188
|
+
this.results = await searchApi(query);
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
{ delay: 300 }
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Call `this.watch` as many times as you need — each watcher is tracked and disposed independently.
|
|
198
|
+
|
|
199
|
+
**Early disposal** (works the same for `effect`):
|
|
200
|
+
|
|
201
|
+
```tsx
|
|
202
|
+
onMount() {
|
|
203
|
+
const stop = this.watch(() => this.props.token, (token) => {
|
|
204
|
+
this.authenticate(token);
|
|
205
|
+
stop(); // only needed once
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
`this.watch` wraps MobX's `reaction` with automatic lifecycle disposal and remount-safe re-creation. For advanced MobX patterns (`when`, custom schedulers), use MobX directly and return a dispose function from `onMount`.
|
|
211
|
+
|
|
212
|
+
### Effects
|
|
213
|
+
|
|
214
|
+
Use `this.effect` to run a side effect that auto-tracks dependencies. It runs immediately and re-runs whenever any accessed observable changes.
|
|
215
|
+
|
|
216
|
+
```tsx
|
|
217
|
+
this.effect(() => {
|
|
218
|
+
document.title = `${this.count} items`; // auto-tracks this.count
|
|
219
|
+
return () => { /* cleanup */ }; // optional, runs before each re-run
|
|
220
|
+
}, { delay: 100 }); // optional delay: batches changes within 100ms
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
**When to use which:**
|
|
224
|
+
|
|
225
|
+
| Method | Best for |
|
|
226
|
+
|--------|----------|
|
|
227
|
+
| `effect(fn)` | Simple sync: DOM updates, logging, derived side effects |
|
|
228
|
+
| `watch(expr, fn)` | Complex side effects with explicit triggers: API calls, debounced actions |
|
|
229
|
+
|
|
230
|
+
`effect` auto-tracks all accessed state, which can lead to unexpected re-runs in complex scenarios. For side effects where you want explicit control over what triggers re-runs, prefer `watch`.
|
|
231
|
+
|
|
232
|
+
**Example:**
|
|
233
|
+
|
|
234
|
+
```tsx
|
|
235
|
+
class Counter extends Component<Props> {
|
|
236
|
+
count = 0;
|
|
237
|
+
|
|
238
|
+
onMount() {
|
|
239
|
+
// Runs immediately, re-runs when this.count changes
|
|
240
|
+
this.effect(() => {
|
|
241
|
+
const handler = () => console.log('clicked at count:', this.count);
|
|
242
|
+
window.addEventListener('click', handler);
|
|
243
|
+
|
|
244
|
+
// Cleanup runs before each re-run and on unmount
|
|
245
|
+
return () => window.removeEventListener('click', handler);
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Early disposal works the same as `watch` — the returned `stop()` function tears the effect down early.
|
|
252
|
+
|
|
253
|
+
### Cleanup
|
|
254
|
+
|
|
255
|
+
Use `this.addCleanup` to register any cleanup function that should run automatically on unmount. This is useful for subscriptions, event listeners, timers, observers, and third-party libraries.
|
|
256
|
+
|
|
257
|
+
```tsx
|
|
258
|
+
onMount() {
|
|
259
|
+
this.addCleanup(store.subscribe(this.handleChange));
|
|
260
|
+
|
|
261
|
+
window.addEventListener('resize', this.handleResize);
|
|
262
|
+
this.addCleanup(() => window.removeEventListener('resize', this.handleResize));
|
|
263
|
+
|
|
264
|
+
const id = setInterval(this.tick, 1000);
|
|
265
|
+
this.addCleanup(() => clearInterval(id));
|
|
266
|
+
}
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
`addCleanup` returns a function you can call for early cleanup. `watch()` and `effect()` use the same automatic cleanup behavior internally.
|
|
270
|
+
|
|
271
|
+
> **Note:** `addCleanup` registrations are one-shot — unlike `watch`/`effect`, they are not re-created if the component remounts. Acquire resources in `onMount()` (which re-runs on remount), or use `this.effect()` when setup must happen at creation — its setup/teardown pair is remount-safe. Mantle warns in development if `addCleanup` is called before mount.
|
|
272
|
+
|
|
273
|
+
### Props Reactivity
|
|
274
|
+
|
|
275
|
+
`this.props` is reactive: your component re-renders when accessed props change.
|
|
276
|
+
|
|
277
|
+
**Option 1: `this.watch`** — the recommended way to react to state changes:
|
|
278
|
+
|
|
279
|
+
```tsx
|
|
280
|
+
onMount() {
|
|
281
|
+
this.watch(
|
|
282
|
+
() => this.props.filter,
|
|
283
|
+
(filter) => this.applyFilter(filter)
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
Watchers are automatically disposed on unmount and re-created on remount. No cleanup needed.
|
|
289
|
+
|
|
290
|
+
**Option 2: raw MobX** — for advanced patterns (`autorun`, `when`, custom schedulers), call MobX directly in `onMount` and return the disposer.
|
|
291
|
+
|
|
292
|
+
**Option 3: `onUpdate`** — imperative hook after each render; requires manual dirty-checking against the previous value.
|
|
293
|
+
|
|
294
|
+
Or access props directly in `render()` and MobX handles re-renders when they change.
|
|
295
|
+
|
|
296
|
+
> **Performance note:** a prop change costs exactly one render when `render()` reads props directly. A computed getter *over props* read in `render()` can add one extra render before paint — what's painted is always correct. If a hot component renders twice per prop change, that's the first thing to check.
|
|
297
|
+
|
|
298
|
+
## Patterns
|
|
299
|
+
|
|
300
|
+
### Combined (default)
|
|
301
|
+
|
|
302
|
+
State, logic, and template in one class:
|
|
303
|
+
|
|
304
|
+
```tsx
|
|
305
|
+
class Todo extends Component<Props> {
|
|
306
|
+
todos: TodoItem[] = [];
|
|
307
|
+
input = '';
|
|
308
|
+
|
|
309
|
+
add() {
|
|
310
|
+
this.todos.push({ id: Date.now(), text: this.input, done: false });
|
|
311
|
+
this.input = '';
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
setInput(e: React.ChangeEvent<HTMLInputElement>) {
|
|
315
|
+
this.input = e.target.value;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
render() {
|
|
319
|
+
return (
|
|
320
|
+
<div>
|
|
321
|
+
<input value={this.input} onChange={this.setInput} />
|
|
322
|
+
<button onClick={this.add}>Add</button>
|
|
323
|
+
<ul>{this.todos.map(t => <li key={t.id}>{t.text}</li>)}</ul>
|
|
324
|
+
</div>
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export default createComponent(Todo);
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
### Separated
|
|
333
|
+
|
|
334
|
+
ViewModel and template separate:
|
|
335
|
+
|
|
336
|
+
```tsx
|
|
337
|
+
import { ViewModel, createComponent } from 'mobx-mantle';
|
|
338
|
+
|
|
339
|
+
class Todo extends ViewModel<Props> {
|
|
340
|
+
todos: TodoItem[] = [];
|
|
341
|
+
input = '';
|
|
342
|
+
|
|
343
|
+
add() {
|
|
344
|
+
this.todos.push({ id: Date.now(), text: this.input, done: false });
|
|
345
|
+
this.input = '';
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
setInput(e: React.ChangeEvent<HTMLInputElement>) {
|
|
349
|
+
this.input = e.target.value;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export default createComponent(Todo, (vm) => (
|
|
354
|
+
<div>
|
|
355
|
+
<input value={vm.input} onChange={vm.setInput} />
|
|
356
|
+
<button onClick={vm.add}>Add</button>
|
|
357
|
+
<ul>{vm.todos.map(t => <li key={t.id}>{t.text}</li>)}</ul>
|
|
358
|
+
</div>
|
|
359
|
+
));
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
## Views
|
|
363
|
+
|
|
364
|
+
A component owns its model. A view borrows one. Use a view when a piece of template needs a model that already exists: splitting a large template across files, or handing a library a component that reads your state.
|
|
365
|
+
|
|
366
|
+
### Splitting a Template
|
|
367
|
+
|
|
368
|
+
`createView(Model, template)` finds the nearest `Model` above it and renders with it as `vm`:
|
|
369
|
+
|
|
370
|
+
```tsx
|
|
371
|
+
class TodoList extends ViewModel<Props> {
|
|
372
|
+
todos: TodoItem[] = [];
|
|
373
|
+
|
|
374
|
+
get remaining() {
|
|
375
|
+
return this.todos.filter(t => !t.done).length;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const Summary = createView(TodoList, ({ vm }) => <p>{vm.remaining} left</p>);
|
|
380
|
+
|
|
381
|
+
export default createComponent(TodoList, (vm) => (
|
|
382
|
+
<div>
|
|
383
|
+
<ul>{vm.todos.map(t => <li key={t.id}>{t.text}</li>)}</ul>
|
|
384
|
+
<Summary />
|
|
385
|
+
</div>
|
|
386
|
+
));
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
`Summary` re-renders on its own when `remaining` changes. The list does not. Extra props pass through, typed on the template: `createView(TodoList, ({ vm, label }: { vm: TodoList; label: string }) => …)`.
|
|
390
|
+
|
|
391
|
+
### Handing a View to a Library
|
|
392
|
+
|
|
393
|
+
`this.view(render)` is a view of one instance. The library decides when to render it, and it re-renders by itself when the state it reads changes:
|
|
394
|
+
|
|
395
|
+
```tsx
|
|
396
|
+
class Table extends Component<Props> {
|
|
397
|
+
activeId: string | null = null;
|
|
398
|
+
|
|
399
|
+
row = this.view((props: RowProps) => (
|
|
400
|
+
<tr {...props} className={props.id === this.activeId ? 'active' : ''} />
|
|
401
|
+
));
|
|
402
|
+
|
|
403
|
+
render() {
|
|
404
|
+
return <DataTable rows={this.props.rows} components={{ row: this.row }} />;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
It is created once, so the library sees a stable component. Refs forward through it. It also carries the model's context, so `findParent()` and services resolve inside it even when the library renders it in its own React root (map popups, editor widgets).
|
|
410
|
+
|
|
411
|
+
## Services
|
|
412
|
+
|
|
413
|
+
Configure your application's resolver once, then declare services as fields:
|
|
414
|
+
|
|
415
|
+
```tsx
|
|
416
|
+
import { Component, configure, createComponent, inject } from 'mobx-mantle';
|
|
417
|
+
import { container } from 'tsyringe';
|
|
418
|
+
|
|
419
|
+
configure({ resolveService: token => container.resolve(token) });
|
|
420
|
+
|
|
421
|
+
class ReportsComponent extends Component {
|
|
422
|
+
preferences = inject(PreferencesService);
|
|
423
|
+
reporting = inject(ReportingAgencyService);
|
|
424
|
+
|
|
425
|
+
render() {
|
|
426
|
+
return <div>{this.reporting.title}</div>;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const Reports = createComponent(ReportsComponent);
|
|
431
|
+
root.render(<Reports />);
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
Class tokens infer the service type. For an interface, create a token with
|
|
435
|
+
`createServiceToken<MyService>('MyService')` and register it with your resolver.
|
|
436
|
+
Mantle works with any compatible resolver; tsyringe is optional.
|
|
437
|
+
|
|
438
|
+
### Choosing between `inject()` and `this.inject()`
|
|
439
|
+
|
|
440
|
+
Use `inject()` when setting up your component or behavior, either in a field
|
|
441
|
+
declaration or in `onCreate`. Usually, you can get the service once and use that
|
|
442
|
+
field whenever you need it:
|
|
443
|
+
|
|
444
|
+
```ts
|
|
445
|
+
preferences = inject(PreferencesService);
|
|
446
|
+
|
|
447
|
+
onClick() {
|
|
448
|
+
this.preferences.save();
|
|
449
|
+
}
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
If you need to get a service later, such as when a button is clicked or after an
|
|
453
|
+
`await`, use `this.inject()` instead:
|
|
454
|
+
|
|
455
|
+
```ts
|
|
456
|
+
onClick() {
|
|
457
|
+
const reporting = this.inject(ReportingAgencyService);
|
|
458
|
+
reporting.open();
|
|
459
|
+
}
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
`this.inject()` also works during setup. Both versions are available to Components
|
|
463
|
+
and Behaviors, including nested behaviors and those used through `useBehavior()`.
|
|
464
|
+
|
|
465
|
+
### How services are shared
|
|
466
|
+
|
|
467
|
+
Your resolver decides whether to create a service or return an existing one.
|
|
468
|
+
Mantle asks it each time you call either version of `inject()`, and returns the
|
|
469
|
+
object it gives back. Caching and cleanup are also up to your resolver.
|
|
470
|
+
|
|
471
|
+
Mantle keeps service fields out of its automatic MobX setup. The service's own
|
|
472
|
+
observable state still works normally. If you use decorators, leave the service
|
|
473
|
+
field undecorated. An injected service that is itself a Behavior won't be mounted
|
|
474
|
+
or unmounted by Mantle. Services must be objects or functions. To provide a simple
|
|
475
|
+
setting such as a string or number, put it in an object.
|
|
476
|
+
|
|
477
|
+
Set up `configure({ resolveService })` before creating components. If one part of
|
|
478
|
+
your app needs different services, wrap it in a `ServiceProvider resolve={resolve}`.
|
|
479
|
+
Components inside that provider use its resolver instead of the default.
|
|
480
|
+
|
|
481
|
+
Each component or behavior remembers the resolver it started with. Changing the
|
|
482
|
+
configuration or provider won't switch existing instances over. Recreate them to
|
|
483
|
+
use the new resolver. If a provider can't supply a service, Mantle reports the
|
|
484
|
+
error rather than trying the application default.
|
|
485
|
+
|
|
486
|
+
You can keep using your existing tsyringe container and its service registrations.
|
|
487
|
+
Mantle's field injection doesn't need extra decorator metadata from the compiler.
|
|
488
|
+
Your tsyringe services still need whatever decorator and metadata setup they
|
|
489
|
+
already use.
|
|
490
|
+
|
|
491
|
+
### Creating a model outside React
|
|
492
|
+
|
|
493
|
+
If you're creating a model or behavior yourself with `new`, use
|
|
494
|
+
`withInjectionScope()` to make the resolver available to imported `inject()`:
|
|
495
|
+
|
|
496
|
+
```ts
|
|
497
|
+
const model = withInjectionScope(resolve, () => new ReportsComponent());
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
Pass `undefined` for `resolve` to use the default from `configure()`. This creates
|
|
501
|
+
the model but doesn't render it or run its mount lifecycle. Keep the setup inside
|
|
502
|
+
the callback synchronous. For code that runs after an `await`, use `this.inject()`.
|
|
503
|
+
|
|
504
|
+
If your model only uses `this.inject()`, you can use `new` directly with the
|
|
456
505
|
configured default, without `withInjectionScope()`.
|
|
457
506
|
|
|
458
|
-
## Testing
|
|
459
|
-
|
|
460
|
-
Use `mobx-mantle/testing` to replace services or an entire model. Create a fresh
|
|
461
|
-
scope per test and register substitutes before rendering.
|
|
462
|
-
|
|
463
|
-
### Real model, fake services
|
|
464
|
-
|
|
465
|
-
```tsx
|
|
466
|
-
import { createTestScope } from 'mobx-mantle/testing';
|
|
467
|
-
|
|
468
|
-
const scope = createTestScope();
|
|
469
|
-
scope.service(ReportingAgencyService, fakeReportingService);
|
|
470
|
-
scope.service(PreferencesService, fakePreferencesService);
|
|
471
|
-
|
|
472
|
-
render(<scope.Provider><Reports /></scope.Provider>);
|
|
473
|
-
```
|
|
474
|
-
|
|
475
|
-
If a service hasn't been registered, you'll get an error, even if you've configured an application resolver.
|
|
476
|
-
To reuse existing container mocks, supply a fallback:
|
|
477
|
-
|
|
478
|
-
```ts
|
|
479
|
-
const scope = createTestScope({
|
|
480
|
-
resolve: token => container.resolve(token),
|
|
481
|
-
});
|
|
482
|
-
```
|
|
483
|
-
|
|
484
|
-
Test-local registrations take precedence. A shared render helper can apply the
|
|
485
|
-
Provider internally so individual tests need no JSX setup.
|
|
486
|
-
|
|
487
|
-
### Only the view
|
|
488
|
-
|
|
489
|
-
To test how a view looks and responds without running the model behind it, give
|
|
490
|
-
it some test values and functions instead.
|
|
491
|
-
|
|
492
|
-
If your view is written separately from the model, the test uses that view. If
|
|
493
|
-
the JSX lives in the model's `render()` method, you'll need to provide JSX for the
|
|
494
|
-
test, as shown below. That tests the JSX you provide, not the original `render()`
|
|
495
|
-
method. To test the original method, keep the real model and mock its services
|
|
496
|
-
instead.
|
|
497
|
-
|
|
498
|
-
```tsx
|
|
499
|
-
const scope = createTestScope();
|
|
500
|
-
scope.model(ReportsComponent, () => ({
|
|
501
|
-
reporting: fakeReportingService,
|
|
502
|
-
}), {
|
|
503
|
-
template: model => <div>{model.reporting.title}</div>,
|
|
504
|
-
});
|
|
505
|
-
|
|
506
|
-
render(<scope.Provider><Reports /></scope.Provider>);
|
|
507
|
-
```
|
|
508
|
-
|
|
509
|
-
### Substitution rules
|
|
510
|
-
|
|
511
|
-
- The state factory runs for each instance and may be retried by React. Return
|
|
512
|
-
fresh mutable state unless sharing is intentional.
|
|
513
|
-
- Fields are observable by reference. Supplied objects retain their identity and
|
|
514
|
-
existing observability; nested plain objects are not made observable. Use
|
|
515
|
-
`runInAction` when changing a mounted substitute's fields.
|
|
516
|
-
- Supply data and callbacks, not lifecycle methods, getters, framework members
|
|
517
|
-
or an original model instance. Missing members stay `undefined`.
|
|
518
|
-
- `findParent(Model)` and `isModel(instance, Model)` recognize substitutes;
|
|
519
|
-
`instanceof Model` does not. Props, rendering, forwarded DOM refs and cleanup
|
|
520
|
-
retain their normal framework behavior.
|
|
521
|
-
- Providers snapshot registrations at mount. Remount to apply later registrations.
|
|
522
|
-
|
|
523
|
-
`ModelProvider` exposes the underlying substitution mechanism for custom test
|
|
524
|
-
helpers. The testing package does not bundle a test runner or DI container.
|
|
525
|
-
|
|
526
|
-
## Decorators
|
|
527
|
-
|
|
528
|
-
Use Mantle's decorators when you prefer to choose which fields are reactive.
|
|
529
|
-
Methods remain automatically bound, and `this.props` is always reactive.
|
|
530
|
-
|
|
531
|
-
```tsx
|
|
532
|
-
import { Component, createComponent, observable, action, computed } from 'mobx-mantle';
|
|
533
|
-
|
|
534
|
-
class Todo extends Component<Props> {
|
|
535
|
-
@observable todos: TodoItem[] = [];
|
|
536
|
-
@observable input = '';
|
|
537
|
-
|
|
538
|
-
@computed get remaining() {
|
|
539
|
-
return this.todos.filter(t => !t.done).length;
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
@action add() {
|
|
543
|
-
this.todos.push({ id: Date.now(), text: this.input, done: false });
|
|
544
|
-
this.input = '';
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
render() {
|
|
548
|
-
return /* ... */;
|
|
549
|
-
}
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
export default createComponent(Todo);
|
|
553
|
-
```
|
|
554
|
-
|
|
555
|
-
### Compiler setup
|
|
556
|
-
|
|
557
|
-
Choose one decorator mode for your project:
|
|
558
|
-
|
|
559
|
-
- **TypeScript legacy:** set `experimentalDecorators: true`. Mantle needs no
|
|
560
|
-
metadata polyfill. Tsyringe services may still require `reflect-metadata` and
|
|
561
|
-
`emitDecoratorMetadata`.
|
|
562
|
-
- **TC39:** omit that flag, include `ESNext.Decorators` or `ESNext` in `lib`, and
|
|
563
|
-
load a `Symbol.metadata` polyfill before decorated modules.
|
|
564
|
-
|
|
565
|
-
### Available Decorators
|
|
566
|
-
|
|
567
|
-
| Decorator | Purpose |
|
|
568
|
-
|-----------|---------|
|
|
569
|
-
| `@observable` | Deep observable field |
|
|
570
|
-
| `@observable.deep` | Explicit deep observable field |
|
|
571
|
-
| `@observable.ref` | Reference-only observation |
|
|
572
|
-
| `@observable.shallow` | Shallow observation (add/remove only) |
|
|
573
|
-
| `@observable.struct` | Structural equality comparison |
|
|
574
|
-
| `@action` | Action method (auto-bound) or arrow-function field |
|
|
575
|
-
| `@computed` | Computed getter |
|
|
576
|
-
|
|
577
|
-
### Supported members and inheritance
|
|
578
|
-
|
|
579
|
-
- Decorate public instance fields, prototype methods and getters in Components
|
|
580
|
-
and Behaviors. Undecorated fields remain nonreactive.
|
|
581
|
-
- Use ordinary fields with `@observable`, without `accessor`. Static/private
|
|
582
|
-
members, parameter decorators and TC39 auto-accessors are not supported by
|
|
583
|
-
Mantle's decorators.
|
|
584
|
-
- Overrides of annotated methods/getters retain their annotation. Repeating it
|
|
585
|
-
is allowed; changing it is rejected. Do not redeclare inherited observable fields.
|
|
586
|
-
- Use one decorator family per model hierarchy. Do not mix Mantle and raw MobX
|
|
587
|
-
annotations on the same instance.
|
|
588
|
-
|
|
589
|
-
### MobX decorators
|
|
590
|
-
|
|
591
|
-
You can instead use MobX's own decorators with auto-observable disabled. MobX's
|
|
592
|
-
TC39 observable fields require `accessor`:
|
|
593
|
-
|
|
594
|
-
```tsx
|
|
595
|
-
import { observable, action } from 'mobx';
|
|
596
|
-
import { Component, configure, createComponent } from 'mobx-mantle';
|
|
597
|
-
|
|
598
|
-
configure({ autoObservable: false });
|
|
599
|
-
|
|
600
|
-
class Todo extends Component<Props> {
|
|
601
|
-
@observable accessor todos: TodoItem[] = [];
|
|
602
|
-
@action add() { /* ... */ }
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
export default createComponent(Todo);
|
|
606
|
-
```
|
|
607
|
-
|
|
608
|
-
## Refs
|
|
609
|
-
|
|
610
|
-
```tsx
|
|
611
|
-
class Form extends Component<Props> {
|
|
612
|
-
inputRef = this.ref<HTMLInputElement>();
|
|
613
|
-
|
|
614
|
-
onMount() {
|
|
615
|
-
this.inputRef.current?.focus();
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
render() {
|
|
619
|
-
return <input ref={this.inputRef} />;
|
|
620
|
-
}
|
|
621
|
-
}
|
|
622
|
-
```
|
|
623
|
-
|
|
624
|
-
### Forwarding Refs
|
|
625
|
-
|
|
626
|
-
Expose a DOM element to parent components via `this.forwardRef`:
|
|
627
|
-
|
|
628
|
-
```tsx
|
|
629
|
-
class FancyInput extends Component<InputProps> {
|
|
630
|
-
render() {
|
|
631
|
-
return <input ref={this.forwardRef} className="fancy-input" />;
|
|
632
|
-
}
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
export default createComponent(FancyInput);
|
|
636
|
-
|
|
637
|
-
// Parent can now get a ref to the underlying input:
|
|
638
|
-
function Parent() {
|
|
639
|
-
const inputRef = useRef<HTMLInputElement>(null);
|
|
640
|
-
|
|
641
|
-
return (
|
|
642
|
-
<>
|
|
643
|
-
<FancyInput ref={inputRef} placeholder="Type here..." />
|
|
644
|
-
<button onClick={() => inputRef.current?.focus()}>Focus</button>
|
|
645
|
-
</>
|
|
646
|
-
);
|
|
647
|
-
}
|
|
648
|
-
```
|
|
649
|
-
|
|
650
|
-
## React Hooks
|
|
651
|
-
|
|
652
|
-
Hooks work inside `render()`:
|
|
653
|
-
|
|
654
|
-
```tsx
|
|
655
|
-
class DataView extends Component<{ id: string }> {
|
|
656
|
-
render() {
|
|
657
|
-
const navigate = useNavigate();
|
|
658
|
-
const { data, isLoading } = useQuery({
|
|
659
|
-
queryKey: ['item', this.props.id],
|
|
660
|
-
queryFn: () => fetchItem(this.props.id),
|
|
661
|
-
});
|
|
662
|
-
|
|
663
|
-
if (isLoading) return <div>Loading...</div>;
|
|
664
|
-
|
|
665
|
-
return (
|
|
666
|
-
<div onClick={() => navigate('/home')}>
|
|
667
|
-
{data.name}
|
|
668
|
-
</div>
|
|
669
|
-
);
|
|
670
|
-
}
|
|
671
|
-
}
|
|
672
|
-
```
|
|
673
|
-
|
|
674
|
-
## Vanilla JS Integration
|
|
675
|
-
|
|
676
|
-
Imperative libraries become straightforward:
|
|
677
|
-
|
|
678
|
-
```tsx
|
|
679
|
-
class Chart extends Component<{ data: number[] }> {
|
|
680
|
-
containerRef = this.ref<HTMLDivElement>();
|
|
681
|
-
chart: Chart | null = null;
|
|
682
|
-
|
|
683
|
-
onMount() {
|
|
684
|
-
this.chart = new Chart(this.containerRef.current!, {
|
|
685
|
-
data: this.props.data,
|
|
686
|
-
});
|
|
687
|
-
|
|
688
|
-
this.watch(
|
|
689
|
-
() => this.props.data,
|
|
690
|
-
(data) => this.chart?.update(data)
|
|
691
|
-
);
|
|
692
|
-
|
|
693
|
-
return () => this.chart?.destroy();
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
render() {
|
|
697
|
-
return <div ref={this.containerRef} />;
|
|
698
|
-
}
|
|
699
|
-
}
|
|
700
|
-
```
|
|
701
|
-
|
|
702
|
-
Compare to hooks:
|
|
703
|
-
|
|
704
|
-
```tsx
|
|
705
|
-
function ChartView({ data }) {
|
|
706
|
-
const containerRef = useRef();
|
|
707
|
-
const chartRef = useRef();
|
|
708
|
-
|
|
709
|
-
useEffect(() => {
|
|
710
|
-
chartRef.current = new Chart(containerRef.current, { data });
|
|
711
|
-
return () => chartRef.current.destroy();
|
|
712
|
-
}, []);
|
|
713
|
-
|
|
714
|
-
useEffect(() => {
|
|
715
|
-
chartRef.current?.update(data);
|
|
716
|
-
}, [data]);
|
|
717
|
-
|
|
718
|
-
return <div ref={containerRef} />;
|
|
719
|
-
}
|
|
720
|
-
```
|
|
721
|
-
|
|
722
|
-
Split effects, multiple refs, dependency tracking: all unnecessary with Mantle.
|
|
723
|
-
|
|
724
|
-
## Side by Side
|
|
725
|
-
|
|
726
|
-
Each example shows the same component in Mantle and React hooks. As components grow, hooks require an increasingly complex web of dependency arrays, memoized callbacks, and mirrored refs that you have to maintain by hand — miss one and you get a silent bug. These comparisons show how Mantle helps to significantly sidesteps that overhead.
|
|
727
|
-
|
|
728
|
-
### Copy to Clipboard
|
|
729
|
-
|
|
730
|
-
A small component with async logic, a reset timer, and cleanup. The kind of thing every project has:
|
|
731
|
-
|
|
732
|
-
```tsx
|
|
733
|
-
class CopyButton extends Component<{ text: string; label?: string }> {
|
|
734
|
-
copied = false;
|
|
735
|
-
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
736
|
-
|
|
737
|
-
async copy() {
|
|
738
|
-
await navigator.clipboard.writeText(this.props.text);
|
|
739
|
-
this.copied = true;
|
|
740
|
-
if (this.timer) clearTimeout(this.timer);
|
|
741
|
-
this.timer = setTimeout(() => (this.copied = false), 2000);
|
|
742
|
-
}
|
|
743
|
-
|
|
744
|
-
onUnmount() {
|
|
745
|
-
if (this.timer) clearTimeout(this.timer);
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
render() {
|
|
749
|
-
return (
|
|
750
|
-
<button onClick={this.copy}>
|
|
751
|
-
{this.copied ? 'Copied!' : (this.props.label ?? 'Copy')}
|
|
752
|
-
</button>
|
|
753
|
-
);
|
|
754
|
-
}
|
|
755
|
-
}
|
|
756
|
-
```
|
|
757
|
-
|
|
758
|
-
Compare to hooks:
|
|
759
|
-
|
|
760
|
-
```tsx
|
|
761
|
-
function CopyButton({ text, label }: { text: string; label?: string }) {
|
|
762
|
-
const [copied, setCopied] = useState(false);
|
|
763
|
-
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
764
|
-
|
|
765
|
-
const copy = useCallback(async () => {
|
|
766
|
-
await navigator.clipboard.writeText(text);
|
|
767
|
-
setCopied(true);
|
|
768
|
-
if (timerRef.current) clearTimeout(timerRef.current);
|
|
769
|
-
timerRef.current = setTimeout(() => setCopied(false), 2000);
|
|
770
|
-
}, [text]); // forget `text` here and you copy stale values
|
|
771
|
-
|
|
772
|
-
useEffect(() => {
|
|
773
|
-
return () => { if (timerRef.current) clearTimeout(timerRef.current); };
|
|
774
|
-
}, []);
|
|
775
|
-
|
|
776
|
-
return (
|
|
777
|
-
<button onClick={copy}>
|
|
778
|
-
{copied ? 'Copied!' : (label ?? 'Copy')}
|
|
779
|
-
</button>
|
|
780
|
-
);
|
|
781
|
-
}
|
|
782
|
-
```
|
|
783
|
-
|
|
784
|
-
The hooks version needs `useState`, `useRef`, `useCallback` with a dependency array, and a separate `useEffect` for cleanup. Miss `text` in the dependency array and you get a stale closure bug. In Mantle, `this.props.text` is always current.
|
|
785
|
-
|
|
786
|
-
### Inline List Editor
|
|
787
|
-
|
|
788
|
-
A component with multiple interaction modes — adding, editing, keyboard shortcuts, and paste handling:
|
|
789
|
-
|
|
790
|
-
```tsx
|
|
791
|
-
class ListEditor extends Component<{ items: string[]; onChange: (items: string[]) => void }> {
|
|
792
|
-
newItem = '';
|
|
793
|
-
editingIndex: number | null = null;
|
|
794
|
-
editValue = '';
|
|
795
|
-
|
|
796
|
-
add() {
|
|
797
|
-
if (!this.newItem.trim()) return;
|
|
798
|
-
this.props.onChange([...this.props.items, this.newItem.trim()]);
|
|
799
|
-
this.newItem = '';
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
remove(index: number) {
|
|
803
|
-
this.props.onChange(this.props.items.filter((_, i) => i !== index));
|
|
804
|
-
}
|
|
805
|
-
|
|
806
|
-
startEdit(index: number) {
|
|
807
|
-
this.editingIndex = index;
|
|
808
|
-
this.editValue = this.props.items[index];
|
|
809
|
-
}
|
|
810
|
-
|
|
811
|
-
saveEdit() {
|
|
812
|
-
if (this.editingIndex === null) return;
|
|
813
|
-
const updated = [...this.props.items];
|
|
814
|
-
updated[this.editingIndex] = this.editValue.trim();
|
|
815
|
-
this.props.onChange(updated);
|
|
816
|
-
this.editingIndex = null;
|
|
817
|
-
}
|
|
818
|
-
|
|
819
|
-
cancelEdit() {
|
|
820
|
-
this.editingIndex = null;
|
|
821
|
-
}
|
|
822
|
-
|
|
823
|
-
handleKeyDown(e: React.KeyboardEvent) {
|
|
824
|
-
if (e.key === 'Enter') this.add();
|
|
825
|
-
}
|
|
826
|
-
|
|
827
|
-
handleEditKeyDown(e: React.KeyboardEvent) {
|
|
828
|
-
if (e.key === 'Enter') this.saveEdit();
|
|
829
|
-
if (e.key === 'Escape') this.cancelEdit();
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
handlePaste(e: React.ClipboardEvent) {
|
|
833
|
-
const lines = e.clipboardData.getData('text').split(/\n/).map(l => l.trim()).filter(Boolean);
|
|
834
|
-
if (lines.length <= 1) return;
|
|
835
|
-
e.preventDefault();
|
|
836
|
-
this.props.onChange([...this.props.items, ...lines]);
|
|
837
|
-
}
|
|
838
|
-
|
|
839
|
-
render() { /* ... */ }
|
|
840
|
-
}
|
|
841
|
-
```
|
|
842
|
-
|
|
843
|
-
Compare to hooks:
|
|
844
|
-
|
|
845
|
-
```tsx
|
|
846
|
-
function ListEditor({ items, onChange }: { items: string[]; onChange: (items: string[]) => void }) {
|
|
847
|
-
const [newItem, setNewItem] = useState('');
|
|
848
|
-
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
|
849
|
-
const [editValue, setEditValue] = useState('');
|
|
850
|
-
|
|
851
|
-
const add = useCallback(() => {
|
|
852
|
-
if (!newItem.trim()) return;
|
|
853
|
-
onChange([...items, newItem.trim()]);
|
|
854
|
-
setNewItem('');
|
|
855
|
-
}, [newItem, items, onChange]);
|
|
856
|
-
|
|
857
|
-
const remove = useCallback((index: number) => {
|
|
858
|
-
onChange(items.filter((_, i) => i !== index));
|
|
859
|
-
}, [items, onChange]);
|
|
860
|
-
|
|
861
|
-
const startEdit = useCallback((index: number) => {
|
|
862
|
-
setEditingIndex(index);
|
|
863
|
-
setEditValue(items[index]);
|
|
864
|
-
}, [items]);
|
|
865
|
-
|
|
866
|
-
const saveEdit = useCallback(() => {
|
|
867
|
-
if (editingIndex === null) return;
|
|
868
|
-
const updated = [...items];
|
|
869
|
-
updated[editingIndex] = editValue.trim();
|
|
870
|
-
onChange(updated);
|
|
871
|
-
setEditingIndex(null);
|
|
872
|
-
}, [editingIndex, editValue, items, onChange]);
|
|
873
|
-
|
|
874
|
-
const cancelEdit = useCallback(() => setEditingIndex(null), []);
|
|
875
|
-
|
|
876
|
-
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
|
877
|
-
if (e.key === 'Enter') add();
|
|
878
|
-
}, [add]);
|
|
879
|
-
|
|
880
|
-
const handleEditKeyDown = useCallback((e: React.KeyboardEvent) => {
|
|
881
|
-
if (e.key === 'Enter') saveEdit();
|
|
882
|
-
if (e.key === 'Escape') cancelEdit();
|
|
883
|
-
}, [saveEdit, cancelEdit]);
|
|
884
|
-
|
|
885
|
-
const handlePaste = useCallback((e: React.ClipboardEvent) => {
|
|
886
|
-
const lines = e.clipboardData.getData('text').split(/\n/).map(l => l.trim()).filter(Boolean);
|
|
887
|
-
if (lines.length <= 1) return;
|
|
888
|
-
e.preventDefault();
|
|
889
|
-
onChange([...items, ...lines]);
|
|
890
|
-
}, [items, onChange]);
|
|
891
|
-
|
|
892
|
-
return /* ... */;
|
|
893
|
-
}
|
|
894
|
-
```
|
|
895
|
-
|
|
896
|
-
3 `useState`, 7 `useCallback` with dependency chains where each callback depends on others. `handleKeyDown` depends on `add`, which depends on `newItem`, `items`, and `onChange`. Miss one and you get stale state. The Mantle version is just methods calling `this` — no dependency graph to maintain.
|
|
897
|
-
|
|
898
|
-
## Error Handling
|
|
899
|
-
|
|
900
|
-
Render errors propagate to React error boundaries as usual. Lifecycle errors (`onLayoutMount`, `onMount`, `onUpdate`, `onUnmount`, `watch`) in both Components and Behaviors are caught and routed through a configurable handler.
|
|
901
|
-
|
|
902
|
-
By default, errors are logged to `console.error`. Configure a global handler to integrate with your error reporting:
|
|
903
|
-
|
|
904
|
-
```tsx
|
|
905
|
-
import { configure } from 'mobx-mantle';
|
|
906
|
-
|
|
907
|
-
configure({
|
|
908
|
-
onError: (error, context) => {
|
|
909
|
-
// context.phase: 'onLayoutMount' | 'onMount' | 'onUpdate' | 'onUnmount' | 'watch'
|
|
910
|
-
// context.name: class name of the Component or Behavior
|
|
911
|
-
// context.isBehavior: true if the error came from a Behavior
|
|
912
|
-
Sentry.captureException(error, {
|
|
913
|
-
tags: { phase: context.phase, component: context.name },
|
|
914
|
-
});
|
|
915
|
-
},
|
|
916
|
-
});
|
|
917
|
-
```
|
|
918
|
-
|
|
919
|
-
Behavior errors are isolated. A failing Behavior won't prevent sibling Behaviors or the parent Component from mounting.
|
|
920
|
-
|
|
921
|
-
## MobX Action Enforcement
|
|
922
|
-
|
|
923
|
-
Mantle preserves the host application's MobX action policy by default (`manageMobxActions: false`). Importing or constructing a Mantle model does not change it. MobX can still warn about changes made outside actions during development. These warnings don't stop your code.
|
|
924
|
-
|
|
925
|
-
Synchronous model methods and lifecycle callbacks batch their writes as actions. Render helpers retain tracked reads. Watch callbacks are reaction actions; effect bodies stay tracked and should wrap their writes explicitly. An async method leaves its action when it reaches an `await`:
|
|
926
|
-
|
|
927
|
-
```tsx
|
|
928
|
-
import { runInAction } from 'mobx';
|
|
929
|
-
async load() {
|
|
930
|
-
const result = await fetchReports();
|
|
931
|
-
runInAction(() => { this.reports = result; });
|
|
932
|
-
}
|
|
933
|
-
```
|
|
934
|
-
|
|
935
|
-
The built-in behaviors handle their own async/timer writes. To deliberately keep the permissive pre-0.6 behavior, configure it **before the first component or behavior is created**, including module-level behavior construction:
|
|
936
|
-
|
|
937
|
-
```tsx
|
|
938
|
-
import { configure } from 'mobx-mantle';
|
|
939
|
-
configure({ manageMobxActions: true });
|
|
940
|
-
```
|
|
941
|
-
|
|
942
|
-
This opt-in sets `enforceActions: 'never'` globally for that MobX instance. Providers cannot scope this policy. Setting the default to false does not undo configuration another library has already applied.
|
|
943
|
-
|
|
944
|
-
## Behaviors
|
|
945
|
-
|
|
946
|
-
Behaviors are a supported feature for reusable React/MobX state and logic. The core API covers creation, nesting, lifecycle cleanup, reactive arguments, and hosting through Components or `useBehavior()`. MobX Mantle remains pre-1.0; supported means this is an API intended for normal application use and ongoing maintenance, not a promise that no future release can change it.
|
|
947
|
-
|
|
948
|
-
Behaviors are reusable pieces of state and logic that can be shared across components. Define them as classes, wrap with `createBehavior()`, and use the resulting factory function in your Components.
|
|
949
|
-
|
|
950
|
-
### Defining a Behavior
|
|
951
|
-
|
|
952
|
-
```tsx
|
|
953
|
-
import { Behavior, createBehavior } from 'mobx-mantle';
|
|
954
|
-
|
|
955
|
-
class WindowSizeBehavior extends Behavior {
|
|
956
|
-
width = window.innerWidth;
|
|
957
|
-
height = window.innerHeight;
|
|
958
|
-
breakpoint!: number;
|
|
959
|
-
|
|
960
|
-
onCreate(breakpoint = 768) {
|
|
961
|
-
this.breakpoint = breakpoint;
|
|
962
|
-
}
|
|
963
|
-
|
|
964
|
-
get isMobile() {
|
|
965
|
-
return this.width < this.breakpoint;
|
|
966
|
-
}
|
|
967
|
-
|
|
968
|
-
handleResize() {
|
|
969
|
-
this.width = window.innerWidth;
|
|
970
|
-
this.height = window.innerHeight;
|
|
971
|
-
}
|
|
972
|
-
|
|
973
|
-
onMount() {
|
|
974
|
-
window.addEventListener('resize', this.handleResize);
|
|
975
|
-
return () => window.removeEventListener('resize', this.handleResize);
|
|
976
|
-
}
|
|
977
|
-
}
|
|
978
|
-
|
|
979
|
-
export const withWindowSize = createBehavior(WindowSizeBehavior);
|
|
980
|
-
```
|
|
981
|
-
|
|
982
|
-
The naming convention:
|
|
983
|
-
- **Class**: PascalCase (`WindowSizeBehavior`)
|
|
984
|
-
- **Factory**: camelCase with `with` prefix (`withWindowSize`)
|
|
985
|
-
|
|
986
|
-
Behavior methods have the same tracking-aware binding as Component methods. A
|
|
987
|
-
read helper called from a render or computed remains inside that derivation:
|
|
988
|
-
|
|
989
|
-
```tsx
|
|
990
|
-
class BreakpointBehavior extends Behavior {
|
|
991
|
-
width = 0;
|
|
992
|
-
below(px: number) { return this.width < px; }
|
|
993
|
-
}
|
|
994
|
-
```
|
|
995
|
-
|
|
996
|
-
`this.size.below(640)` therefore subscribes its caller to `width`; passing a
|
|
997
|
-
mutating method as an event handler still keeps it bound and batches its writes.
|
|
998
|
-
|
|
999
|
-
### Using Behaviors
|
|
1000
|
-
|
|
1001
|
-
Call the factory function (no `new` keyword) in your Component. The `with` prefix signals that the Component manages this behavior's lifecycle:
|
|
1002
|
-
|
|
1003
|
-
```tsx
|
|
1004
|
-
import { withWindowSize } from './withWindowSize';
|
|
1005
|
-
|
|
1006
|
-
class Responsive extends Component<Props> {
|
|
1007
|
-
windowSize = withWindowSize(768);
|
|
1008
|
-
|
|
1009
|
-
render() {
|
|
1010
|
-
return (
|
|
1011
|
-
<div>
|
|
1012
|
-
{this.windowSize.isMobile ? <MobileLayout /> : <DesktopLayout />}
|
|
1013
|
-
<p>Window: {this.windowSize.width}x{this.windowSize.height}</p>
|
|
1014
|
-
</div>
|
|
1015
|
-
);
|
|
1016
|
-
}
|
|
1017
|
-
}
|
|
1018
|
-
|
|
1019
|
-
export default createComponent(Responsive);
|
|
1020
|
-
```
|
|
1021
|
-
|
|
1022
|
-
### Watching in Behaviors
|
|
1023
|
-
|
|
1024
|
-
Behaviors can use `this.watch` just like Components:
|
|
1025
|
-
|
|
1026
|
-
```tsx
|
|
1027
|
-
class FetchBehavior extends Behavior {
|
|
1028
|
-
url!: string;
|
|
1029
|
-
data: any[] = [];
|
|
1030
|
-
loading = false;
|
|
1031
|
-
|
|
1032
|
-
onCreate(url: string) {
|
|
1033
|
-
this.url = url;
|
|
1034
|
-
}
|
|
1035
|
-
|
|
1036
|
-
onMount() {
|
|
1037
|
-
this.watch(() => this.url, () => this.fetchData(), { fireImmediately: true });
|
|
1038
|
-
}
|
|
1039
|
-
|
|
1040
|
-
async fetchData() {
|
|
1041
|
-
this.loading = true;
|
|
1042
|
-
this.data = await fetch(this.url).then(r => r.json());
|
|
1043
|
-
this.loading = false;
|
|
1044
|
-
}
|
|
1045
|
-
}
|
|
1046
|
-
|
|
1047
|
-
export const withFetch = createBehavior(FetchBehavior);
|
|
1048
|
-
```
|
|
1049
|
-
|
|
1050
|
-
### Multiple Behaviors
|
|
1051
|
-
|
|
1052
|
-
Behaviors compose naturally:
|
|
1053
|
-
|
|
1054
|
-
```tsx
|
|
1055
|
-
// FetchBehavior.ts
|
|
1056
|
-
import { Behavior, createBehavior } from 'mobx-mantle';
|
|
1057
|
-
|
|
1058
|
-
class FetchBehavior extends Behavior {
|
|
1059
|
-
url!: string;
|
|
1060
|
-
interval = 5000;
|
|
1061
|
-
data: Item[] = [];
|
|
1062
|
-
loading = false;
|
|
1063
|
-
|
|
1064
|
-
onCreate(url: string, interval = 5000) {
|
|
1065
|
-
this.url = url;
|
|
1066
|
-
this.interval = interval;
|
|
1067
|
-
}
|
|
1068
|
-
|
|
1069
|
-
onMount() {
|
|
1070
|
-
this.fetchData();
|
|
1071
|
-
const id = setInterval(() => this.fetchData(), this.interval);
|
|
1072
|
-
return () => clearInterval(id);
|
|
1073
|
-
}
|
|
1074
|
-
|
|
1075
|
-
async fetchData() {
|
|
1076
|
-
this.loading = true;
|
|
1077
|
-
this.data = await fetch(this.url).then(r => r.json());
|
|
1078
|
-
this.loading = false;
|
|
1079
|
-
}
|
|
1080
|
-
}
|
|
1081
|
-
|
|
1082
|
-
export const withFetch = createBehavior(FetchBehavior);
|
|
1083
|
-
```
|
|
1084
|
-
|
|
1085
|
-
```tsx
|
|
1086
|
-
import { Component, createComponent } from 'mobx-mantle';
|
|
1087
|
-
import { withFetch } from './FetchBehavior';
|
|
1088
|
-
import { withWindowSize } from './WindowSizeBehavior';
|
|
1089
|
-
|
|
1090
|
-
class Dashboard extends Component<Props> {
|
|
1091
|
-
users = withFetch('/api/users', 10000);
|
|
1092
|
-
posts = withFetch('/api/posts');
|
|
1093
|
-
windowSize = withWindowSize(768);
|
|
1094
|
-
|
|
1095
|
-
render() {
|
|
1096
|
-
return (
|
|
1097
|
-
<div>
|
|
1098
|
-
{this.users.loading ? 'Loading...' : `${this.users.data.length} users`}
|
|
1099
|
-
{this.windowSize.isMobile && <MobileNav />}
|
|
1100
|
-
</div>
|
|
1101
|
-
);
|
|
1102
|
-
}
|
|
1103
|
-
}
|
|
1104
|
-
|
|
1105
|
-
export default createComponent(Dashboard);
|
|
1106
|
-
```
|
|
1107
|
-
|
|
1108
|
-
### Behavior Lifecycle
|
|
1109
|
-
|
|
1110
|
-
Behaviors support the same lifecycle methods as Components:
|
|
1111
|
-
|
|
1112
|
-
| Method | When |
|
|
1113
|
-
|--------|------|
|
|
1114
|
-
| `onCreate(...args)` | Called during construction with the factory arguments |
|
|
1115
|
-
| `onLayoutMount()` | Called when parent Component layout mounts (before paint). Return cleanup (optional). |
|
|
1116
|
-
| `onMount()` | Called when parent Component mounts (after paint). Return cleanup (optional). |
|
|
1117
|
-
| `onUnmount()` | Called when parent Component unmounts, after cleanups (optional). |
|
|
1118
|
-
|
|
1119
|
-
React StrictMode replays the mount lifecycle with the same model and refs in
|
|
1120
|
-
development. Pair setup and teardown in the same lifecycle method so the
|
|
1121
|
-
second setup always reacquires what the first cleanup released. A real removal
|
|
1122
|
-
from the React tree destroys the model; rendering it later creates a new one.
|
|
1123
|
-
|
|
1124
|
-
### Reactive Arguments
|
|
1125
|
-
|
|
1126
|
-
A behavior's setup runs once, so a plain argument is a snapshot — frozen at construction. To pass a *live* value, pass a getter:
|
|
1127
|
-
|
|
1128
|
-
```tsx
|
|
1129
|
-
class Dashboard extends Component<Props> {
|
|
1130
|
-
frozen = withFetch('/api/users'); // frozen at construction
|
|
1131
|
-
live = withFetch(() => this.props.url); // refetches when the prop changes
|
|
1132
|
-
}
|
|
1133
|
-
```
|
|
1134
|
-
|
|
1135
|
-
**Consumer rule of thumb:** not sure which to pass? Pass the arrow — it's never wrong, only occasionally redundant. Reserve plain values for things you intend to freeze. (Parameters that don't accept getters are typed plain, so TypeScript tells you when the question even exists.)
|
|
1136
|
-
|
|
1137
|
-
**Observable objects need none of this.** Reactivity lives in property reads, so an observable *object* (a store, another behavior) passes by reference and stays fully live — `withSync(this.settingsStore)` needs no arrow. The getter is only for primitives and computed expressions, which leave the reactive graph the moment they're evaluated. One footgun: the reference is captured once, so it survives *mutation* (`store.theme = 'dark'` ✅) but not *reassignment* of the field that held it (`this.settingsStore = newStore` ❌ — the behavior still holds the old object). If you reassign, pass a getter.
|
|
1138
|
-
|
|
1139
|
-
**Authoring a behavior with live arguments:** `this.sync()` is an optional convenience shortcut for using `this.effect()` to keep a field synchronized with an input. It handles field initialization and one-way mirroring for you; use `effect()` directly when you need custom synchronization logic. Using `effect()`:
|
|
1140
|
-
|
|
1141
|
-
```tsx
|
|
1142
|
-
class SearchBehavior extends Behavior {
|
|
1143
|
-
query = '';
|
|
1144
|
-
|
|
1145
|
-
onCreate(getQuery: () => string) {
|
|
1146
|
-
this.effect(() => {
|
|
1147
|
-
this.query = getQuery();
|
|
1148
|
-
});
|
|
1149
|
-
}
|
|
1150
|
-
}
|
|
1151
|
-
```
|
|
1152
|
-
|
|
1153
|
-
Using the `sync()` shortcut:
|
|
1154
|
-
|
|
1155
|
-
```tsx
|
|
1156
|
-
class SearchBehavior extends Behavior {
|
|
1157
|
-
query = '';
|
|
1158
|
-
|
|
1159
|
-
onCreate(getQuery: () => string) {
|
|
1160
|
-
this.query = this.sync(getQuery);
|
|
1161
|
-
}
|
|
1162
|
-
}
|
|
1163
|
-
```
|
|
1164
|
-
|
|
1165
|
-
Both keep `query` following the getter. The effect first runs at mount;
|
|
1166
|
-
`sync()` also initializes the field after `onCreate()` completes, before the
|
|
1167
|
-
first render, then keeps it updated from mount onward.
|
|
1168
|
-
|
|
1169
|
-
Type the input `MaybeGetter<T>` to accept either a fixed value or a live getter:
|
|
1170
|
-
|
|
1171
|
-
```tsx
|
|
1172
|
-
import { Behavior, createBehavior, type MaybeGetter } from 'mobx-mantle';
|
|
1173
|
-
|
|
1174
|
-
class FetchBehavior extends Behavior {
|
|
1175
|
-
url = '';
|
|
1176
|
-
|
|
1177
|
-
onCreate(url: MaybeGetter<string>) {
|
|
1178
|
-
this.url = this.sync(url); // one-way mirror: argument → field
|
|
1179
|
-
this.watch(() => this.url, (u) => this.fetchData(u), { fireImmediately: true });
|
|
1180
|
-
}
|
|
1181
|
-
|
|
1182
|
-
refresh() {
|
|
1183
|
-
this.fetchData(this.url); // plain read — always current
|
|
1184
|
-
}
|
|
1185
|
-
}
|
|
1186
|
-
export const withFetch = createBehavior(FetchBehavior);
|
|
1187
|
-
```
|
|
1188
|
-
|
|
1189
|
-
A plain value just sits in the field; a getter drives a hidden effect that keeps the field updated (alive from mount, like `watch`/`effect`, StrictMode-safe). The sync is **one-way** — don't write to the field from outside; the next source change would overwrite it, and Mantle warns in development if you do.
|
|
1190
|
-
|
|
1191
|
-
When passing a getter, assign `this.sync()` to a behavior field during initialization. The field receives its initial value after `onCreate()` completes. Use `toValue(argument)` if you need the input value immediately inside `onCreate()`.
|
|
1192
|
-
|
|
1193
|
-
Two lighter alternatives when a mirror field is more than you need:
|
|
1194
|
-
|
|
1195
|
-
- `this.watch(source, cb)` — a `MaybeGetter` is a valid watch source directly (a plain value dev-warns unless `fireImmediately`, since a constant watch can never fire).
|
|
1196
|
-
- `toValue(arg)` — unwrap in place: returns `arg()` if it's a getter, `arg` otherwise. For one-off reads and `effect` bodies, where calling the getter doubles as dependency tracking.
|
|
1197
|
-
|
|
1198
|
-
This is the same convention Vue (`MaybeRefOrGetter` + `toValue`) and Solid (`MaybeAccessor` + `access`) converged on. One caveat: an argument that is legitimately a function (a callback, not a getter) can't also be a `MaybeGetter` — the two are indistinguishable at runtime, so choose per parameter which ones are reactive.
|
|
1199
|
-
|
|
1200
|
-
#### Config-Object Behaviors
|
|
1201
|
-
|
|
1202
|
-
Positional arguments are fine for one or two parameters. When a behavior takes more — or its signature will grow — take a single config object typed `MaybeGetter<Options>`. One arrow at the call site makes the whole bag live, and callbacks inside are safe (they're read, never called):
|
|
1203
|
-
|
|
1204
|
-
```tsx
|
|
1205
|
-
interface SearchOptions {
|
|
1206
|
-
query: string;
|
|
1207
|
-
limit?: number;
|
|
1208
|
-
onResult?: (items: string[]) => void;
|
|
1209
|
-
}
|
|
1210
|
-
|
|
1211
|
-
class SearchBehavior extends Behavior {
|
|
1212
|
-
opts!: SearchOptions;
|
|
1213
|
-
|
|
1214
|
-
onCreate(opts: MaybeGetter<SearchOptions>) {
|
|
1215
|
-
this.opts = this.sync(opts); // same one line as any other live argument
|
|
1216
|
-
this.watch(() => this.opts.query, (q) => this.run(q));
|
|
1217
|
-
}
|
|
1218
|
-
}
|
|
1219
|
-
export const withSearch = createBehavior(SearchBehavior);
|
|
1220
|
-
```
|
|
1221
|
-
|
|
1222
|
-
```tsx
|
|
1223
|
-
class Page extends Component<Props> {
|
|
1224
|
-
search = withSearch(() => ({ query: this.query, limit: 20 })); // live bag — one arrow
|
|
1225
|
-
fixed = withSearch({ query: 'docs' }); // deliberately frozen
|
|
1226
|
-
}
|
|
1227
|
-
```
|
|
1228
|
-
|
|
1229
|
-
The bag object is re-created when its sources change, but watchers on individual keys (`() => this.opts.query`) still fire only when that key's value actually changes — MobX's value comparison absorbs the churn.
|
|
1230
|
-
|
|
1231
|
-
### Nesting Behaviors
|
|
1232
|
-
|
|
1233
|
-
Behaviors compose recursively: a behavior declared as a field of another behavior (or assigned in its `onCreate`) receives the full lifecycle relay. Children mount before their parent's `onMount` — so a parent can rely on live children — and tear down after their parent's `onUnmount`, in reverse order.
|
|
1234
|
-
|
|
1235
|
-
```tsx
|
|
1236
|
-
class AutosaveBehavior extends Behavior {
|
|
1237
|
-
saver = withAsync((url, data) => postJson(url, data)); // child: request state machine
|
|
1238
|
-
timer!: IntervalBehavior;
|
|
1239
|
-
|
|
1240
|
-
onCreate(url: MaybeGetter<string>, data: () => unknown, interval = 5000) {
|
|
1241
|
-
this.timer = withInterval(() => this.save(), interval); // child: scheduling
|
|
1242
|
-
}
|
|
1243
|
-
|
|
1244
|
-
save() { /* diff + this.saver.run(...) */ }
|
|
1245
|
-
}
|
|
1246
|
-
export const withAutosave = createBehavior(AutosaveBehavior);
|
|
1247
|
-
```
|
|
1248
|
-
|
|
1249
|
-
Two rules, both consistent with how Components collect behaviors:
|
|
1250
|
-
|
|
1251
|
-
- **Underscore-prefixed fields are invisible** to collection — `_helper = withThing()` is not relayed.
|
|
1252
|
-
- **Late assignment isn't collected.** Components collect behaviors at construction; a behavior assigned to a Component field later (conditionally, lazily, or in the Component's `onCreate`) silently never mounts. Mantle warns in development when it finds one. (Inside a *behavior*, `onCreate` assignment is fine — behaviors scan after `onCreate` runs.)
|
|
1253
|
-
|
|
1254
|
-
### Behaviors in Plain Function Components
|
|
1255
|
-
|
|
1256
|
-
You don't have to adopt Mantle components to use behaviors. `useBehavior()` hosts any behavior inside an ordinary function component, with the same lifecycle relay (StrictMode-safe, watchers alive from commit, full teardown on unmount). Wrap the component in `observer()` so renders track the behavior's observables:
|
|
1257
|
-
|
|
1258
|
-
```tsx
|
|
1259
|
-
import { useBehavior, observer } from 'mobx-mantle';
|
|
1260
|
-
import { withWindowSize } from 'mobx-mantle/behaviors';
|
|
1261
|
-
|
|
1262
|
-
const Toolbar = observer(() => {
|
|
1263
|
-
const size = useBehavior(() => withWindowSize());
|
|
1264
|
-
return <div>{size.width} × {size.height}</div>;
|
|
1265
|
-
});
|
|
1266
|
-
```
|
|
1267
|
-
|
|
1268
|
-
The factory runs once per mount lifetime. Because of that, getter arguments close over the first render — getters reading observables stay live, but getters reading the function component's own props go stale. Pass observable sources instead.
|
|
1269
|
-
|
|
1270
|
-
This inverts the adoption story: logic written as a behavior runs in both worlds, and Mantle Components are simply the nicer host.
|
|
1271
|
-
|
|
1272
|
-
### Built-in Behaviors
|
|
1273
|
-
|
|
1274
|
-
`mobx-mantle/behaviors` ships a standard library of small behaviors, all with `MaybeGetter` arguments:
|
|
1275
|
-
|
|
1276
|
-
`withEventListener`, `withInterval`, `withTimeout`, `withAsync`, `withFetch`, `withLocalStorage`, `withElementSize`, `withWindowSize`, `withMediaQuery`, `withDebounce`, `withThrottle`, `withDocumentTitle`, `withPageVisibility`, `withAutosave`.
|
|
1277
|
-
|
|
1278
|
-
Several are themselves compositions — `withFetch` nests `withAsync`; `withWindowSize`, `withLocalStorage`, and `withPageVisibility` nest `withEventListener`; `withAutosave` nests `withInterval` + `withAsync` — the same nesting available to your own behaviors.
|
|
1279
|
-
|
|
1280
|
-
```tsx
|
|
1281
|
-
import { withFetch, withMediaQuery } from 'mobx-mantle/behaviors';
|
|
1282
|
-
|
|
1283
|
-
class Dashboard extends Component<Props> {
|
|
1284
|
-
users = withFetch<User[]>(() => `/api/users?team=${this.props.teamId}`);
|
|
1285
|
-
compact = withMediaQuery('(max-width: 768px)');
|
|
1286
|
-
|
|
1287
|
-
render() {
|
|
1288
|
-
if (this.users.loading) return <Spinner />;
|
|
1289
|
-
return <UserList users={this.users.data} compact={this.compact.matches} />;
|
|
1290
|
-
}
|
|
1291
|
-
}
|
|
1292
|
-
```
|
|
1293
|
-
|
|
1294
|
-
Element measurement uses the same ref objects as Components and owns its
|
|
1295
|
-
`ResizeObserver` through the layout lifecycle, including StrictMode replay.
|
|
1296
|
-
It reports the element's layout-space border box, so CSS transforms on the
|
|
1297
|
-
element or an ancestor do not change component
|
|
1298
|
-
breakpoints:
|
|
1299
|
-
|
|
1300
|
-
```tsx
|
|
1301
|
-
import { withElementSize } from 'mobx-mantle/behaviors';
|
|
1302
|
-
|
|
1303
|
-
class ResponsivePanel extends Component {
|
|
1304
|
-
root = this.ref<HTMLDivElement>();
|
|
1305
|
-
size = withElementSize(this.root);
|
|
1306
|
-
|
|
1307
|
-
get compact() {
|
|
1308
|
-
return this.size.width !== null && this.size.width < 640;
|
|
1309
|
-
}
|
|
1310
|
-
|
|
1311
|
-
render() {
|
|
1312
|
-
return <div ref={this.root}>{this.compact ? <Compact /> : <Wide />}</div>;
|
|
1313
|
-
}
|
|
1314
|
-
}
|
|
1315
|
-
```
|
|
1316
|
-
|
|
1317
|
-
### What Behaviors Are Not For
|
|
1318
|
-
|
|
1319
|
-
- **Behaviors cannot call hooks.** `useContext`, `useQuery`, and friends are render-scoped; behaviors live outside the render cycle. The blessed pattern: read the hook in `render()` and pass the value where it's needed.
|
|
1320
|
-
- **Division of labor:** behaviors own vanilla-JS integration, MobX-native logic, and cheaply-testable state machines; hooks own React-ecosystem bindings; `render()` is where the two meet.
|
|
1321
|
-
|
|
1322
|
-
## Shared Stores
|
|
1323
|
-
|
|
1324
|
-
A store that several windows must agree on — a theme, a navigation preference, anything a
|
|
1325
|
-
design tool's hosted frames or a second tab should follow — is shared once, at the entry point:
|
|
1326
|
-
|
|
1327
|
-
```ts
|
|
1328
|
-
import { shareStores } from 'mobx-mantle';
|
|
1329
|
-
import { themeStore } from './themeStore';
|
|
1330
|
-
import { navPreferences } from './navPreferences';
|
|
1331
|
-
|
|
1332
|
-
shareStores({ themeStore, navPreferences });
|
|
1333
|
-
```
|
|
1334
|
-
|
|
1335
|
-
Every window that imports that module wires the same stores. Each window keeps its own
|
|
1336
|
-
instance; values are mirrored, identity is not shared, and nothing goes through a server or a
|
|
1337
|
-
parent document. The mechanics are the web's own answer for two windows, packaged: the store's
|
|
1338
|
-
observable fields are restored from `localStorage` at startup, published through a reaction to
|
|
1339
|
-
`localStorage` and a `BroadcastChannel`, and incoming changes from the channel or the `storage`
|
|
1340
|
-
event are applied inside an action. Echo loops are bounded because a window publishes only when
|
|
1341
|
-
its serialized state changed and applies only what differs from it.
|
|
1342
|
-
|
|
1343
|
-
`shared(store, key, options)` shares one store under an explicit key; `options.fields` limits
|
|
1344
|
-
the shared fields (default: the store's own observable data fields, not getters or methods) and
|
|
1345
|
-
`options.persist: false` skips storage. `unshare(store)` stops the sync.
|
|
1346
|
-
|
|
1347
|
-
**The one authoring rule: effects derive from state.** A change that arrives from another window
|
|
1348
|
-
sets fields directly, so anything the store does to the world — writing custom properties onto
|
|
1349
|
-
`document.documentElement`, updating a title — must be a `reaction`, an `autorun`, or a
|
|
1350
|
-
component's `watch`/`effect`, never a call inside a setter:
|
|
1351
|
-
|
|
1352
|
-
```ts
|
|
1353
|
-
class ThemeStore {
|
|
1354
|
-
theme = 'light';
|
|
1355
|
-
constructor() {
|
|
1356
|
-
makeAutoObservable(this);
|
|
1357
|
-
autorun(() => document.documentElement.dataset.theme = this.theme); // repaints on any window's change
|
|
1358
|
-
}
|
|
1359
|
-
setTheme(theme: string) {
|
|
1360
|
-
this.theme = theme; // no apply() here
|
|
1361
|
-
}
|
|
1362
|
-
}
|
|
1363
|
-
```
|
|
1364
|
-
|
|
1365
|
-
## API
|
|
1366
|
-
|
|
1367
|
-
### `configure(config)`
|
|
1368
|
-
|
|
1369
|
-
Set global defaults for all components. Settings can still be overridden per-component in `createComponent` options.
|
|
1370
|
-
|
|
1371
|
-
```tsx
|
|
1372
|
-
import { configure } from 'mobx-mantle';
|
|
1373
|
-
|
|
1374
|
-
// Disable auto-observable globally (for decorator users)
|
|
1375
|
-
configure({ autoObservable: false });
|
|
1376
|
-
```
|
|
1377
|
-
|
|
1378
|
-
| Option | Default | Description |
|
|
1379
|
-
|--------|---------|-------------|
|
|
1380
|
-
| `autoObservable` | `true` | Whether to automatically make Component instances observable |
|
|
1381
|
-
| `cacheAnnotations` | `true` | Cache per-class annotation data (getters, methods) so repeated instantiations skip the prototype walk. Turn off only if class prototypes are mutated between instantiations. |
|
|
1382
|
-
| `onError` | `console.error` | Global error handler for lifecycle errors (see [Error Handling](#error-handling)) |
|
|
1383
|
-
| `manageMobxActions` | `false` | Preserve the host policy. Set true before first construction to opt into globally permissive actions (see [MobX Action Enforcement](#mobx-action-enforcement)). |
|
|
1384
|
-
|
|
1385
|
-
### `Component<P>` / `ViewModel<P>`
|
|
1386
|
-
|
|
1387
|
-
Base class for components. `ViewModel` is an alias for `Component`. Use it when separating the ViewModel from the template for semantic clarity.
|
|
1388
|
-
|
|
1389
|
-
| Property/Method | Description |
|
|
1390
|
-
|-----------------|-------------|
|
|
1391
|
-
| `props` | Current props (reactive, available in field initializers) |
|
|
1392
|
-
| `forwardRef` | Ref passed from parent component (for ref forwarding) |
|
|
1393
|
-
| `getParent()` | Immediate ancestor in the live Mantle view tree, or `undefined` at the root |
|
|
1394
|
-
| `findParent(Type)` | Nearest parent matching a Mantle component/model class |
|
|
1395
|
-
| `getParents()` | Readonly array of all parents, ordered from the immediate parent toward the root |
|
|
1396
|
-
| `getRoot()` | Highest Mantle ancestor, or the current instance when already at the root |
|
|
1397
|
-
| `onCreate()` | Called when instance is created, before first render |
|
|
1398
|
-
| `onLayoutMount()` | Called before paint, return cleanup (optional) |
|
|
1399
|
-
| `onMount()` | Called after paint, return cleanup (optional) |
|
|
1400
|
-
| `onUpdate()` | Called after every render |
|
|
1401
|
-
| `onUnmount()` | Called on unmount, after cleanups (optional) |
|
|
1402
|
-
| `render()` | Return JSX (optional if using template) |
|
|
1403
|
-
| `ref<T>()` | Create a ref for DOM elements |
|
|
1404
|
-
| `
|
|
1405
|
-
| `
|
|
1406
|
-
| `
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
const
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
| `
|
|
1435
|
-
| `
|
|
1436
|
-
| `
|
|
1437
|
-
| `
|
|
1438
|
-
| `
|
|
1439
|
-
| `
|
|
1440
|
-
| `
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
}
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
507
|
+
## Testing
|
|
508
|
+
|
|
509
|
+
Use `mobx-mantle/testing` to replace services or an entire model. Create a fresh
|
|
510
|
+
scope per test and register substitutes before rendering.
|
|
511
|
+
|
|
512
|
+
### Real model, fake services
|
|
513
|
+
|
|
514
|
+
```tsx
|
|
515
|
+
import { createTestScope } from 'mobx-mantle/testing';
|
|
516
|
+
|
|
517
|
+
const scope = createTestScope();
|
|
518
|
+
scope.service(ReportingAgencyService, fakeReportingService);
|
|
519
|
+
scope.service(PreferencesService, fakePreferencesService);
|
|
520
|
+
|
|
521
|
+
render(<scope.Provider><Reports /></scope.Provider>);
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
If a service hasn't been registered, you'll get an error, even if you've configured an application resolver.
|
|
525
|
+
To reuse existing container mocks, supply a fallback:
|
|
526
|
+
|
|
527
|
+
```ts
|
|
528
|
+
const scope = createTestScope({
|
|
529
|
+
resolve: token => container.resolve(token),
|
|
530
|
+
});
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
Test-local registrations take precedence. A shared render helper can apply the
|
|
534
|
+
Provider internally so individual tests need no JSX setup.
|
|
535
|
+
|
|
536
|
+
### Only the view
|
|
537
|
+
|
|
538
|
+
To test how a view looks and responds without running the model behind it, give
|
|
539
|
+
it some test values and functions instead.
|
|
540
|
+
|
|
541
|
+
If your view is written separately from the model, the test uses that view. If
|
|
542
|
+
the JSX lives in the model's `render()` method, you'll need to provide JSX for the
|
|
543
|
+
test, as shown below. That tests the JSX you provide, not the original `render()`
|
|
544
|
+
method. To test the original method, keep the real model and mock its services
|
|
545
|
+
instead.
|
|
546
|
+
|
|
547
|
+
```tsx
|
|
548
|
+
const scope = createTestScope();
|
|
549
|
+
scope.model(ReportsComponent, () => ({
|
|
550
|
+
reporting: fakeReportingService,
|
|
551
|
+
}), {
|
|
552
|
+
template: model => <div>{model.reporting.title}</div>,
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
render(<scope.Provider><Reports /></scope.Provider>);
|
|
556
|
+
```
|
|
557
|
+
|
|
558
|
+
### Substitution rules
|
|
559
|
+
|
|
560
|
+
- The state factory runs for each instance and may be retried by React. Return
|
|
561
|
+
fresh mutable state unless sharing is intentional.
|
|
562
|
+
- Fields are observable by reference. Supplied objects retain their identity and
|
|
563
|
+
existing observability; nested plain objects are not made observable. Use
|
|
564
|
+
`runInAction` when changing a mounted substitute's fields.
|
|
565
|
+
- Supply data and callbacks, not lifecycle methods, getters, framework members
|
|
566
|
+
or an original model instance. Missing members stay `undefined`.
|
|
567
|
+
- `findParent(Model)` and `isModel(instance, Model)` recognize substitutes;
|
|
568
|
+
`instanceof Model` does not. Props, rendering, forwarded DOM refs and cleanup
|
|
569
|
+
retain their normal framework behavior.
|
|
570
|
+
- Providers snapshot registrations at mount. Remount to apply later registrations.
|
|
571
|
+
|
|
572
|
+
`ModelProvider` exposes the underlying substitution mechanism for custom test
|
|
573
|
+
helpers. The testing package does not bundle a test runner or DI container.
|
|
574
|
+
|
|
575
|
+
## Decorators
|
|
576
|
+
|
|
577
|
+
Use Mantle's decorators when you prefer to choose which fields are reactive.
|
|
578
|
+
Methods remain automatically bound, and `this.props` is always reactive.
|
|
579
|
+
|
|
580
|
+
```tsx
|
|
581
|
+
import { Component, createComponent, observable, action, computed } from 'mobx-mantle';
|
|
582
|
+
|
|
583
|
+
class Todo extends Component<Props> {
|
|
584
|
+
@observable todos: TodoItem[] = [];
|
|
585
|
+
@observable input = '';
|
|
586
|
+
|
|
587
|
+
@computed get remaining() {
|
|
588
|
+
return this.todos.filter(t => !t.done).length;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
@action add() {
|
|
592
|
+
this.todos.push({ id: Date.now(), text: this.input, done: false });
|
|
593
|
+
this.input = '';
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
render() {
|
|
597
|
+
return /* ... */;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
export default createComponent(Todo);
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
### Compiler setup
|
|
605
|
+
|
|
606
|
+
Choose one decorator mode for your project:
|
|
607
|
+
|
|
608
|
+
- **TypeScript legacy:** set `experimentalDecorators: true`. Mantle needs no
|
|
609
|
+
metadata polyfill. Tsyringe services may still require `reflect-metadata` and
|
|
610
|
+
`emitDecoratorMetadata`.
|
|
611
|
+
- **TC39:** omit that flag, include `ESNext.Decorators` or `ESNext` in `lib`, and
|
|
612
|
+
load a `Symbol.metadata` polyfill before decorated modules.
|
|
613
|
+
|
|
614
|
+
### Available Decorators
|
|
615
|
+
|
|
616
|
+
| Decorator | Purpose |
|
|
617
|
+
|-----------|---------|
|
|
618
|
+
| `@observable` | Deep observable field |
|
|
619
|
+
| `@observable.deep` | Explicit deep observable field |
|
|
620
|
+
| `@observable.ref` | Reference-only observation |
|
|
621
|
+
| `@observable.shallow` | Shallow observation (add/remove only) |
|
|
622
|
+
| `@observable.struct` | Structural equality comparison |
|
|
623
|
+
| `@action` | Action method (auto-bound) or arrow-function field |
|
|
624
|
+
| `@computed` | Computed getter |
|
|
625
|
+
|
|
626
|
+
### Supported members and inheritance
|
|
627
|
+
|
|
628
|
+
- Decorate public instance fields, prototype methods and getters in Components
|
|
629
|
+
and Behaviors. Undecorated fields remain nonreactive.
|
|
630
|
+
- Use ordinary fields with `@observable`, without `accessor`. Static/private
|
|
631
|
+
members, parameter decorators and TC39 auto-accessors are not supported by
|
|
632
|
+
Mantle's decorators.
|
|
633
|
+
- Overrides of annotated methods/getters retain their annotation. Repeating it
|
|
634
|
+
is allowed; changing it is rejected. Do not redeclare inherited observable fields.
|
|
635
|
+
- Use one decorator family per model hierarchy. Do not mix Mantle and raw MobX
|
|
636
|
+
annotations on the same instance.
|
|
637
|
+
|
|
638
|
+
### MobX decorators
|
|
639
|
+
|
|
640
|
+
You can instead use MobX's own decorators with auto-observable disabled. MobX's
|
|
641
|
+
TC39 observable fields require `accessor`:
|
|
642
|
+
|
|
643
|
+
```tsx
|
|
644
|
+
import { observable, action } from 'mobx';
|
|
645
|
+
import { Component, configure, createComponent } from 'mobx-mantle';
|
|
646
|
+
|
|
647
|
+
configure({ autoObservable: false });
|
|
648
|
+
|
|
649
|
+
class Todo extends Component<Props> {
|
|
650
|
+
@observable accessor todos: TodoItem[] = [];
|
|
651
|
+
@action add() { /* ... */ }
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
export default createComponent(Todo);
|
|
655
|
+
```
|
|
656
|
+
|
|
657
|
+
## Refs
|
|
658
|
+
|
|
659
|
+
```tsx
|
|
660
|
+
class Form extends Component<Props> {
|
|
661
|
+
inputRef = this.ref<HTMLInputElement>();
|
|
662
|
+
|
|
663
|
+
onMount() {
|
|
664
|
+
this.inputRef.current?.focus();
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
render() {
|
|
668
|
+
return <input ref={this.inputRef} />;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
```
|
|
672
|
+
|
|
673
|
+
### Forwarding Refs
|
|
674
|
+
|
|
675
|
+
Expose a DOM element to parent components via `this.forwardRef`:
|
|
676
|
+
|
|
677
|
+
```tsx
|
|
678
|
+
class FancyInput extends Component<InputProps> {
|
|
679
|
+
render() {
|
|
680
|
+
return <input ref={this.forwardRef} className="fancy-input" />;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
export default createComponent(FancyInput);
|
|
685
|
+
|
|
686
|
+
// Parent can now get a ref to the underlying input:
|
|
687
|
+
function Parent() {
|
|
688
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
689
|
+
|
|
690
|
+
return (
|
|
691
|
+
<>
|
|
692
|
+
<FancyInput ref={inputRef} placeholder="Type here..." />
|
|
693
|
+
<button onClick={() => inputRef.current?.focus()}>Focus</button>
|
|
694
|
+
</>
|
|
695
|
+
);
|
|
696
|
+
}
|
|
697
|
+
```
|
|
698
|
+
|
|
699
|
+
## React Hooks
|
|
700
|
+
|
|
701
|
+
Hooks work inside `render()`:
|
|
702
|
+
|
|
703
|
+
```tsx
|
|
704
|
+
class DataView extends Component<{ id: string }> {
|
|
705
|
+
render() {
|
|
706
|
+
const navigate = useNavigate();
|
|
707
|
+
const { data, isLoading } = useQuery({
|
|
708
|
+
queryKey: ['item', this.props.id],
|
|
709
|
+
queryFn: () => fetchItem(this.props.id),
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
if (isLoading) return <div>Loading...</div>;
|
|
713
|
+
|
|
714
|
+
return (
|
|
715
|
+
<div onClick={() => navigate('/home')}>
|
|
716
|
+
{data.name}
|
|
717
|
+
</div>
|
|
718
|
+
);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
```
|
|
722
|
+
|
|
723
|
+
## Vanilla JS Integration
|
|
724
|
+
|
|
725
|
+
Imperative libraries become straightforward:
|
|
726
|
+
|
|
727
|
+
```tsx
|
|
728
|
+
class Chart extends Component<{ data: number[] }> {
|
|
729
|
+
containerRef = this.ref<HTMLDivElement>();
|
|
730
|
+
chart: Chart | null = null;
|
|
731
|
+
|
|
732
|
+
onMount() {
|
|
733
|
+
this.chart = new Chart(this.containerRef.current!, {
|
|
734
|
+
data: this.props.data,
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
this.watch(
|
|
738
|
+
() => this.props.data,
|
|
739
|
+
(data) => this.chart?.update(data)
|
|
740
|
+
);
|
|
741
|
+
|
|
742
|
+
return () => this.chart?.destroy();
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
render() {
|
|
746
|
+
return <div ref={this.containerRef} />;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
```
|
|
750
|
+
|
|
751
|
+
Compare to hooks:
|
|
752
|
+
|
|
753
|
+
```tsx
|
|
754
|
+
function ChartView({ data }) {
|
|
755
|
+
const containerRef = useRef();
|
|
756
|
+
const chartRef = useRef();
|
|
757
|
+
|
|
758
|
+
useEffect(() => {
|
|
759
|
+
chartRef.current = new Chart(containerRef.current, { data });
|
|
760
|
+
return () => chartRef.current.destroy();
|
|
761
|
+
}, []);
|
|
762
|
+
|
|
763
|
+
useEffect(() => {
|
|
764
|
+
chartRef.current?.update(data);
|
|
765
|
+
}, [data]);
|
|
766
|
+
|
|
767
|
+
return <div ref={containerRef} />;
|
|
768
|
+
}
|
|
769
|
+
```
|
|
770
|
+
|
|
771
|
+
Split effects, multiple refs, dependency tracking: all unnecessary with Mantle.
|
|
772
|
+
|
|
773
|
+
## Side by Side
|
|
774
|
+
|
|
775
|
+
Each example shows the same component in Mantle and React hooks. As components grow, hooks require an increasingly complex web of dependency arrays, memoized callbacks, and mirrored refs that you have to maintain by hand — miss one and you get a silent bug. These comparisons show how Mantle helps to significantly sidesteps that overhead.
|
|
776
|
+
|
|
777
|
+
### Copy to Clipboard
|
|
778
|
+
|
|
779
|
+
A small component with async logic, a reset timer, and cleanup. The kind of thing every project has:
|
|
780
|
+
|
|
781
|
+
```tsx
|
|
782
|
+
class CopyButton extends Component<{ text: string; label?: string }> {
|
|
783
|
+
copied = false;
|
|
784
|
+
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
785
|
+
|
|
786
|
+
async copy() {
|
|
787
|
+
await navigator.clipboard.writeText(this.props.text);
|
|
788
|
+
this.copied = true;
|
|
789
|
+
if (this.timer) clearTimeout(this.timer);
|
|
790
|
+
this.timer = setTimeout(() => (this.copied = false), 2000);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
onUnmount() {
|
|
794
|
+
if (this.timer) clearTimeout(this.timer);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
render() {
|
|
798
|
+
return (
|
|
799
|
+
<button onClick={this.copy}>
|
|
800
|
+
{this.copied ? 'Copied!' : (this.props.label ?? 'Copy')}
|
|
801
|
+
</button>
|
|
802
|
+
);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
```
|
|
806
|
+
|
|
807
|
+
Compare to hooks:
|
|
808
|
+
|
|
809
|
+
```tsx
|
|
810
|
+
function CopyButton({ text, label }: { text: string; label?: string }) {
|
|
811
|
+
const [copied, setCopied] = useState(false);
|
|
812
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
813
|
+
|
|
814
|
+
const copy = useCallback(async () => {
|
|
815
|
+
await navigator.clipboard.writeText(text);
|
|
816
|
+
setCopied(true);
|
|
817
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
818
|
+
timerRef.current = setTimeout(() => setCopied(false), 2000);
|
|
819
|
+
}, [text]); // forget `text` here and you copy stale values
|
|
820
|
+
|
|
821
|
+
useEffect(() => {
|
|
822
|
+
return () => { if (timerRef.current) clearTimeout(timerRef.current); };
|
|
823
|
+
}, []);
|
|
824
|
+
|
|
825
|
+
return (
|
|
826
|
+
<button onClick={copy}>
|
|
827
|
+
{copied ? 'Copied!' : (label ?? 'Copy')}
|
|
828
|
+
</button>
|
|
829
|
+
);
|
|
830
|
+
}
|
|
831
|
+
```
|
|
832
|
+
|
|
833
|
+
The hooks version needs `useState`, `useRef`, `useCallback` with a dependency array, and a separate `useEffect` for cleanup. Miss `text` in the dependency array and you get a stale closure bug. In Mantle, `this.props.text` is always current.
|
|
834
|
+
|
|
835
|
+
### Inline List Editor
|
|
836
|
+
|
|
837
|
+
A component with multiple interaction modes — adding, editing, keyboard shortcuts, and paste handling:
|
|
838
|
+
|
|
839
|
+
```tsx
|
|
840
|
+
class ListEditor extends Component<{ items: string[]; onChange: (items: string[]) => void }> {
|
|
841
|
+
newItem = '';
|
|
842
|
+
editingIndex: number | null = null;
|
|
843
|
+
editValue = '';
|
|
844
|
+
|
|
845
|
+
add() {
|
|
846
|
+
if (!this.newItem.trim()) return;
|
|
847
|
+
this.props.onChange([...this.props.items, this.newItem.trim()]);
|
|
848
|
+
this.newItem = '';
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
remove(index: number) {
|
|
852
|
+
this.props.onChange(this.props.items.filter((_, i) => i !== index));
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
startEdit(index: number) {
|
|
856
|
+
this.editingIndex = index;
|
|
857
|
+
this.editValue = this.props.items[index];
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
saveEdit() {
|
|
861
|
+
if (this.editingIndex === null) return;
|
|
862
|
+
const updated = [...this.props.items];
|
|
863
|
+
updated[this.editingIndex] = this.editValue.trim();
|
|
864
|
+
this.props.onChange(updated);
|
|
865
|
+
this.editingIndex = null;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
cancelEdit() {
|
|
869
|
+
this.editingIndex = null;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
handleKeyDown(e: React.KeyboardEvent) {
|
|
873
|
+
if (e.key === 'Enter') this.add();
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
handleEditKeyDown(e: React.KeyboardEvent) {
|
|
877
|
+
if (e.key === 'Enter') this.saveEdit();
|
|
878
|
+
if (e.key === 'Escape') this.cancelEdit();
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
handlePaste(e: React.ClipboardEvent) {
|
|
882
|
+
const lines = e.clipboardData.getData('text').split(/\n/).map(l => l.trim()).filter(Boolean);
|
|
883
|
+
if (lines.length <= 1) return;
|
|
884
|
+
e.preventDefault();
|
|
885
|
+
this.props.onChange([...this.props.items, ...lines]);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
render() { /* ... */ }
|
|
889
|
+
}
|
|
890
|
+
```
|
|
891
|
+
|
|
892
|
+
Compare to hooks:
|
|
893
|
+
|
|
894
|
+
```tsx
|
|
895
|
+
function ListEditor({ items, onChange }: { items: string[]; onChange: (items: string[]) => void }) {
|
|
896
|
+
const [newItem, setNewItem] = useState('');
|
|
897
|
+
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
|
898
|
+
const [editValue, setEditValue] = useState('');
|
|
899
|
+
|
|
900
|
+
const add = useCallback(() => {
|
|
901
|
+
if (!newItem.trim()) return;
|
|
902
|
+
onChange([...items, newItem.trim()]);
|
|
903
|
+
setNewItem('');
|
|
904
|
+
}, [newItem, items, onChange]);
|
|
905
|
+
|
|
906
|
+
const remove = useCallback((index: number) => {
|
|
907
|
+
onChange(items.filter((_, i) => i !== index));
|
|
908
|
+
}, [items, onChange]);
|
|
909
|
+
|
|
910
|
+
const startEdit = useCallback((index: number) => {
|
|
911
|
+
setEditingIndex(index);
|
|
912
|
+
setEditValue(items[index]);
|
|
913
|
+
}, [items]);
|
|
914
|
+
|
|
915
|
+
const saveEdit = useCallback(() => {
|
|
916
|
+
if (editingIndex === null) return;
|
|
917
|
+
const updated = [...items];
|
|
918
|
+
updated[editingIndex] = editValue.trim();
|
|
919
|
+
onChange(updated);
|
|
920
|
+
setEditingIndex(null);
|
|
921
|
+
}, [editingIndex, editValue, items, onChange]);
|
|
922
|
+
|
|
923
|
+
const cancelEdit = useCallback(() => setEditingIndex(null), []);
|
|
924
|
+
|
|
925
|
+
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
|
926
|
+
if (e.key === 'Enter') add();
|
|
927
|
+
}, [add]);
|
|
928
|
+
|
|
929
|
+
const handleEditKeyDown = useCallback((e: React.KeyboardEvent) => {
|
|
930
|
+
if (e.key === 'Enter') saveEdit();
|
|
931
|
+
if (e.key === 'Escape') cancelEdit();
|
|
932
|
+
}, [saveEdit, cancelEdit]);
|
|
933
|
+
|
|
934
|
+
const handlePaste = useCallback((e: React.ClipboardEvent) => {
|
|
935
|
+
const lines = e.clipboardData.getData('text').split(/\n/).map(l => l.trim()).filter(Boolean);
|
|
936
|
+
if (lines.length <= 1) return;
|
|
937
|
+
e.preventDefault();
|
|
938
|
+
onChange([...items, ...lines]);
|
|
939
|
+
}, [items, onChange]);
|
|
940
|
+
|
|
941
|
+
return /* ... */;
|
|
942
|
+
}
|
|
943
|
+
```
|
|
944
|
+
|
|
945
|
+
3 `useState`, 7 `useCallback` with dependency chains where each callback depends on others. `handleKeyDown` depends on `add`, which depends on `newItem`, `items`, and `onChange`. Miss one and you get stale state. The Mantle version is just methods calling `this` — no dependency graph to maintain.
|
|
946
|
+
|
|
947
|
+
## Error Handling
|
|
948
|
+
|
|
949
|
+
Render errors propagate to React error boundaries as usual. Lifecycle errors (`onLayoutMount`, `onMount`, `onUpdate`, `onUnmount`, `watch`) in both Components and Behaviors are caught and routed through a configurable handler.
|
|
950
|
+
|
|
951
|
+
By default, errors are logged to `console.error`. Configure a global handler to integrate with your error reporting:
|
|
952
|
+
|
|
953
|
+
```tsx
|
|
954
|
+
import { configure } from 'mobx-mantle';
|
|
955
|
+
|
|
956
|
+
configure({
|
|
957
|
+
onError: (error, context) => {
|
|
958
|
+
// context.phase: 'onLayoutMount' | 'onMount' | 'onUpdate' | 'onUnmount' | 'watch'
|
|
959
|
+
// context.name: class name of the Component or Behavior
|
|
960
|
+
// context.isBehavior: true if the error came from a Behavior
|
|
961
|
+
Sentry.captureException(error, {
|
|
962
|
+
tags: { phase: context.phase, component: context.name },
|
|
963
|
+
});
|
|
964
|
+
},
|
|
965
|
+
});
|
|
966
|
+
```
|
|
967
|
+
|
|
968
|
+
Behavior errors are isolated. A failing Behavior won't prevent sibling Behaviors or the parent Component from mounting.
|
|
969
|
+
|
|
970
|
+
## MobX Action Enforcement
|
|
971
|
+
|
|
972
|
+
Mantle preserves the host application's MobX action policy by default (`manageMobxActions: false`). Importing or constructing a Mantle model does not change it. MobX can still warn about changes made outside actions during development. These warnings don't stop your code.
|
|
973
|
+
|
|
974
|
+
Synchronous model methods and lifecycle callbacks batch their writes as actions. Render helpers retain tracked reads. Watch callbacks are reaction actions; effect bodies stay tracked and should wrap their writes explicitly. An async method leaves its action when it reaches an `await`:
|
|
975
|
+
|
|
976
|
+
```tsx
|
|
977
|
+
import { runInAction } from 'mobx';
|
|
978
|
+
async load() {
|
|
979
|
+
const result = await fetchReports();
|
|
980
|
+
runInAction(() => { this.reports = result; });
|
|
981
|
+
}
|
|
982
|
+
```
|
|
983
|
+
|
|
984
|
+
The built-in behaviors handle their own async/timer writes. To deliberately keep the permissive pre-0.6 behavior, configure it **before the first component or behavior is created**, including module-level behavior construction:
|
|
985
|
+
|
|
986
|
+
```tsx
|
|
987
|
+
import { configure } from 'mobx-mantle';
|
|
988
|
+
configure({ manageMobxActions: true });
|
|
989
|
+
```
|
|
990
|
+
|
|
991
|
+
This opt-in sets `enforceActions: 'never'` globally for that MobX instance. Providers cannot scope this policy. Setting the default to false does not undo configuration another library has already applied.
|
|
992
|
+
|
|
993
|
+
## Behaviors
|
|
994
|
+
|
|
995
|
+
Behaviors are a supported feature for reusable React/MobX state and logic. The core API covers creation, nesting, lifecycle cleanup, reactive arguments, and hosting through Components or `useBehavior()`. MobX Mantle remains pre-1.0; supported means this is an API intended for normal application use and ongoing maintenance, not a promise that no future release can change it.
|
|
996
|
+
|
|
997
|
+
Behaviors are reusable pieces of state and logic that can be shared across components. Define them as classes, wrap with `createBehavior()`, and use the resulting factory function in your Components.
|
|
998
|
+
|
|
999
|
+
### Defining a Behavior
|
|
1000
|
+
|
|
1001
|
+
```tsx
|
|
1002
|
+
import { Behavior, createBehavior } from 'mobx-mantle';
|
|
1003
|
+
|
|
1004
|
+
class WindowSizeBehavior extends Behavior {
|
|
1005
|
+
width = window.innerWidth;
|
|
1006
|
+
height = window.innerHeight;
|
|
1007
|
+
breakpoint!: number;
|
|
1008
|
+
|
|
1009
|
+
onCreate(breakpoint = 768) {
|
|
1010
|
+
this.breakpoint = breakpoint;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
get isMobile() {
|
|
1014
|
+
return this.width < this.breakpoint;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
handleResize() {
|
|
1018
|
+
this.width = window.innerWidth;
|
|
1019
|
+
this.height = window.innerHeight;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
onMount() {
|
|
1023
|
+
window.addEventListener('resize', this.handleResize);
|
|
1024
|
+
return () => window.removeEventListener('resize', this.handleResize);
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
export const withWindowSize = createBehavior(WindowSizeBehavior);
|
|
1029
|
+
```
|
|
1030
|
+
|
|
1031
|
+
The naming convention:
|
|
1032
|
+
- **Class**: PascalCase (`WindowSizeBehavior`)
|
|
1033
|
+
- **Factory**: camelCase with `with` prefix (`withWindowSize`)
|
|
1034
|
+
|
|
1035
|
+
Behavior methods have the same tracking-aware binding as Component methods. A
|
|
1036
|
+
read helper called from a render or computed remains inside that derivation:
|
|
1037
|
+
|
|
1038
|
+
```tsx
|
|
1039
|
+
class BreakpointBehavior extends Behavior {
|
|
1040
|
+
width = 0;
|
|
1041
|
+
below(px: number) { return this.width < px; }
|
|
1042
|
+
}
|
|
1043
|
+
```
|
|
1044
|
+
|
|
1045
|
+
`this.size.below(640)` therefore subscribes its caller to `width`; passing a
|
|
1046
|
+
mutating method as an event handler still keeps it bound and batches its writes.
|
|
1047
|
+
|
|
1048
|
+
### Using Behaviors
|
|
1049
|
+
|
|
1050
|
+
Call the factory function (no `new` keyword) in your Component. The `with` prefix signals that the Component manages this behavior's lifecycle:
|
|
1051
|
+
|
|
1052
|
+
```tsx
|
|
1053
|
+
import { withWindowSize } from './withWindowSize';
|
|
1054
|
+
|
|
1055
|
+
class Responsive extends Component<Props> {
|
|
1056
|
+
windowSize = withWindowSize(768);
|
|
1057
|
+
|
|
1058
|
+
render() {
|
|
1059
|
+
return (
|
|
1060
|
+
<div>
|
|
1061
|
+
{this.windowSize.isMobile ? <MobileLayout /> : <DesktopLayout />}
|
|
1062
|
+
<p>Window: {this.windowSize.width}x{this.windowSize.height}</p>
|
|
1063
|
+
</div>
|
|
1064
|
+
);
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
export default createComponent(Responsive);
|
|
1069
|
+
```
|
|
1070
|
+
|
|
1071
|
+
### Watching in Behaviors
|
|
1072
|
+
|
|
1073
|
+
Behaviors can use `this.watch` just like Components:
|
|
1074
|
+
|
|
1075
|
+
```tsx
|
|
1076
|
+
class FetchBehavior extends Behavior {
|
|
1077
|
+
url!: string;
|
|
1078
|
+
data: any[] = [];
|
|
1079
|
+
loading = false;
|
|
1080
|
+
|
|
1081
|
+
onCreate(url: string) {
|
|
1082
|
+
this.url = url;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
onMount() {
|
|
1086
|
+
this.watch(() => this.url, () => this.fetchData(), { fireImmediately: true });
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
async fetchData() {
|
|
1090
|
+
this.loading = true;
|
|
1091
|
+
this.data = await fetch(this.url).then(r => r.json());
|
|
1092
|
+
this.loading = false;
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
export const withFetch = createBehavior(FetchBehavior);
|
|
1097
|
+
```
|
|
1098
|
+
|
|
1099
|
+
### Multiple Behaviors
|
|
1100
|
+
|
|
1101
|
+
Behaviors compose naturally:
|
|
1102
|
+
|
|
1103
|
+
```tsx
|
|
1104
|
+
// FetchBehavior.ts
|
|
1105
|
+
import { Behavior, createBehavior } from 'mobx-mantle';
|
|
1106
|
+
|
|
1107
|
+
class FetchBehavior extends Behavior {
|
|
1108
|
+
url!: string;
|
|
1109
|
+
interval = 5000;
|
|
1110
|
+
data: Item[] = [];
|
|
1111
|
+
loading = false;
|
|
1112
|
+
|
|
1113
|
+
onCreate(url: string, interval = 5000) {
|
|
1114
|
+
this.url = url;
|
|
1115
|
+
this.interval = interval;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
onMount() {
|
|
1119
|
+
this.fetchData();
|
|
1120
|
+
const id = setInterval(() => this.fetchData(), this.interval);
|
|
1121
|
+
return () => clearInterval(id);
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
async fetchData() {
|
|
1125
|
+
this.loading = true;
|
|
1126
|
+
this.data = await fetch(this.url).then(r => r.json());
|
|
1127
|
+
this.loading = false;
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
export const withFetch = createBehavior(FetchBehavior);
|
|
1132
|
+
```
|
|
1133
|
+
|
|
1134
|
+
```tsx
|
|
1135
|
+
import { Component, createComponent } from 'mobx-mantle';
|
|
1136
|
+
import { withFetch } from './FetchBehavior';
|
|
1137
|
+
import { withWindowSize } from './WindowSizeBehavior';
|
|
1138
|
+
|
|
1139
|
+
class Dashboard extends Component<Props> {
|
|
1140
|
+
users = withFetch('/api/users', 10000);
|
|
1141
|
+
posts = withFetch('/api/posts');
|
|
1142
|
+
windowSize = withWindowSize(768);
|
|
1143
|
+
|
|
1144
|
+
render() {
|
|
1145
|
+
return (
|
|
1146
|
+
<div>
|
|
1147
|
+
{this.users.loading ? 'Loading...' : `${this.users.data.length} users`}
|
|
1148
|
+
{this.windowSize.isMobile && <MobileNav />}
|
|
1149
|
+
</div>
|
|
1150
|
+
);
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
export default createComponent(Dashboard);
|
|
1155
|
+
```
|
|
1156
|
+
|
|
1157
|
+
### Behavior Lifecycle
|
|
1158
|
+
|
|
1159
|
+
Behaviors support the same lifecycle methods as Components:
|
|
1160
|
+
|
|
1161
|
+
| Method | When |
|
|
1162
|
+
|--------|------|
|
|
1163
|
+
| `onCreate(...args)` | Called during construction with the factory arguments |
|
|
1164
|
+
| `onLayoutMount()` | Called when parent Component layout mounts (before paint). Return cleanup (optional). |
|
|
1165
|
+
| `onMount()` | Called when parent Component mounts (after paint). Return cleanup (optional). |
|
|
1166
|
+
| `onUnmount()` | Called when parent Component unmounts, after cleanups (optional). |
|
|
1167
|
+
|
|
1168
|
+
React StrictMode replays the mount lifecycle with the same model and refs in
|
|
1169
|
+
development. Pair setup and teardown in the same lifecycle method so the
|
|
1170
|
+
second setup always reacquires what the first cleanup released. A real removal
|
|
1171
|
+
from the React tree destroys the model; rendering it later creates a new one.
|
|
1172
|
+
|
|
1173
|
+
### Reactive Arguments
|
|
1174
|
+
|
|
1175
|
+
A behavior's setup runs once, so a plain argument is a snapshot — frozen at construction. To pass a *live* value, pass a getter:
|
|
1176
|
+
|
|
1177
|
+
```tsx
|
|
1178
|
+
class Dashboard extends Component<Props> {
|
|
1179
|
+
frozen = withFetch('/api/users'); // frozen at construction
|
|
1180
|
+
live = withFetch(() => this.props.url); // refetches when the prop changes
|
|
1181
|
+
}
|
|
1182
|
+
```
|
|
1183
|
+
|
|
1184
|
+
**Consumer rule of thumb:** not sure which to pass? Pass the arrow — it's never wrong, only occasionally redundant. Reserve plain values for things you intend to freeze. (Parameters that don't accept getters are typed plain, so TypeScript tells you when the question even exists.)
|
|
1185
|
+
|
|
1186
|
+
**Observable objects need none of this.** Reactivity lives in property reads, so an observable *object* (a store, another behavior) passes by reference and stays fully live — `withSync(this.settingsStore)` needs no arrow. The getter is only for primitives and computed expressions, which leave the reactive graph the moment they're evaluated. One footgun: the reference is captured once, so it survives *mutation* (`store.theme = 'dark'` ✅) but not *reassignment* of the field that held it (`this.settingsStore = newStore` ❌ — the behavior still holds the old object). If you reassign, pass a getter.
|
|
1187
|
+
|
|
1188
|
+
**Authoring a behavior with live arguments:** `this.sync()` is an optional convenience shortcut for using `this.effect()` to keep a field synchronized with an input. It handles field initialization and one-way mirroring for you; use `effect()` directly when you need custom synchronization logic. Using `effect()`:
|
|
1189
|
+
|
|
1190
|
+
```tsx
|
|
1191
|
+
class SearchBehavior extends Behavior {
|
|
1192
|
+
query = '';
|
|
1193
|
+
|
|
1194
|
+
onCreate(getQuery: () => string) {
|
|
1195
|
+
this.effect(() => {
|
|
1196
|
+
this.query = getQuery();
|
|
1197
|
+
});
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
```
|
|
1201
|
+
|
|
1202
|
+
Using the `sync()` shortcut:
|
|
1203
|
+
|
|
1204
|
+
```tsx
|
|
1205
|
+
class SearchBehavior extends Behavior {
|
|
1206
|
+
query = '';
|
|
1207
|
+
|
|
1208
|
+
onCreate(getQuery: () => string) {
|
|
1209
|
+
this.query = this.sync(getQuery);
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
```
|
|
1213
|
+
|
|
1214
|
+
Both keep `query` following the getter. The effect first runs at mount;
|
|
1215
|
+
`sync()` also initializes the field after `onCreate()` completes, before the
|
|
1216
|
+
first render, then keeps it updated from mount onward.
|
|
1217
|
+
|
|
1218
|
+
Type the input `MaybeGetter<T>` to accept either a fixed value or a live getter:
|
|
1219
|
+
|
|
1220
|
+
```tsx
|
|
1221
|
+
import { Behavior, createBehavior, type MaybeGetter } from 'mobx-mantle';
|
|
1222
|
+
|
|
1223
|
+
class FetchBehavior extends Behavior {
|
|
1224
|
+
url = '';
|
|
1225
|
+
|
|
1226
|
+
onCreate(url: MaybeGetter<string>) {
|
|
1227
|
+
this.url = this.sync(url); // one-way mirror: argument → field
|
|
1228
|
+
this.watch(() => this.url, (u) => this.fetchData(u), { fireImmediately: true });
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
refresh() {
|
|
1232
|
+
this.fetchData(this.url); // plain read — always current
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
export const withFetch = createBehavior(FetchBehavior);
|
|
1236
|
+
```
|
|
1237
|
+
|
|
1238
|
+
A plain value just sits in the field; a getter drives a hidden effect that keeps the field updated (alive from mount, like `watch`/`effect`, StrictMode-safe). The sync is **one-way** — don't write to the field from outside; the next source change would overwrite it, and Mantle warns in development if you do.
|
|
1239
|
+
|
|
1240
|
+
When passing a getter, assign `this.sync()` to a behavior field during initialization. The field receives its initial value after `onCreate()` completes. Use `toValue(argument)` if you need the input value immediately inside `onCreate()`.
|
|
1241
|
+
|
|
1242
|
+
Two lighter alternatives when a mirror field is more than you need:
|
|
1243
|
+
|
|
1244
|
+
- `this.watch(source, cb)` — a `MaybeGetter` is a valid watch source directly (a plain value dev-warns unless `fireImmediately`, since a constant watch can never fire).
|
|
1245
|
+
- `toValue(arg)` — unwrap in place: returns `arg()` if it's a getter, `arg` otherwise. For one-off reads and `effect` bodies, where calling the getter doubles as dependency tracking.
|
|
1246
|
+
|
|
1247
|
+
This is the same convention Vue (`MaybeRefOrGetter` + `toValue`) and Solid (`MaybeAccessor` + `access`) converged on. One caveat: an argument that is legitimately a function (a callback, not a getter) can't also be a `MaybeGetter` — the two are indistinguishable at runtime, so choose per parameter which ones are reactive.
|
|
1248
|
+
|
|
1249
|
+
#### Config-Object Behaviors
|
|
1250
|
+
|
|
1251
|
+
Positional arguments are fine for one or two parameters. When a behavior takes more — or its signature will grow — take a single config object typed `MaybeGetter<Options>`. One arrow at the call site makes the whole bag live, and callbacks inside are safe (they're read, never called):
|
|
1252
|
+
|
|
1253
|
+
```tsx
|
|
1254
|
+
interface SearchOptions {
|
|
1255
|
+
query: string;
|
|
1256
|
+
limit?: number;
|
|
1257
|
+
onResult?: (items: string[]) => void;
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
class SearchBehavior extends Behavior {
|
|
1261
|
+
opts!: SearchOptions;
|
|
1262
|
+
|
|
1263
|
+
onCreate(opts: MaybeGetter<SearchOptions>) {
|
|
1264
|
+
this.opts = this.sync(opts); // same one line as any other live argument
|
|
1265
|
+
this.watch(() => this.opts.query, (q) => this.run(q));
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
export const withSearch = createBehavior(SearchBehavior);
|
|
1269
|
+
```
|
|
1270
|
+
|
|
1271
|
+
```tsx
|
|
1272
|
+
class Page extends Component<Props> {
|
|
1273
|
+
search = withSearch(() => ({ query: this.query, limit: 20 })); // live bag — one arrow
|
|
1274
|
+
fixed = withSearch({ query: 'docs' }); // deliberately frozen
|
|
1275
|
+
}
|
|
1276
|
+
```
|
|
1277
|
+
|
|
1278
|
+
The bag object is re-created when its sources change, but watchers on individual keys (`() => this.opts.query`) still fire only when that key's value actually changes — MobX's value comparison absorbs the churn.
|
|
1279
|
+
|
|
1280
|
+
### Nesting Behaviors
|
|
1281
|
+
|
|
1282
|
+
Behaviors compose recursively: a behavior declared as a field of another behavior (or assigned in its `onCreate`) receives the full lifecycle relay. Children mount before their parent's `onMount` — so a parent can rely on live children — and tear down after their parent's `onUnmount`, in reverse order.
|
|
1283
|
+
|
|
1284
|
+
```tsx
|
|
1285
|
+
class AutosaveBehavior extends Behavior {
|
|
1286
|
+
saver = withAsync((url, data) => postJson(url, data)); // child: request state machine
|
|
1287
|
+
timer!: IntervalBehavior;
|
|
1288
|
+
|
|
1289
|
+
onCreate(url: MaybeGetter<string>, data: () => unknown, interval = 5000) {
|
|
1290
|
+
this.timer = withInterval(() => this.save(), interval); // child: scheduling
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
save() { /* diff + this.saver.run(...) */ }
|
|
1294
|
+
}
|
|
1295
|
+
export const withAutosave = createBehavior(AutosaveBehavior);
|
|
1296
|
+
```
|
|
1297
|
+
|
|
1298
|
+
Two rules, both consistent with how Components collect behaviors:
|
|
1299
|
+
|
|
1300
|
+
- **Underscore-prefixed fields are invisible** to collection — `_helper = withThing()` is not relayed.
|
|
1301
|
+
- **Late assignment isn't collected.** Components collect behaviors at construction; a behavior assigned to a Component field later (conditionally, lazily, or in the Component's `onCreate`) silently never mounts. Mantle warns in development when it finds one. (Inside a *behavior*, `onCreate` assignment is fine — behaviors scan after `onCreate` runs.)
|
|
1302
|
+
|
|
1303
|
+
### Behaviors in Plain Function Components
|
|
1304
|
+
|
|
1305
|
+
You don't have to adopt Mantle components to use behaviors. `useBehavior()` hosts any behavior inside an ordinary function component, with the same lifecycle relay (StrictMode-safe, watchers alive from commit, full teardown on unmount). Wrap the component in `observer()` so renders track the behavior's observables:
|
|
1306
|
+
|
|
1307
|
+
```tsx
|
|
1308
|
+
import { useBehavior, observer } from 'mobx-mantle';
|
|
1309
|
+
import { withWindowSize } from 'mobx-mantle/behaviors';
|
|
1310
|
+
|
|
1311
|
+
const Toolbar = observer(() => {
|
|
1312
|
+
const size = useBehavior(() => withWindowSize());
|
|
1313
|
+
return <div>{size.width} × {size.height}</div>;
|
|
1314
|
+
});
|
|
1315
|
+
```
|
|
1316
|
+
|
|
1317
|
+
The factory runs once per mount lifetime. Because of that, getter arguments close over the first render — getters reading observables stay live, but getters reading the function component's own props go stale. Pass observable sources instead.
|
|
1318
|
+
|
|
1319
|
+
This inverts the adoption story: logic written as a behavior runs in both worlds, and Mantle Components are simply the nicer host.
|
|
1320
|
+
|
|
1321
|
+
### Built-in Behaviors
|
|
1322
|
+
|
|
1323
|
+
`mobx-mantle/behaviors` ships a standard library of small behaviors, all with `MaybeGetter` arguments:
|
|
1324
|
+
|
|
1325
|
+
`withEventListener`, `withInterval`, `withTimeout`, `withAsync`, `withFetch`, `withLocalStorage`, `withElementSize`, `withWindowSize`, `withMediaQuery`, `withDebounce`, `withThrottle`, `withDocumentTitle`, `withPageVisibility`, `withAutosave`.
|
|
1326
|
+
|
|
1327
|
+
Several are themselves compositions — `withFetch` nests `withAsync`; `withWindowSize`, `withLocalStorage`, and `withPageVisibility` nest `withEventListener`; `withAutosave` nests `withInterval` + `withAsync` — the same nesting available to your own behaviors.
|
|
1328
|
+
|
|
1329
|
+
```tsx
|
|
1330
|
+
import { withFetch, withMediaQuery } from 'mobx-mantle/behaviors';
|
|
1331
|
+
|
|
1332
|
+
class Dashboard extends Component<Props> {
|
|
1333
|
+
users = withFetch<User[]>(() => `/api/users?team=${this.props.teamId}`);
|
|
1334
|
+
compact = withMediaQuery('(max-width: 768px)');
|
|
1335
|
+
|
|
1336
|
+
render() {
|
|
1337
|
+
if (this.users.loading) return <Spinner />;
|
|
1338
|
+
return <UserList users={this.users.data} compact={this.compact.matches} />;
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
```
|
|
1342
|
+
|
|
1343
|
+
Element measurement uses the same ref objects as Components and owns its
|
|
1344
|
+
`ResizeObserver` through the layout lifecycle, including StrictMode replay.
|
|
1345
|
+
It reports the element's layout-space border box, so CSS transforms on the
|
|
1346
|
+
element or an ancestor do not change component
|
|
1347
|
+
breakpoints:
|
|
1348
|
+
|
|
1349
|
+
```tsx
|
|
1350
|
+
import { withElementSize } from 'mobx-mantle/behaviors';
|
|
1351
|
+
|
|
1352
|
+
class ResponsivePanel extends Component {
|
|
1353
|
+
root = this.ref<HTMLDivElement>();
|
|
1354
|
+
size = withElementSize(this.root);
|
|
1355
|
+
|
|
1356
|
+
get compact() {
|
|
1357
|
+
return this.size.width !== null && this.size.width < 640;
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
render() {
|
|
1361
|
+
return <div ref={this.root}>{this.compact ? <Compact /> : <Wide />}</div>;
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
```
|
|
1365
|
+
|
|
1366
|
+
### What Behaviors Are Not For
|
|
1367
|
+
|
|
1368
|
+
- **Behaviors cannot call hooks.** `useContext`, `useQuery`, and friends are render-scoped; behaviors live outside the render cycle. The blessed pattern: read the hook in `render()` and pass the value where it's needed.
|
|
1369
|
+
- **Division of labor:** behaviors own vanilla-JS integration, MobX-native logic, and cheaply-testable state machines; hooks own React-ecosystem bindings; `render()` is where the two meet.
|
|
1370
|
+
|
|
1371
|
+
## Shared Stores
|
|
1372
|
+
|
|
1373
|
+
A store that several windows must agree on — a theme, a navigation preference, anything a
|
|
1374
|
+
design tool's hosted frames or a second tab should follow — is shared once, at the entry point:
|
|
1375
|
+
|
|
1376
|
+
```ts
|
|
1377
|
+
import { shareStores } from 'mobx-mantle';
|
|
1378
|
+
import { themeStore } from './themeStore';
|
|
1379
|
+
import { navPreferences } from './navPreferences';
|
|
1380
|
+
|
|
1381
|
+
shareStores({ themeStore, navPreferences });
|
|
1382
|
+
```
|
|
1383
|
+
|
|
1384
|
+
Every window that imports that module wires the same stores. Each window keeps its own
|
|
1385
|
+
instance; values are mirrored, identity is not shared, and nothing goes through a server or a
|
|
1386
|
+
parent document. The mechanics are the web's own answer for two windows, packaged: the store's
|
|
1387
|
+
observable fields are restored from `localStorage` at startup, published through a reaction to
|
|
1388
|
+
`localStorage` and a `BroadcastChannel`, and incoming changes from the channel or the `storage`
|
|
1389
|
+
event are applied inside an action. Echo loops are bounded because a window publishes only when
|
|
1390
|
+
its serialized state changed and applies only what differs from it.
|
|
1391
|
+
|
|
1392
|
+
`shared(store, key, options)` shares one store under an explicit key; `options.fields` limits
|
|
1393
|
+
the shared fields (default: the store's own observable data fields, not getters or methods) and
|
|
1394
|
+
`options.persist: false` skips storage. `unshare(store)` stops the sync.
|
|
1395
|
+
|
|
1396
|
+
**The one authoring rule: effects derive from state.** A change that arrives from another window
|
|
1397
|
+
sets fields directly, so anything the store does to the world — writing custom properties onto
|
|
1398
|
+
`document.documentElement`, updating a title — must be a `reaction`, an `autorun`, or a
|
|
1399
|
+
component's `watch`/`effect`, never a call inside a setter:
|
|
1400
|
+
|
|
1401
|
+
```ts
|
|
1402
|
+
class ThemeStore {
|
|
1403
|
+
theme = 'light';
|
|
1404
|
+
constructor() {
|
|
1405
|
+
makeAutoObservable(this);
|
|
1406
|
+
autorun(() => document.documentElement.dataset.theme = this.theme); // repaints on any window's change
|
|
1407
|
+
}
|
|
1408
|
+
setTheme(theme: string) {
|
|
1409
|
+
this.theme = theme; // no apply() here
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
```
|
|
1413
|
+
|
|
1414
|
+
## API
|
|
1415
|
+
|
|
1416
|
+
### `configure(config)`
|
|
1417
|
+
|
|
1418
|
+
Set global defaults for all components. Settings can still be overridden per-component in `createComponent` options.
|
|
1419
|
+
|
|
1420
|
+
```tsx
|
|
1421
|
+
import { configure } from 'mobx-mantle';
|
|
1422
|
+
|
|
1423
|
+
// Disable auto-observable globally (for decorator users)
|
|
1424
|
+
configure({ autoObservable: false });
|
|
1425
|
+
```
|
|
1426
|
+
|
|
1427
|
+
| Option | Default | Description |
|
|
1428
|
+
|--------|---------|-------------|
|
|
1429
|
+
| `autoObservable` | `true` | Whether to automatically make Component instances observable |
|
|
1430
|
+
| `cacheAnnotations` | `true` | Cache per-class annotation data (getters, methods) so repeated instantiations skip the prototype walk. Turn off only if class prototypes are mutated between instantiations. |
|
|
1431
|
+
| `onError` | `console.error` | Global error handler for lifecycle errors (see [Error Handling](#error-handling)) |
|
|
1432
|
+
| `manageMobxActions` | `false` | Preserve the host policy. Set true before first construction to opt into globally permissive actions (see [MobX Action Enforcement](#mobx-action-enforcement)). |
|
|
1433
|
+
|
|
1434
|
+
### `Component<P>` / `ViewModel<P>`
|
|
1435
|
+
|
|
1436
|
+
Base class for components. `ViewModel` is an alias for `Component`. Use it when separating the ViewModel from the template for semantic clarity.
|
|
1437
|
+
|
|
1438
|
+
| Property/Method | Description |
|
|
1439
|
+
|-----------------|-------------|
|
|
1440
|
+
| `props` | Current props (reactive, available in field initializers) |
|
|
1441
|
+
| `forwardRef` | Ref passed from parent component (for ref forwarding) |
|
|
1442
|
+
| `getParent()` | Immediate ancestor in the live Mantle view tree, or `undefined` at the root |
|
|
1443
|
+
| `findParent(Type)` | Nearest parent matching a Mantle component/model class |
|
|
1444
|
+
| `getParents()` | Readonly array of all parents, ordered from the immediate parent toward the root |
|
|
1445
|
+
| `getRoot()` | Highest Mantle ancestor, or the current instance when already at the root |
|
|
1446
|
+
| `onCreate()` | Called when instance is created, before first render |
|
|
1447
|
+
| `onLayoutMount()` | Called before paint, return cleanup (optional) |
|
|
1448
|
+
| `onMount()` | Called after paint, return cleanup (optional) |
|
|
1449
|
+
| `onUpdate()` | Called after every render |
|
|
1450
|
+
| `onUnmount()` | Called on unmount, after cleanups (optional) |
|
|
1451
|
+
| `render()` | Return JSX (optional if using template) |
|
|
1452
|
+
| `ref<T>()` | Create a ref for DOM elements |
|
|
1453
|
+
| `view(render)` | Tracked view of this instance, for handing to a library (see [Views](#views)) |
|
|
1454
|
+
| `addCleanup(fn)` | Register cleanup to run automatically on unmount |
|
|
1455
|
+
| `watch(source, callback, options?)` | Watch a reactive expression (or a `MaybeGetter` directly), auto-disposed on unmount, re-created on remount |
|
|
1456
|
+
| `effect(fn, options?)` | Run auto-tracked side effect, auto-disposed on unmount, re-created on remount |
|
|
1457
|
+
|
|
1458
|
+
Ancestry follows the logical Mantle view tree. Plain React wrappers and fragments
|
|
1459
|
+
are transparent, portals preserve their logical ancestry, and no DOM wrapper is
|
|
1460
|
+
added. The API works the same way when `ViewModel` uses an external template:
|
|
1461
|
+
|
|
1462
|
+
```tsx
|
|
1463
|
+
class FormModel extends ViewModel {}
|
|
1464
|
+
|
|
1465
|
+
class FieldModel extends ViewModel {
|
|
1466
|
+
form = this.findParent(FormModel)
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
const Field = createComponent(FieldModel, (field) => <input />)
|
|
1470
|
+
const Form = createComponent(FormModel, (form) => <Field />)
|
|
1471
|
+
```
|
|
1472
|
+
|
|
1473
|
+
Ancestry is available in field initializers and `onCreate()`. It is runtime-only,
|
|
1474
|
+
non-observable, and not serialized. A model constructed manually with `new`
|
|
1475
|
+
outside `createComponent()` has no automatic parent.
|
|
1476
|
+
|
|
1477
|
+
### `Behavior`
|
|
1478
|
+
|
|
1479
|
+
Base class for behaviors. Extend it and wrap with `createBehavior()`.
|
|
1480
|
+
|
|
1481
|
+
| Method | Description |
|
|
1482
|
+
|--------|-------------|
|
|
1483
|
+
| `onCreate(...args)` | Called during construction with constructor args |
|
|
1484
|
+
| `onLayoutMount()` | Called before paint, return cleanup (optional) |
|
|
1485
|
+
| `onMount()` | Called after paint, return cleanup (optional) |
|
|
1486
|
+
| `onUnmount()` | Called when parent Component unmounts |
|
|
1487
|
+
| `addCleanup(fn)` | Register cleanup to run automatically on unmount |
|
|
1488
|
+
| `watch(source, callback, options?)` | Watch a reactive expression (or a `MaybeGetter` argument directly), auto-disposed on unmount, re-created on remount |
|
|
1489
|
+
| `effect(fn, options?)` | Run auto-tracked side effect, auto-disposed on unmount, re-created on remount |
|
|
1490
|
+
| `sync(arg)` | Normalize a `MaybeGetter` argument into an ordinary field that stays current (one-way, see [Reactive Arguments](#reactive-arguments)) |
|
|
1491
|
+
|
|
1492
|
+
### `createBehavior(Class)`
|
|
1493
|
+
|
|
1494
|
+
Creates a factory function from a behavior class. Returns a callable (no `new` needed).
|
|
1495
|
+
|
|
1496
|
+
```tsx
|
|
1497
|
+
class MyBehavior extends Behavior {
|
|
1498
|
+
onCreate(value: string) { /* ... */ }
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
export const withMyBehavior = createBehavior(MyBehavior);
|
|
1502
|
+
|
|
1503
|
+
// Usage: withMyBehavior('hello')
|
|
1504
|
+
```
|
|
1505
|
+
|
|
1506
|
+
### `createComponent(ComponentClass, templateOrOptions?)`
|
|
1507
|
+
|
|
1508
|
+
Function that creates a React component from a Component class.
|
|
1509
|
+
|
|
1510
|
+
```tsx
|
|
1511
|
+
// Basic (auto-observable)
|
|
1512
|
+
createComponent(MyComponent)
|
|
1513
|
+
|
|
1514
|
+
// With template
|
|
1515
|
+
createComponent(MyComponent, (vm) => <div>{vm.value}</div>)
|
|
1516
|
+
|
|
1517
|
+
// With options
|
|
1518
|
+
createComponent(MyComponent, { autoObservable: false })
|
|
1519
|
+
```
|
|
1520
|
+
|
|
1521
|
+
| Option | Default | Description |
|
|
1522
|
+
|--------|---------|-------------|
|
|
1523
|
+
| `autoObservable` | `true` | Make all fields observable. Set to `false` when using decorators. |
|
|
1524
|
+
|
|
1525
|
+
### `createView(ModelClass, template)`
|
|
1526
|
+
|
|
1527
|
+
Creates a React component that renders `template` with the nearest mounted `ModelClass` as `vm` (see [Views](#views)). Throws if none is mounted above it.
|
|
1528
|
+
|
|
1529
|
+
```tsx
|
|
1530
|
+
const Summary = createView(TodoList, ({ vm }) => <p>{vm.remaining} left</p>);
|
|
1531
|
+
```
|
|
1532
|
+
|
|
1533
|
+
### `useBehavior(factory)`
|
|
1534
|
+
|
|
1535
|
+
Host a behavior in a plain React function component (see [Behaviors in Plain Function Components](#behaviors-in-plain-function-components)). The factory runs once per mount lifetime; the returned instance is observable — pair with `observer()`.
|
|
1536
|
+
|
|
1537
|
+
```tsx
|
|
1538
|
+
const size = useBehavior(() => withWindowSize());
|
|
1539
|
+
```
|
|
1540
|
+
|
|
1541
|
+
### `observer(fc)`
|
|
1542
|
+
|
|
1543
|
+
Minimal observer wrapper for function components — re-renders when any observable read during render changes. Ships with Mantle (no `mobx-react-lite` needed); intended for components using `useBehavior()` or reading MobX stores directly.
|
|
1544
|
+
|
|
1545
|
+
### `toValue(value)` / `MaybeGetter<T>`
|
|
1546
|
+
|
|
1547
|
+
The value-or-getter convention for reactive behavior arguments (see [Reactive Arguments](#reactive-arguments)). `toValue(v)` returns `v()` if `v` is a function, otherwise `v`. Behavior authors normalizing an argument into a self-updating field use `this.sync()` instead (documented in the same section).
|
|
1548
|
+
|
|
1549
|
+
## Who This Is For
|
|
1550
|
+
|
|
1551
|
+
- Teams using MobX for state management
|
|
1552
|
+
- Developers from other platforms (mobile, backend, other frameworks)
|
|
1553
|
+
- Projects integrating vanilla JS libraries
|
|
1554
|
+
- Anyone tired of dependency arrays
|
|
1555
|
+
|
|
1556
|
+
## License
|
|
1557
|
+
|
|
1558
|
+
MIT
|