mobx-mantle 0.3.6 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # MobX Mantle
2
2
 
3
- A lightweight library for building React components with a 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.
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
4
 
5
5
  ## Why
6
6
 
@@ -14,23 +14,45 @@ The goal is to give React developers a way to build components using patterns co
14
14
  npm install mobx-mantle
15
15
  ```
16
16
 
17
- Requires React 17+, MobX 6+, and mobx-react-lite 3+.
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).
18
44
 
19
45
  ## Basic Example
20
46
 
21
47
  ```tsx
22
- import { View, createView } from 'mobx-mantle';
48
+ import { Component, createComponent } from 'mobx-mantle';
23
49
 
24
50
  interface CounterProps {
25
51
  initial: number;
26
52
  }
27
53
 
28
- class Counter extends View<CounterProps> {
29
- count = 0;
30
-
31
- onCreate() {
32
- this.count = this.props.initial;
33
- }
54
+ class Counter extends Component<CounterProps> {
55
+ count = this.props.initial;
34
56
 
35
57
  increment() {
36
58
  this.count++;
@@ -45,10 +67,10 @@ class Counter extends View<CounterProps> {
45
67
  }
46
68
  }
47
69
 
48
- export default createView(Counter);
70
+ export default createComponent(Counter);
49
71
  ```
50
72
 
51
- **Everything is reactive by default.** All properties become observable, getters become computed, and methods become auto-bound actions. No annotations needed.
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.
52
74
 
53
75
  > Want explicit control? See [Decorators](#decorators) below to opt into manual annotations.
54
76
 
@@ -80,7 +102,7 @@ toggle(id: number) { // automatically bound to this
80
102
  **React to changes explicitly:**
81
103
  ```tsx
82
104
  onMount() {
83
- return reaction(
105
+ this.watch(
84
106
  () => this.props.filter,
85
107
  (filter) => this.applyFilter(filter)
86
108
  );
@@ -91,40 +113,74 @@ onMount() {
91
113
 
92
114
  | Method | When |
93
115
  |--------|------|
94
- | `onCreate()` | Instance created, props available |
116
+ | `onCreate()` | Instance created, props available, before first render. Derive initial state here (synchronous only). |
95
117
  | `onLayoutMount()` | DOM ready, before paint. Return a cleanup function (optional). |
96
- | `onMount()` | Component mounted, after 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). |
97
119
  | `onUpdate()` | After every render (via `useEffect`). |
98
120
  | `onUnmount()` | Component unmounting. Called after cleanups (optional). |
99
121
  | `render()` | On mount and updates. Return JSX. |
100
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
+
101
155
  ### Watching State
102
156
 
103
- Use `this.watch` to react to state changes. Watchers are automatically disposed on unmount.
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.)
104
160
 
105
161
  ```tsx
106
162
  this.watch(
107
- () => expr, // reactive expression (getter)
108
- (value, prev) => {}, // callback when expression result changes
109
- options? // optional: { delay, fireImmediately }
110
- )
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
+ );
111
167
  ```
112
168
 
113
169
  **Options:**
114
170
 
115
171
  | Option | Type | Default | Description |
116
172
  |--------|------|---------|-------------|
117
- | `delay` | `number` | — | Debounce the callback by N milliseconds |
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) |
118
174
  | `fireImmediately` | `boolean` | `false` | Run callback immediately with current value |
119
175
 
120
176
  **Basic example:**
121
177
 
122
178
  ```tsx
123
- class SearchView extends View<Props> {
179
+ class Search extends Component<Props> {
124
180
  query = '';
125
181
  results: string[] = [];
126
182
 
127
- onCreate() {
183
+ onMount() {
128
184
  this.watch(
129
185
  () => this.query,
130
186
  async (query) => {
@@ -138,20 +194,12 @@ class SearchView extends View<Props> {
138
194
  }
139
195
  ```
140
196
 
141
- **Multiple watchers:**
142
-
143
- ```tsx
144
- onCreate() {
145
- this.watch(() => this.props.filter, (filter) => this.applyFilter(filter));
146
- this.watch(() => this.props.sort, (sort) => this.applySort(sort));
147
- this.watch(() => this.props.page, (page) => this.fetchPage(page));
148
- }
149
- ```
197
+ Call `this.watch` as many times as you need — each watcher is tracked and disposed independently.
150
198
 
151
- **Early disposal:**
199
+ **Early disposal** (works the same for `effect`):
152
200
 
153
201
  ```tsx
154
- onCreate() {
202
+ onMount() {
155
203
  const stop = this.watch(() => this.props.token, (token) => {
156
204
  this.authenticate(token);
157
205
  stop(); // only needed once
@@ -159,47 +207,94 @@ onCreate() {
159
207
  }
160
208
  ```
161
209
 
162
- ### Props Reactivity
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`.
163
211
 
164
- `this.props` is reactive: your component re-renders when accessed props change.
212
+ ### Effects
165
213
 
166
- **Option 1: `this.watch`** — the recommended way to react to state changes:
214
+ Use `this.effect` to run a side effect that auto-tracks dependencies. It runs immediately and re-runs whenever any accessed observable changes.
167
215
 
168
216
  ```tsx
169
- onCreate() {
170
- this.watch(
171
- () => this.props.filter,
172
- (filter) => this.applyFilter(filter)
173
- );
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
+ }
174
248
  }
175
249
  ```
176
250
 
177
- Watchers are automatically disposed on unmount. No cleanup needed.
251
+ Early disposal works the same as `watch` — the returned `stop()` function tears the effect down early.
252
+
253
+ ### Cleanup
178
254
 
179
- **Option 2: `reaction`** — for advanced MobX patterns (autorun, when, custom schedulers):
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.
180
256
 
181
257
  ```tsx
182
258
  onMount() {
183
- return reaction(
184
- () => this.props.filter,
185
- (filter) => this.applyFilter(filter)
186
- );
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));
187
266
  }
188
267
  ```
189
268
 
190
- **Option 3: `onUpdate`** — imperative hook after each render (requires manual dirty-checking):
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:
191
278
 
192
279
  ```tsx
193
- onUpdate() {
194
- if (this.props.filter !== this.lastFilter) {
195
- this.lastFilter = this.props.filter;
196
- this.applyFilter(this.props.filter);
197
- }
280
+ onMount() {
281
+ this.watch(
282
+ () => this.props.filter,
283
+ (filter) => this.applyFilter(filter)
284
+ );
198
285
  }
199
286
  ```
200
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
+
201
294
  Or access props directly in `render()` and MobX handles re-renders when they change.
202
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
+
203
298
  ## Patterns
204
299
 
205
300
  ### Combined (default)
@@ -207,7 +302,7 @@ Or access props directly in `render()` and MobX handles re-renders when they cha
207
302
  State, logic, and template in one class:
208
303
 
209
304
  ```tsx
210
- class Todo extends View<Props> {
305
+ class Todo extends Component<Props> {
211
306
  todos: TodoItem[] = [];
212
307
  input = '';
213
308
 
@@ -231,7 +326,7 @@ class Todo extends View<Props> {
231
326
  }
232
327
  }
233
328
 
234
- export default createView(Todo);
329
+ export default createComponent(Todo);
235
330
  ```
236
331
 
237
332
  ### Separated
@@ -239,7 +334,7 @@ export default createView(Todo);
239
334
  ViewModel and template separate:
240
335
 
241
336
  ```tsx
242
- import { ViewModel, createView } from 'mobx-mantle';
337
+ import { ViewModel, createComponent } from 'mobx-mantle';
243
338
 
244
339
  class Todo extends ViewModel<Props> {
245
340
  todos: TodoItem[] = [];
@@ -255,7 +350,7 @@ class Todo extends ViewModel<Props> {
255
350
  }
256
351
  }
257
352
 
258
- export default createView(Todo, (vm) => (
353
+ export default createComponent(Todo, (vm) => (
259
354
  <div>
260
355
  <input value={vm.input} onChange={vm.setInput} />
261
356
  <button onClick={vm.add}>Add</button>
@@ -264,14 +359,179 @@ export default createView(Todo, (vm) => (
264
359
  ));
265
360
  ```
266
361
 
362
+ ## Services
363
+
364
+ Configure your application's resolver once, then declare services as fields:
365
+
366
+ ```tsx
367
+ import { Component, configure, createComponent, inject } from 'mobx-mantle';
368
+ import { container } from 'tsyringe';
369
+
370
+ configure({ resolveService: token => container.resolve(token) });
371
+
372
+ class ReportsComponent extends Component {
373
+ preferences = inject(PreferencesService);
374
+ reporting = inject(ReportingAgencyService);
375
+
376
+ render() {
377
+ return <div>{this.reporting.title}</div>;
378
+ }
379
+ }
380
+
381
+ const Reports = createComponent(ReportsComponent);
382
+ root.render(<Reports />);
383
+ ```
384
+
385
+ Class tokens infer the service type. For an interface, create a token with
386
+ `createServiceToken<MyService>('MyService')` and register it with your resolver.
387
+ Mantle works with any compatible resolver; tsyringe is optional.
388
+
389
+ ### Choosing between `inject()` and `this.inject()`
390
+
391
+ Use `inject()` when setting up your component or behavior, either in a field
392
+ declaration or in `onCreate`. Usually, you can get the service once and use that
393
+ field whenever you need it:
394
+
395
+ ```ts
396
+ preferences = inject(PreferencesService);
397
+
398
+ onClick() {
399
+ this.preferences.save();
400
+ }
401
+ ```
402
+
403
+ If you need to get a service later, such as when a button is clicked or after an
404
+ `await`, use `this.inject()` instead:
405
+
406
+ ```ts
407
+ onClick() {
408
+ const reporting = this.inject(ReportingAgencyService);
409
+ reporting.open();
410
+ }
411
+ ```
412
+
413
+ `this.inject()` also works during setup. Both versions are available to Components
414
+ and Behaviors, including nested behaviors and those used through `useBehavior()`.
415
+
416
+ ### How services are shared
417
+
418
+ Your resolver decides whether to create a service or return an existing one.
419
+ Mantle asks it each time you call either version of `inject()`, and returns the
420
+ object it gives back. Caching and cleanup are also up to your resolver.
421
+
422
+ Mantle keeps service fields out of its automatic MobX setup. The service's own
423
+ observable state still works normally. If you use decorators, leave the service
424
+ field undecorated. An injected service that is itself a Behavior won't be mounted
425
+ or unmounted by Mantle. Services must be objects or functions. To provide a simple
426
+ setting such as a string or number, put it in an object.
427
+
428
+ Set up `configure({ resolveService })` before creating components. If one part of
429
+ your app needs different services, wrap it in a `ServiceProvider resolve={resolve}`.
430
+ Components inside that provider use its resolver instead of the default.
431
+
432
+ Each component or behavior remembers the resolver it started with. Changing the
433
+ configuration or provider won't switch existing instances over. Recreate them to
434
+ use the new resolver. If a provider can't supply a service, Mantle reports the
435
+ error rather than trying the application default.
436
+
437
+ You can keep using your existing tsyringe container and its service registrations.
438
+ Mantle's field injection doesn't need extra decorator metadata from the compiler.
439
+ Your tsyringe services still need whatever decorator and metadata setup they
440
+ already use.
441
+
442
+ ### Creating a model outside React
443
+
444
+ If you're creating a model or behavior yourself with `new`, use
445
+ `withInjectionScope()` to make the resolver available to imported `inject()`:
446
+
447
+ ```ts
448
+ const model = withInjectionScope(resolve, () => new ReportsComponent());
449
+ ```
450
+
451
+ Pass `undefined` for `resolve` to use the default from `configure()`. This creates
452
+ the model but doesn't render it or run its mount lifecycle. Keep the setup inside
453
+ the callback synchronous. For code that runs after an `await`, use `this.inject()`.
454
+
455
+ If your model only uses `this.inject()`, you can use `new` directly with the
456
+ configured default, without `withInjectionScope()`.
457
+
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
+
267
526
  ## Decorators
268
527
 
269
- For teams that prefer explicit annotations over auto-observable, Mantle provides its own decorators. These are lightweight metadata collectors. No `accessor` keyword required.
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.
270
530
 
271
531
  ```tsx
272
- import { View, createView, observable, action, computed } from 'mobx-mantle';
532
+ import { Component, createComponent, observable, action, computed } from 'mobx-mantle';
273
533
 
274
- class Todo extends View<Props> {
534
+ class Todo extends Component<Props> {
275
535
  @observable todos: TodoItem[] = [];
276
536
  @observable input = '';
277
537
 
@@ -289,49 +549,66 @@ class Todo extends View<Props> {
289
549
  }
290
550
  }
291
551
 
292
- export default createView(Todo);
552
+ export default createComponent(Todo);
293
553
  ```
294
554
 
295
- **Key differences from auto-observable mode:**
296
- - Only decorated fields are reactive (undecorated fields are inert)
297
- - Methods are still auto-bound for stable `this` references
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.
298
564
 
299
565
  ### Available Decorators
300
566
 
301
567
  | Decorator | Purpose |
302
568
  |-----------|---------|
303
569
  | `@observable` | Deep observable field |
570
+ | `@observable.deep` | Explicit deep observable field |
304
571
  | `@observable.ref` | Reference-only observation |
305
572
  | `@observable.shallow` | Shallow observation (add/remove only) |
306
573
  | `@observable.struct` | Structural equality comparison |
307
- | `@action` | Action method (auto-bound) |
308
- | `@computed` | Computed getter (optional; getters are computed by default) |
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.
309
588
 
310
- ### MobX Decorators (Legacy)
589
+ ### MobX decorators
311
590
 
312
- If you prefer using MobX's own decorators (requires `accessor` keyword for TC39):
591
+ You can instead use MobX's own decorators with auto-observable disabled. MobX's
592
+ TC39 observable fields require `accessor`:
313
593
 
314
594
  ```tsx
315
595
  import { observable, action } from 'mobx';
316
- import { configure } from 'mobx-mantle';
596
+ import { Component, configure, createComponent } from 'mobx-mantle';
317
597
 
318
- // Disable auto-observable globally
319
598
  configure({ autoObservable: false });
320
599
 
321
- class Todo extends View<Props> {
322
- @observable accessor todos: TodoItem[] = []; // note: accessor required
600
+ class Todo extends Component<Props> {
601
+ @observable accessor todos: TodoItem[] = [];
323
602
  @action add() { /* ... */ }
324
603
  }
325
604
 
326
- export default createView(Todo);
327
- ```
328
-
329
- Note: `this.props` is always reactive regardless of decorator mode.
330
-
605
+ export default createComponent(Todo);
606
+ ```
607
+
331
608
  ## Refs
332
609
 
333
610
  ```tsx
334
- class FormView extends View<Props> {
611
+ class Form extends Component<Props> {
335
612
  inputRef = this.ref<HTMLInputElement>();
336
613
 
337
614
  onMount() {
@@ -349,13 +626,13 @@ class FormView extends View<Props> {
349
626
  Expose a DOM element to parent components via `this.forwardRef`:
350
627
 
351
628
  ```tsx
352
- class FancyInput extends View<InputProps> {
629
+ class FancyInput extends Component<InputProps> {
353
630
  render() {
354
631
  return <input ref={this.forwardRef} className="fancy-input" />;
355
632
  }
356
633
  }
357
634
 
358
- export default createView(FancyInput);
635
+ export default createComponent(FancyInput);
359
636
 
360
637
  // Parent can now get a ref to the underlying input:
361
638
  function Parent() {
@@ -370,48 +647,12 @@ function Parent() {
370
647
  }
371
648
  ```
372
649
 
373
- ## Reactions
374
-
375
- ```tsx
376
- class SearchView extends View<Props> {
377
- query = '';
378
- results: string[] = [];
379
-
380
- onMount() {
381
- const dispose = reaction(
382
- () => this.query,
383
- async (query) => {
384
- if (query.length > 2) {
385
- this.results = await searchApi(query);
386
- }
387
- },
388
- { delay: 300 }
389
- );
390
-
391
- return dispose;
392
- }
393
-
394
- setQuery(e: React.ChangeEvent<HTMLInputElement>) {
395
- this.query = e.target.value;
396
- }
397
-
398
- render() {
399
- return (
400
- <div>
401
- <input value={this.query} onChange={this.setQuery} />
402
- <ul>{this.results.map(r => <li key={r}>{r}</li>)}</ul>
403
- </div>
404
- );
405
- }
406
- }
407
- ```
408
-
409
650
  ## React Hooks
410
651
 
411
652
  Hooks work inside `render()`:
412
653
 
413
654
  ```tsx
414
- class DataView extends View<{ id: string }> {
655
+ class DataView extends Component<{ id: string }> {
415
656
  render() {
416
657
  const navigate = useNavigate();
417
658
  const { data, isLoading } = useQuery({
@@ -435,7 +676,7 @@ class DataView extends View<{ id: string }> {
435
676
  Imperative libraries become straightforward:
436
677
 
437
678
  ```tsx
438
- class ChartView extends View<{ data: number[] }> {
679
+ class Chart extends Component<{ data: number[] }> {
439
680
  containerRef = this.ref<HTMLDivElement>();
440
681
  chart: Chart | null = null;
441
682
 
@@ -444,15 +685,12 @@ class ChartView extends View<{ data: number[] }> {
444
685
  data: this.props.data,
445
686
  });
446
687
 
447
- const dispose = reaction(
688
+ this.watch(
448
689
  () => this.props.data,
449
690
  (data) => this.chart?.update(data)
450
691
  );
451
692
 
452
- return () => {
453
- dispose();
454
- this.chart?.destroy();
455
- };
693
+ return () => this.chart?.destroy();
456
694
  }
457
695
 
458
696
  render() {
@@ -483,9 +721,183 @@ function ChartView({ data }) {
483
721
 
484
722
  Split effects, multiple refs, dependency tracking: all unnecessary with Mantle.
485
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
+
486
898
  ## Error Handling
487
899
 
488
- Render errors propagate to React error boundaries as usual. Lifecycle errors (`onLayoutMount`, `onMount`, `onUpdate`, `onUnmount`, `watch`) in both Views and Behaviors are caught and routed through a configurable handler.
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.
489
901
 
490
902
  By default, errors are logged to `console.error`. Configure a global handler to integrate with your error reporting:
491
903
 
@@ -495,7 +907,7 @@ import { configure } from 'mobx-mantle';
495
907
  configure({
496
908
  onError: (error, context) => {
497
909
  // context.phase: 'onLayoutMount' | 'onMount' | 'onUpdate' | 'onUnmount' | 'watch'
498
- // context.name: class name of the View or Behavior
910
+ // context.name: class name of the Component or Behavior
499
911
  // context.isBehavior: true if the error came from a Behavior
500
912
  Sentry.captureException(error, {
501
913
  tags: { phase: context.phase, component: context.name },
@@ -504,13 +916,36 @@ configure({
504
916
  });
505
917
  ```
506
918
 
507
- Behavior errors are isolated. A failing Behavior won't prevent sibling Behaviors or the parent View from mounting.
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.
508
943
 
509
- ## Behaviors (Experimental)
944
+ ## Behaviors
510
945
 
511
- > ⚠️ **Experimental:** The Behaviors API is still evolving and may change in future releases.
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.
512
947
 
513
- Behaviors are reusable pieces of state and logic that can be shared across views. Define them as classes, wrap with `createBehavior()`, and use the resulting factory function in your Views.
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.
514
949
 
515
950
  ### Defining a Behavior
516
951
 
@@ -548,14 +983,27 @@ The naming convention:
548
983
  - **Class**: PascalCase (`WindowSizeBehavior`)
549
984
  - **Factory**: camelCase with `with` prefix (`withWindowSize`)
550
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
+
551
999
  ### Using Behaviors
552
1000
 
553
- Call the factory function (no `new` keyword) in your View. The `with` prefix signals that the View manages this behavior's lifecycle:
1001
+ Call the factory function (no `new` keyword) in your Component. The `with` prefix signals that the Component manages this behavior's lifecycle:
554
1002
 
555
1003
  ```tsx
556
1004
  import { withWindowSize } from './withWindowSize';
557
1005
 
558
- class Responsive extends View<Props> {
1006
+ class Responsive extends Component<Props> {
559
1007
  windowSize = withWindowSize(768);
560
1008
 
561
1009
  render() {
@@ -568,12 +1016,12 @@ class Responsive extends View<Props> {
568
1016
  }
569
1017
  }
570
1018
 
571
- export default createView(Responsive);
1019
+ export default createComponent(Responsive);
572
1020
  ```
573
1021
 
574
1022
  ### Watching in Behaviors
575
1023
 
576
- Behaviors can use `this.watch` just like Views:
1024
+ Behaviors can use `this.watch` just like Components:
577
1025
 
578
1026
  ```tsx
579
1027
  class FetchBehavior extends Behavior {
@@ -583,6 +1031,9 @@ class FetchBehavior extends Behavior {
583
1031
 
584
1032
  onCreate(url: string) {
585
1033
  this.url = url;
1034
+ }
1035
+
1036
+ onMount() {
586
1037
  this.watch(() => this.url, () => this.fetchData(), { fireImmediately: true });
587
1038
  }
588
1039
 
@@ -632,11 +1083,11 @@ export const withFetch = createBehavior(FetchBehavior);
632
1083
  ```
633
1084
 
634
1085
  ```tsx
635
- import { View, createView } from 'mobx-mantle';
1086
+ import { Component, createComponent } from 'mobx-mantle';
636
1087
  import { withFetch } from './FetchBehavior';
637
1088
  import { withWindowSize } from './WindowSizeBehavior';
638
1089
 
639
- class Dashboard extends View<Props> {
1090
+ class Dashboard extends Component<Props> {
640
1091
  users = withFetch('/api/users', 10000);
641
1092
  posts = withFetch('/api/posts');
642
1093
  windowSize = withWindowSize(768);
@@ -651,25 +1102,271 @@ class Dashboard extends View<Props> {
651
1102
  }
652
1103
  }
653
1104
 
654
- export default createView(Dashboard);
1105
+ export default createComponent(Dashboard);
1106
+ ```
655
1107
 
656
1108
  ### Behavior Lifecycle
657
1109
 
658
- Behaviors support the same lifecycle methods as Views:
1110
+ Behaviors support the same lifecycle methods as Components:
659
1111
 
660
1112
  | Method | When |
661
1113
  |--------|------|
662
1114
  | `onCreate(...args)` | Called during construction with the factory arguments |
663
- | `onLayoutMount()` | Called when parent View layout mounts (before paint). Return cleanup (optional). |
664
- | `onMount()` | Called when parent View mounts (after paint). Return cleanup (optional). |
665
- | `onUnmount()` | Called when parent View unmounts, after cleanups (optional). |
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 = '';
666
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
+ ```
667
1364
 
668
1365
  ## API
669
1366
 
670
1367
  ### `configure(config)`
671
1368
 
672
- Set global defaults for all views. Settings can still be overridden per-view in `createView` options.
1369
+ Set global defaults for all components. Settings can still be overridden per-component in `createComponent` options.
673
1370
 
674
1371
  ```tsx
675
1372
  import { configure } from 'mobx-mantle';
@@ -680,25 +1377,52 @@ configure({ autoObservable: false });
680
1377
 
681
1378
  | Option | Default | Description |
682
1379
  |--------|---------|-------------|
683
- | `autoObservable` | `true` | Whether to automatically make View instances observable |
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. |
684
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)). |
685
1384
 
686
- ### `View<P>` / `ViewModel<P>`
1385
+ ### `Component<P>` / `ViewModel<P>`
687
1386
 
688
- Base class for view components. `ViewModel` is an alias for `View`. Use it when separating the ViewModel from the template for semantic clarity.
1387
+ Base class for components. `ViewModel` is an alias for `Component`. Use it when separating the ViewModel from the template for semantic clarity.
689
1388
 
690
1389
  | Property/Method | Description |
691
1390
  |-----------------|-------------|
692
- | `props` | Current props (reactive) |
1391
+ | `props` | Current props (reactive, available in field initializers) |
693
1392
  | `forwardRef` | Ref passed from parent component (for ref forwarding) |
694
- | `onCreate()` | Called when instance created |
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 |
695
1398
  | `onLayoutMount()` | Called before paint, return cleanup (optional) |
696
1399
  | `onMount()` | Called after paint, return cleanup (optional) |
697
1400
  | `onUpdate()` | Called after every render |
698
1401
  | `onUnmount()` | Called on unmount, after cleanups (optional) |
699
1402
  | `render()` | Return JSX (optional if using template) |
700
1403
  | `ref<T>()` | Create a ref for DOM elements |
701
- | `watch(expr, callback, options?)` | Watch reactive expression, auto-disposed on unmount |
1404
+ | `addCleanup(fn)` | Register cleanup to run automatically on unmount |
1405
+ | `watch(source, callback, options?)` | Watch a reactive expression (or a `MaybeGetter` directly), auto-disposed on unmount, re-created on remount |
1406
+ | `effect(fn, options?)` | Run auto-tracked side effect, auto-disposed on unmount, re-created on remount |
1407
+
1408
+ Ancestry follows the logical Mantle view tree. Plain React wrappers and fragments
1409
+ are transparent, portals preserve their logical ancestry, and no DOM wrapper is
1410
+ added. The API works the same way when `ViewModel` uses an external template:
1411
+
1412
+ ```tsx
1413
+ class FormModel extends ViewModel {}
1414
+
1415
+ class FieldModel extends ViewModel {
1416
+ form = this.findParent(FormModel)
1417
+ }
1418
+
1419
+ const Field = createComponent(FieldModel, (field) => <input />)
1420
+ const Form = createComponent(FormModel, (form) => <Field />)
1421
+ ```
1422
+
1423
+ Ancestry is available in field initializers and `onCreate()`. It is runtime-only,
1424
+ non-observable, and not serialized. A model constructed manually with `new`
1425
+ outside `createComponent()` has no automatic parent.
702
1426
 
703
1427
  ### `Behavior`
704
1428
 
@@ -709,8 +1433,11 @@ Base class for behaviors. Extend it and wrap with `createBehavior()`.
709
1433
  | `onCreate(...args)` | Called during construction with constructor args |
710
1434
  | `onLayoutMount()` | Called before paint, return cleanup (optional) |
711
1435
  | `onMount()` | Called after paint, return cleanup (optional) |
712
- | `onUnmount()` | Called when parent View unmounts |
713
- | `watch(expr, callback, options?)` | Watch reactive expression, auto-disposed on unmount |
1436
+ | `onUnmount()` | Called when parent Component unmounts |
1437
+ | `addCleanup(fn)` | Register cleanup to run automatically on unmount |
1438
+ | `watch(source, callback, options?)` | Watch a reactive expression (or a `MaybeGetter` argument directly), auto-disposed on unmount, re-created on remount |
1439
+ | `effect(fn, options?)` | Run auto-tracked side effect, auto-disposed on unmount, re-created on remount |
1440
+ | `sync(arg)` | Normalize a `MaybeGetter` argument into an ordinary field that stays current (one-way, see [Reactive Arguments](#reactive-arguments)) |
714
1441
 
715
1442
  ### `createBehavior(Class)`
716
1443
 
@@ -726,25 +1453,41 @@ export const withMyBehavior = createBehavior(MyBehavior);
726
1453
  // Usage: withMyBehavior('hello')
727
1454
  ```
728
1455
 
729
- ### `createView(ViewClass, templateOrOptions?)`
1456
+ ### `createComponent(ComponentClass, templateOrOptions?)`
730
1457
 
731
- Function that creates a React component from a View class.
1458
+ Function that creates a React component from a Component class.
732
1459
 
733
1460
  ```tsx
734
1461
  // Basic (auto-observable)
735
- createView(MyView)
1462
+ createComponent(MyComponent)
736
1463
 
737
1464
  // With template
738
- createView(MyView, (vm) => <div>{vm.value}</div>)
1465
+ createComponent(MyComponent, (vm) => <div>{vm.value}</div>)
739
1466
 
740
1467
  // With options
741
- createView(MyView, { autoObservable: false })
1468
+ createComponent(MyComponent, { autoObservable: false })
742
1469
  ```
743
1470
 
744
1471
  | Option | Default | Description |
745
1472
  |--------|---------|-------------|
746
1473
  | `autoObservable` | `true` | Make all fields observable. Set to `false` when using decorators. |
747
1474
 
1475
+ ### `useBehavior(factory)`
1476
+
1477
+ 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()`.
1478
+
1479
+ ```tsx
1480
+ const size = useBehavior(() => withWindowSize());
1481
+ ```
1482
+
1483
+ ### `observer(fc)`
1484
+
1485
+ 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.
1486
+
1487
+ ### `toValue(value)` / `MaybeGetter<T>`
1488
+
1489
+ 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).
1490
+
748
1491
  ## Who This Is For
749
1492
 
750
1493
  - Teams using MobX for state management