mobx-mantle 0.1.5

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Craig
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,613 @@
1
+ # mobx-mantle
2
+
3
+ A minimal library that brings MobX reactivity to React components with a familiar class-based API.
4
+
5
+ Full access to the React ecosystem. Better access to vanilla JS libraries. Simpler DX for both.
6
+
7
+ ## Why
8
+
9
+ React hooks solve real problems—stale closures, dependency tracking, memoization. MobX already solves those problems. So if you're using MobX, hooks add complexity without benefit.
10
+
11
+ This library lets you write components in a way that is more familiar to common programming patterns outside the React ecosystem: mutable state, stable references, computed getters, direct method calls.
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ npm install mobx-mantle
17
+ ```
18
+
19
+ Requires React 17+, MobX 6+, and mobx-react-lite 3+.
20
+
21
+ ## Basic Example
22
+
23
+ ```tsx
24
+ import { View, createView } from 'mobx-mantle';
25
+
26
+ interface CounterProps {
27
+ initial: number;
28
+ }
29
+
30
+ class CounterView extends View<CounterProps> {
31
+ count = 0;
32
+
33
+ onCreate() {
34
+ this.count = this.props.initial;
35
+ }
36
+
37
+ increment() {
38
+ this.count++;
39
+ }
40
+
41
+ render() {
42
+ return (
43
+ <button onClick={this.increment}>
44
+ Count: {this.count}
45
+ </button>
46
+ );
47
+ }
48
+ }
49
+
50
+ export const Counter = createView(CounterView);
51
+ ```
52
+
53
+ ## What You Get
54
+
55
+ **Direct mutation:**
56
+ ```tsx
57
+ this.items.push(item); // not setItems(prev => [...prev, item])
58
+ ```
59
+
60
+ **Computed values via getters:**
61
+ ```tsx
62
+ get completed() { // not useMemo(() => items.filter(...), [items])
63
+ return this.items.filter(i => i.done);
64
+ }
65
+ ```
66
+
67
+ **Stable methods (auto-bound):**
68
+ ```tsx
69
+ toggle(id: number) { // automatically bound to this
70
+ const item = this.items.find(i => i.id === id);
71
+ if (item) item.done = !item.done;
72
+ }
73
+
74
+ // use directly, no wrapper needed
75
+ <button onClick={this.toggle} />
76
+ ```
77
+
78
+ **React to changes explicitly:**
79
+ ```tsx
80
+ onMount() {
81
+ return reaction(
82
+ () => this.props.filter,
83
+ (filter) => this.applyFilter(filter)
84
+ );
85
+ }
86
+ ```
87
+
88
+ ## Lifecycle
89
+
90
+ | Method | When |
91
+ |--------|------|
92
+ | `onCreate()` | Instance created, props available |
93
+ | `onLayoutMount()` | DOM ready, before paint. Return a cleanup function (optional). |
94
+ | `onMount()` | Component mounted, after paint. Return a cleanup function (optional). |
95
+ | `onUnmount()` | Component unmounting. Called after cleanups (optional). |
96
+ | `render()` | On mount and updates. Return JSX. |
97
+
98
+ ### Props Reactivity
99
+
100
+ `this.props` is reactive—your component re-renders when accessed props change. Use `reaction` or `autorun` to respond to prop changes:
101
+
102
+ ```tsx
103
+ onMount() {
104
+ return reaction(
105
+ () => this.props.filter,
106
+ (filter) => this.applyFilter(filter)
107
+ );
108
+ }
109
+ ```
110
+
111
+ Or access props directly in `render()` and MobX handles re-renders when they change.
112
+
113
+ ## Patterns
114
+
115
+ ### Combined (default)
116
+
117
+ State, logic, and template in one class:
118
+
119
+ ```tsx
120
+ class TodoView extends View<Props> {
121
+ todos: TodoItem[] = [];
122
+ input = '';
123
+
124
+ add() {
125
+ this.todos.push({ id: Date.now(), text: this.input, done: false });
126
+ this.input = '';
127
+ }
128
+
129
+ setInput(e: React.ChangeEvent<HTMLInputElement>) {
130
+ this.input = e.target.value;
131
+ }
132
+
133
+ render() {
134
+ return (
135
+ <div>
136
+ <input value={this.input} onChange={this.setInput} />
137
+ <button onClick={this.add}>Add</button>
138
+ <ul>{this.todos.map(t => <li key={t.id}>{t.text}</li>)}</ul>
139
+ </div>
140
+ );
141
+ }
142
+ }
143
+
144
+ export const Todo = createView(TodoView);
145
+ ```
146
+
147
+ ### Separated
148
+
149
+ ViewModel and template separate:
150
+
151
+ ```tsx
152
+ import { ViewModel, createView } from 'mobx-mantle';
153
+
154
+ class TodoVM extends ViewModel<Props> {
155
+ todos: TodoItem[] = [];
156
+ input = '';
157
+
158
+ add() {
159
+ this.todos.push({ id: Date.now(), text: this.input, done: false });
160
+ this.input = '';
161
+ }
162
+
163
+ setInput(e: React.ChangeEvent<HTMLInputElement>) {
164
+ this.input = e.target.value;
165
+ }
166
+ }
167
+
168
+ export const Todo = createView(TodoVM, (vm) => (
169
+ <div>
170
+ <input value={vm.input} onChange={vm.setInput} />
171
+ <button onClick={vm.add}>Add</button>
172
+ <ul>{vm.todos.map(t => <li key={t.id}>{t.text}</li>)}</ul>
173
+ </div>
174
+ ));
175
+ ```
176
+
177
+ ### With Decorators
178
+
179
+ For teams that prefer explicit annotations, disable `autoObservable` globally:
180
+
181
+ ```tsx
182
+ // app.tsx (or entry point)
183
+ import { configure } from 'mobx-mantle';
184
+
185
+ configure({ autoObservable: false });
186
+ ```
187
+
188
+ **TC39 Decorators** (recommended, self-registering):
189
+
190
+ ```tsx
191
+ class TodoView extends View<Props> {
192
+ @observable accessor todos: TodoItem[] = [];
193
+ @observable accessor input = '';
194
+
195
+ @action add() {
196
+ this.todos.push({ id: Date.now(), text: this.input, done: false });
197
+ this.input = '';
198
+ }
199
+
200
+ render() {
201
+ return /* ... */;
202
+ }
203
+ }
204
+
205
+ export const Todo = createView(TodoView);
206
+ ```
207
+
208
+ **Legacy Decorators** (experimental, requires `makeObservable`):
209
+
210
+ ```tsx
211
+ class TodoView extends View<Props> {
212
+ @observable todos: TodoItem[] = [];
213
+ @observable input = '';
214
+
215
+ @action add() {
216
+ this.todos.push({ id: Date.now(), text: this.input, done: false });
217
+ this.input = '';
218
+ }
219
+
220
+ render() {
221
+ return /* ... */;
222
+ }
223
+ }
224
+
225
+ export const Todo = createView(TodoView);
226
+ ```
227
+
228
+ Note: `this.props` is always reactive regardless of decorator type.
229
+
230
+ ## Refs
231
+
232
+ ```tsx
233
+ class FormView extends View<Props> {
234
+ inputRef = this.ref<HTMLInputElement>();
235
+
236
+ onMount() {
237
+ this.inputRef.current?.focus();
238
+ }
239
+
240
+ render() {
241
+ return <input ref={this.inputRef} />;
242
+ }
243
+ }
244
+ ```
245
+
246
+ ### Forwarding Refs
247
+
248
+ Expose a DOM element to parent components via `this.forwardRef`:
249
+
250
+ ```tsx
251
+ class FancyInputView extends View<InputProps> {
252
+ render() {
253
+ return <input ref={this.forwardRef} className="fancy-input" />;
254
+ }
255
+ }
256
+
257
+ export const FancyInput = createView(FancyInputView);
258
+
259
+ // Parent can now get a ref to the underlying input:
260
+ function Parent() {
261
+ const inputRef = useRef<HTMLInputElement>(null);
262
+
263
+ return (
264
+ <>
265
+ <FancyInput ref={inputRef} placeholder="Type here..." />
266
+ <button onClick={() => inputRef.current?.focus()}>Focus</button>
267
+ </>
268
+ );
269
+ }
270
+ ```
271
+
272
+ ## Reactions
273
+
274
+ ```tsx
275
+ class SearchView extends View<Props> {
276
+ query = '';
277
+ results: string[] = [];
278
+
279
+ onMount() {
280
+ const dispose = reaction(
281
+ () => this.query,
282
+ async (query) => {
283
+ if (query.length > 2) {
284
+ this.results = await searchApi(query);
285
+ }
286
+ },
287
+ { delay: 300 }
288
+ );
289
+
290
+ return dispose;
291
+ }
292
+
293
+ setQuery(e: React.ChangeEvent<HTMLInputElement>) {
294
+ this.query = e.target.value;
295
+ }
296
+
297
+ render() {
298
+ return (
299
+ <div>
300
+ <input value={this.query} onChange={this.setQuery} />
301
+ <ul>{this.results.map(r => <li key={r}>{r}</li>)}</ul>
302
+ </div>
303
+ );
304
+ }
305
+ }
306
+ ```
307
+
308
+ ## React Hooks
309
+
310
+ Hooks work inside `render()`:
311
+
312
+ ```tsx
313
+ class DataView extends View<{ id: string }> {
314
+ render() {
315
+ const navigate = useNavigate();
316
+ const { data, isLoading } = useQuery({
317
+ queryKey: ['item', this.props.id],
318
+ queryFn: () => fetchItem(this.props.id),
319
+ });
320
+
321
+ if (isLoading) return <div>Loading...</div>;
322
+
323
+ return (
324
+ <div onClick={() => navigate('/home')}>
325
+ {data.name}
326
+ </div>
327
+ );
328
+ }
329
+ }
330
+ ```
331
+
332
+ ## Vanilla JS Integration
333
+
334
+ Imperative libraries become straightforward:
335
+
336
+ ```tsx
337
+ class ChartView extends View<{ data: number[] }> {
338
+ containerRef = this.ref<HTMLDivElement>();
339
+ chart: Chart | null = null;
340
+
341
+ onMount() {
342
+ this.chart = new Chart(this.containerRef.current!, {
343
+ data: this.props.data,
344
+ });
345
+
346
+ const dispose = reaction(
347
+ () => this.props.data,
348
+ (data) => this.chart?.update(data)
349
+ );
350
+
351
+ return () => {
352
+ dispose();
353
+ this.chart?.destroy();
354
+ };
355
+ }
356
+
357
+ render() {
358
+ return <div ref={this.containerRef} />;
359
+ }
360
+ }
361
+ ```
362
+
363
+ Compare to hooks:
364
+
365
+ ```tsx
366
+ function ChartView({ data }) {
367
+ const containerRef = useRef();
368
+ const chartRef = useRef();
369
+
370
+ useEffect(() => {
371
+ chartRef.current = new Chart(containerRef.current, { data });
372
+ return () => chartRef.current.destroy();
373
+ }, []);
374
+
375
+ useEffect(() => {
376
+ chartRef.current?.update(data);
377
+ }, [data]);
378
+
379
+ return <div ref={containerRef} />;
380
+ }
381
+ ```
382
+
383
+ Split effects, multiple refs, dependency tracking—all unnecessary with mobx-mantle.
384
+
385
+ ## Behaviors
386
+
387
+ Behaviors are reusable pieces of state and logic that can be shared across views. Define them as plain classes, wrap with `createBehavior()`, and instantiate them in your Views.
388
+
389
+ ### Basic Behavior
390
+
391
+ ```tsx
392
+ import { Behavior, createBehavior } from 'mobx-mantle';
393
+
394
+ class WindowSizeBehavior extends Behavior {
395
+ width = window.innerWidth;
396
+ height = window.innerHeight;
397
+
398
+ onMount() {
399
+ const handler = () => {
400
+ this.width = window.innerWidth;
401
+ this.height = window.innerHeight;
402
+ };
403
+ window.addEventListener('resize', handler);
404
+ return () => window.removeEventListener('resize', handler);
405
+ }
406
+ }
407
+
408
+ export default createBehavior(WindowSizeBehavior);
409
+ ```
410
+
411
+ Use it in a View by instantiating it directly:
412
+
413
+ ```tsx
414
+ import WindowSizeBehavior from './WindowSizeBehavior';
415
+
416
+ class ResponsiveView extends View<Props> {
417
+ windowSize = new WindowSizeBehavior();
418
+
419
+ get isMobile() {
420
+ return this.windowSize.width < 768;
421
+ }
422
+
423
+ render() {
424
+ return (
425
+ <div>
426
+ {this.isMobile ? <MobileLayout /> : <DesktopLayout />}
427
+ <p>Window: {this.windowSize.width}x{this.windowSize.height}</p>
428
+ </div>
429
+ );
430
+ }
431
+ }
432
+
433
+ export const Responsive = createView(ResponsiveView);
434
+ ```
435
+
436
+ ### Behaviors with Arguments
437
+
438
+ Pass arguments via constructor OR `onCreate()`—your choice:
439
+
440
+ **Option 1: Constructor args** (TypeScript shorthand)
441
+ ```tsx
442
+ class FetchBehavior extends Behavior {
443
+ data: Item[] = [];
444
+ loading = false;
445
+
446
+ constructor(public url: string, public interval = 5000) {
447
+ super();
448
+ }
449
+
450
+ onMount() {
451
+ this.fetchData();
452
+ const id = setInterval(() => this.fetchData(), this.interval);
453
+ return () => clearInterval(id);
454
+ }
455
+
456
+ async fetchData() {
457
+ this.loading = true;
458
+ this.data = await fetch(this.url).then(r => r.json());
459
+ this.loading = false;
460
+ }
461
+ }
462
+
463
+ export default createBehavior(FetchBehavior);
464
+ ```
465
+
466
+ **Option 2: onCreate args** (no constructor boilerplate)
467
+ ```tsx
468
+ class FetchBehavior extends Behavior {
469
+ url!: string;
470
+ interval = 5000;
471
+ data: Item[] = [];
472
+ loading = false;
473
+
474
+ onCreate(url: string, interval = 5000) {
475
+ this.url = url;
476
+ this.interval = interval;
477
+ }
478
+
479
+ onMount() {
480
+ this.fetchData();
481
+ const id = setInterval(() => this.fetchData(), this.interval);
482
+ return () => clearInterval(id);
483
+ }
484
+
485
+ async fetchData() {
486
+ this.loading = true;
487
+ this.data = await fetch(this.url).then(r => r.json());
488
+ this.loading = false;
489
+ }
490
+ }
491
+
492
+ export default createBehavior(FetchBehavior);
493
+ ```
494
+
495
+ ```tsx
496
+ // Constructor args come from onCreate signature
497
+ class MyView extends View<Props> {
498
+ users = new FetchBehavior('/api/users', 10000);
499
+ posts = new FetchBehavior('/api/posts'); // interval defaults to 5000
500
+
501
+ render() {
502
+ return (
503
+ <div>
504
+ {this.users.loading ? 'Loading...' : `${this.users.data.length} users`}
505
+ </div>
506
+ );
507
+ }
508
+ }
509
+ ```
510
+
511
+ ### Behavior Lifecycle
512
+
513
+ Behaviors support the same lifecycle methods as Views:
514
+
515
+ | Method | When |
516
+ |--------|------|
517
+ | `onCreate(...args)` | Called during construction with the constructor arguments |
518
+ | `onLayoutMount()` | Called when parent View layout mounts (before paint). Return cleanup (optional). |
519
+ | `onMount()` | Called when parent View mounts (after paint). Return cleanup (optional). |
520
+ | `onUnmount()` | Called when parent View unmounts, after cleanups (optional). |
521
+
522
+
523
+ ## API
524
+
525
+ ### `configure(config)`
526
+
527
+ Set global defaults for all views. Settings can still be overridden per-view in `createView` options.
528
+
529
+ ```tsx
530
+ import { configure } from 'mobx-mantle';
531
+
532
+ // Disable auto-observable globally (for decorator users)
533
+ configure({ autoObservable: false });
534
+ ```
535
+
536
+ | Option | Default | Description |
537
+ |--------|---------|-------------|
538
+ | `autoObservable` | `true` | Whether to automatically make View instances observable |
539
+
540
+ ### `View<P>` / `ViewModel<P>`
541
+
542
+ Base class for view components. `ViewModel` is an alias for `View`—use it when separating the ViewModel from the template for semantic clarity.
543
+
544
+ | Property/Method | Description |
545
+ |-----------------|-------------|
546
+ | `props` | Current props (reactive) |
547
+ | `forwardRef` | Ref passed from parent component (for ref forwarding) |
548
+ | `onCreate()` | Called when instance created |
549
+ | `onLayoutMount()` | Called before paint, return cleanup (optional) |
550
+ | `onMount()` | Called after paint, return cleanup (optional) |
551
+ | `onUnmount()` | Called on unmount, after cleanups (optional) |
552
+ | `render()` | Return JSX (optional if using template) |
553
+ | `ref<T>()` | Create a ref for DOM elements |
554
+
555
+ ### `Behavior`
556
+
557
+ Base class for behaviors. Extend it and wrap with `createBehavior()`.
558
+
559
+ | Method | Description |
560
+ |--------|-------------|
561
+ | `onCreate(...args)` | Called during construction with constructor args |
562
+ | `onLayoutMount()` | Called before paint, return cleanup (optional) |
563
+ | `onMount()` | Called after paint, return cleanup (optional) |
564
+ | `onUnmount()` | Called when parent View unmounts |
565
+
566
+ ### `createBehavior(Class)`
567
+
568
+ Wraps a behavior class for automatic observable wrapping and lifecycle management.
569
+
570
+ ```tsx
571
+ // Constructor args
572
+ class MyBehavior extends Behavior {
573
+ constructor(public value: string) { super(); }
574
+ }
575
+
576
+ // OR onCreate args
577
+ class MyBehavior extends Behavior {
578
+ value!: string;
579
+ onCreate(value: string) { this.value = value; }
580
+ }
581
+
582
+ export default createBehavior(MyBehavior);
583
+ ```
584
+
585
+ ### `createView(ViewClass, templateOrOptions?)`
586
+
587
+ Creates a React component from a View class.
588
+
589
+ ```tsx
590
+ // Basic
591
+ createView(MyView)
592
+
593
+ // With template
594
+ createView(MyView, (vm) => <div>{vm.value}</div>)
595
+
596
+ // With options
597
+ createView(MyView, { autoObservable: false })
598
+ ```
599
+
600
+ | Option | Default | Description |
601
+ |--------|---------|-------------|
602
+ | `autoObservable` | `true` | Use `makeAutoObservable`. Set to `false` for decorators. |
603
+
604
+ ## Who This Is For
605
+
606
+ - Teams using MobX for state management
607
+ - Developers from other platforms (mobile, backend, other frameworks)
608
+ - Projects integrating vanilla JS libraries
609
+ - Anyone tired of dependency arrays
610
+
611
+ ## License
612
+
613
+ MIT