storion 0.3.0 → 0.5.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 +607 -18
- package/dist/async/async.d.ts +33 -1
- package/dist/async/async.d.ts.map +1 -1
- package/dist/async/index.d.ts +1 -1
- package/dist/async/index.d.ts.map +1 -1
- package/dist/async/index.js +98 -16
- package/dist/core/container.d.ts +6 -12
- package/dist/core/container.d.ts.map +1 -1
- package/dist/core/createResolver.d.ts.map +1 -1
- package/dist/core/effect.d.ts.map +1 -1
- package/dist/core/middleware.d.ts +28 -15
- package/dist/core/middleware.d.ts.map +1 -1
- package/dist/core/store.d.ts.map +1 -1
- package/dist/core/storeContext.d.ts.map +1 -1
- package/dist/devtools/index.js +72 -59
- package/dist/devtools/middleware.d.ts +2 -2
- package/dist/devtools/middleware.d.ts.map +1 -1
- package/dist/devtools-panel/index.js +48 -8
- package/dist/devtools-panel/mount.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/react/index.js +5 -4
- package/dist/{store-XP2pujaJ.js → store-CwA4YTVb.js} +11 -4
- package/dist/storion.js +242 -227
- package/dist/types.d.ts +104 -45
- package/dist/types.d.ts.map +1 -1
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -315,9 +315,41 @@ export const settingsStore = store({
|
|
|
315
315
|
|
|
316
316
|
### Reactive Effects
|
|
317
317
|
|
|
318
|
-
**The problem:** You need to sync with external systems (WebSocket, localStorage
|
|
318
|
+
**The problem:** You need to sync with external systems (WebSocket, localStorage) or compute derived state when dependencies change, and properly clean up when needed.
|
|
319
319
|
|
|
320
|
-
**With Storion:** Effects automatically track which state properties you read and re-run only when those change.
|
|
320
|
+
**With Storion:** Effects automatically track which state properties you read and re-run only when those change. Use them for side effects or computed state.
|
|
321
|
+
|
|
322
|
+
**Example 1: Computed/Derived State**
|
|
323
|
+
|
|
324
|
+
```ts
|
|
325
|
+
import { store, effect } from "storion";
|
|
326
|
+
|
|
327
|
+
export const userStore = store({
|
|
328
|
+
name: "user",
|
|
329
|
+
state: {
|
|
330
|
+
firstName: "",
|
|
331
|
+
lastName: "",
|
|
332
|
+
fullName: "", // Computed from firstName + lastName
|
|
333
|
+
},
|
|
334
|
+
setup({ state }) {
|
|
335
|
+
// Auto-updates fullName when firstName or lastName changes
|
|
336
|
+
effect(() => {
|
|
337
|
+
state.fullName = `${state.firstName} ${state.lastName}`.trim();
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
return {
|
|
341
|
+
setFirstName: (name: string) => {
|
|
342
|
+
state.firstName = name;
|
|
343
|
+
},
|
|
344
|
+
setLastName: (name: string) => {
|
|
345
|
+
state.lastName = name;
|
|
346
|
+
},
|
|
347
|
+
};
|
|
348
|
+
},
|
|
349
|
+
});
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
**Example 2: External System Sync**
|
|
321
353
|
|
|
322
354
|
```ts
|
|
323
355
|
import { store, effect } from "storion";
|
|
@@ -330,7 +362,6 @@ export const syncStore = store({
|
|
|
330
362
|
},
|
|
331
363
|
setup({ state }) {
|
|
332
364
|
effect((ctx) => {
|
|
333
|
-
// Effect tracks state.userId and re-runs when it changes
|
|
334
365
|
if (!state.userId) return;
|
|
335
366
|
|
|
336
367
|
const ws = new WebSocket(`/ws?user=${state.userId}`);
|
|
@@ -391,23 +422,141 @@ effect((ctx) => {
|
|
|
391
422
|
```tsx
|
|
392
423
|
import { pick } from "storion";
|
|
393
424
|
|
|
394
|
-
function
|
|
425
|
+
function UserProfile() {
|
|
395
426
|
// Without pick: re-renders when ANY profile property changes
|
|
396
427
|
const { name } = useStore(({ get }) => {
|
|
397
428
|
const [state] = get(userStore);
|
|
398
429
|
return { name: state.profile.name };
|
|
399
430
|
});
|
|
400
431
|
|
|
401
|
-
// With pick: re-renders ONLY when
|
|
402
|
-
|
|
432
|
+
// With pick: re-renders ONLY when the picked value changes
|
|
433
|
+
// Multiple picks can be used in one selector
|
|
434
|
+
const { name, fullName, coords, settings, nested } = useStore(({ get }) => {
|
|
403
435
|
const [state] = get(userStore);
|
|
404
|
-
return {
|
|
436
|
+
return {
|
|
437
|
+
// Simple pick - uses default equality (===)
|
|
438
|
+
name: pick(() => state.profile.name),
|
|
439
|
+
|
|
440
|
+
// Computed value - only re-renders when result changes
|
|
441
|
+
fullName: pick(() => `${state.profile.first} ${state.profile.last}`),
|
|
442
|
+
|
|
443
|
+
// 'shallow' - compares object properties one level deep
|
|
444
|
+
coords: pick(
|
|
445
|
+
() => ({ x: state.position.x, y: state.position.y }),
|
|
446
|
+
"shallow"
|
|
447
|
+
),
|
|
448
|
+
|
|
449
|
+
// 'deep' - recursively compares nested objects/arrays
|
|
450
|
+
settings: pick(() => state.userSettings, "deep"),
|
|
451
|
+
|
|
452
|
+
// Custom equality function - full control
|
|
453
|
+
nested: pick(
|
|
454
|
+
() => state.data.items.map((i) => i.id),
|
|
455
|
+
(a, b) => a.length === b.length && a.every((id, i) => id === b[i])
|
|
456
|
+
),
|
|
457
|
+
};
|
|
405
458
|
});
|
|
406
459
|
|
|
407
460
|
return <h1>{name}</h1>;
|
|
408
461
|
}
|
|
409
462
|
```
|
|
410
463
|
|
|
464
|
+
### Understanding Equality: Store vs Component Level
|
|
465
|
+
|
|
466
|
+
Storion provides two layers of equality control, each solving different problems:
|
|
467
|
+
|
|
468
|
+
| Layer | API | When it runs | Purpose |
|
|
469
|
+
| -------------------- | ----------------- | --------------------- | ------------------------------------------------ |
|
|
470
|
+
| **Store (write)** | `equality` option | When state is mutated | Prevent unnecessary notifications to subscribers |
|
|
471
|
+
| **Component (read)** | `pick(fn, eq)` | When selector runs | Prevent unnecessary re-renders |
|
|
472
|
+
|
|
473
|
+
```
|
|
474
|
+
┌─────────────────────────────────────────────────────────────────────┐
|
|
475
|
+
│ Store │
|
|
476
|
+
│ ┌──────────────────────────────────────────────────────────────┐ │
|
|
477
|
+
│ │ state.coords = { x: 1, y: 2 } │ │
|
|
478
|
+
│ │ │ │ │
|
|
479
|
+
│ │ ▼ │ │
|
|
480
|
+
│ │ equality: { coords: "shallow" } ──► Same x,y? Skip notify │ │
|
|
481
|
+
│ └──────────────────────────────────────────────────────────────┘ │
|
|
482
|
+
│ │ │
|
|
483
|
+
│ notify if changed │
|
|
484
|
+
│ ▼ │
|
|
485
|
+
│ ┌──────────────────────────────────────────────────────────────┐ │
|
|
486
|
+
│ │ Component A │ Component B │ │
|
|
487
|
+
│ │ pick(() => coords.x) │ pick(() => coords, "shallow")│ │
|
|
488
|
+
│ │ │ │ │ │ │
|
|
489
|
+
│ │ ▼ │ ▼ │ │
|
|
490
|
+
│ │ Re-render if x changed │ Re-render if x OR y changed │ │
|
|
491
|
+
│ └──────────────────────────────────────────────────────────────┘ │
|
|
492
|
+
└─────────────────────────────────────────────────────────────────────┘
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
**Example: Coordinates update**
|
|
496
|
+
|
|
497
|
+
```ts
|
|
498
|
+
// Store level - controls when subscribers get notified
|
|
499
|
+
const mapStore = store({
|
|
500
|
+
state: { coords: { x: 0, y: 0 }, zoom: 1 },
|
|
501
|
+
equality: {
|
|
502
|
+
coords: "shallow", // Don't notify if same { x, y } values
|
|
503
|
+
},
|
|
504
|
+
setup({ state }) {
|
|
505
|
+
return {
|
|
506
|
+
setCoords: (x: number, y: number) => {
|
|
507
|
+
state.coords = { x, y }; // New object, but shallow-equal = no notify
|
|
508
|
+
},
|
|
509
|
+
};
|
|
510
|
+
},
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
// Component level - controls when THIS component re-renders
|
|
514
|
+
function XCoordinate() {
|
|
515
|
+
const { x } = useStore(({ get }) => {
|
|
516
|
+
const [state] = get(mapStore);
|
|
517
|
+
return {
|
|
518
|
+
// Even if coords changed, only re-render if x specifically changed
|
|
519
|
+
x: pick(() => state.coords.x),
|
|
520
|
+
};
|
|
521
|
+
});
|
|
522
|
+
return <span>X: {x}</span>;
|
|
523
|
+
}
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
### Comparison with Other Libraries
|
|
527
|
+
|
|
528
|
+
| Feature | Storion | Redux | Zustand | Jotai | MobX |
|
|
529
|
+
| ------------------ | --------------------- | ------------------------ | ---------------- | ----------------- | --------------- |
|
|
530
|
+
| **Tracking** | Automatic | Manual selectors | Manual selectors | Automatic (atoms) | Automatic |
|
|
531
|
+
| **Write equality** | Per-property config | Reducer-based | Built-in shallow | Per-atom | Deep by default |
|
|
532
|
+
| **Read equality** | `pick()` with options | `useSelector` + equality | `shallow` helper | Atom-level | Computed |
|
|
533
|
+
| **Granularity** | Property + component | Selector-based | Selector-based | Atom-based | Property-based |
|
|
534
|
+
| **Bundle size** | ~4KB | ~2KB + toolkit | ~1KB | ~2KB | ~15KB |
|
|
535
|
+
| **DI / Lifecycle** | Built-in container | External (thunk/saga) | External | Provider-based | External |
|
|
536
|
+
|
|
537
|
+
**Key differences:**
|
|
538
|
+
|
|
539
|
+
- **Redux/Zustand**: You write selectors manually and pass equality functions to `useSelector`. Easy to forget and over-subscribe.
|
|
540
|
+
|
|
541
|
+
```ts
|
|
542
|
+
// Zustand - must remember to add shallow
|
|
543
|
+
const coords = useStore((s) => s.coords, shallow);
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
- **Jotai**: Fine-grained via atoms, but requires splitting state into many atoms upfront.
|
|
547
|
+
|
|
548
|
+
```ts
|
|
549
|
+
// Jotai - must create separate atoms
|
|
550
|
+
const xAtom = atom((get) => get(coordsAtom).x);
|
|
551
|
+
```
|
|
552
|
+
|
|
553
|
+
- **Storion**: Auto-tracking by default, `pick()` for opt-in fine-grained control, store-level equality for write optimization.
|
|
554
|
+
|
|
555
|
+
```ts
|
|
556
|
+
// Storion - automatic tracking, pick() when you need precision
|
|
557
|
+
const x = pick(() => state.coords.x);
|
|
558
|
+
```
|
|
559
|
+
|
|
411
560
|
### Async State Management
|
|
412
561
|
|
|
413
562
|
**The problem:** Every async operation needs loading, error, and success states. You write the same boilerplate: `isLoading`, `error`, `data`, plus handling race conditions, retries, and cancellation.
|
|
@@ -494,11 +643,323 @@ function ProductList() {
|
|
|
494
643
|
}
|
|
495
644
|
```
|
|
496
645
|
|
|
646
|
+
### When to Fetch Data
|
|
647
|
+
|
|
648
|
+
Storion provides multiple patterns for data fetching. Choose based on your use case:
|
|
649
|
+
|
|
650
|
+
| Pattern | When to use | Example |
|
|
651
|
+
| ----------------------- | ---------------------------------------------- | ---------------------------------- |
|
|
652
|
+
| **Setup time** | Data needed immediately when store initializes | App config, user session |
|
|
653
|
+
| **Trigger (no deps)** | One-time fetch when component mounts | Initial page data |
|
|
654
|
+
| **Trigger (with deps)** | Refetch when component visits or deps change | Dashboard refresh |
|
|
655
|
+
| **useEffect** | Standard React pattern, explicit control | Compatibility with existing code |
|
|
656
|
+
| **User interaction** | On-demand fetching | Search, pagination, refresh button |
|
|
657
|
+
|
|
658
|
+
```tsx
|
|
659
|
+
import { store } from "storion";
|
|
660
|
+
import { async, type AsyncState } from "storion/async";
|
|
661
|
+
import { useStore } from "storion/react";
|
|
662
|
+
import { useEffect } from "react";
|
|
663
|
+
|
|
664
|
+
interface User {
|
|
665
|
+
id: string;
|
|
666
|
+
name: string;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
export const userStore = store({
|
|
670
|
+
name: "users",
|
|
671
|
+
state: {
|
|
672
|
+
currentUser: async.fresh<User>(),
|
|
673
|
+
searchResults: async.stale<User[]>([]),
|
|
674
|
+
},
|
|
675
|
+
setup({ focus, effect }) {
|
|
676
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
677
|
+
// Pattern 1: Fetch at SETUP TIME
|
|
678
|
+
// Data is fetched immediately when store is created
|
|
679
|
+
// Good for: App config, auth state, critical data
|
|
680
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
681
|
+
const currentUserAsync = async(focus("currentUser"), async (ctx) => {
|
|
682
|
+
const res = await fetch("/api/me", { signal: ctx.signal });
|
|
683
|
+
return res.json();
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
// Fetch immediately during setup
|
|
687
|
+
currentUserAsync.dispatch();
|
|
688
|
+
|
|
689
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
690
|
+
// Pattern 2: Expose DISPATCH for UI control
|
|
691
|
+
// Store provides action, UI decides when to call
|
|
692
|
+
// Good for: Search, pagination, user-triggered refresh
|
|
693
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
694
|
+
const searchAsync = async(
|
|
695
|
+
focus("searchResults"),
|
|
696
|
+
async (ctx, query: string) => {
|
|
697
|
+
const res = await fetch(`/api/users/search?q=${query}`, {
|
|
698
|
+
signal: ctx.signal,
|
|
699
|
+
});
|
|
700
|
+
return res.json();
|
|
701
|
+
}
|
|
702
|
+
);
|
|
703
|
+
|
|
704
|
+
return {
|
|
705
|
+
currentUser: currentUserAsync,
|
|
706
|
+
search: searchAsync.dispatch,
|
|
707
|
+
cancelSearch: searchAsync.cancel,
|
|
708
|
+
};
|
|
709
|
+
},
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
713
|
+
// Pattern 3: TRIGGER with dependencies
|
|
714
|
+
// Uses useStore's `trigger` for declarative data fetching
|
|
715
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
716
|
+
|
|
717
|
+
// 3a. No deps - fetch ONCE when component mounts
|
|
718
|
+
function UserProfile() {
|
|
719
|
+
const { user } = useStore(({ get, trigger }) => {
|
|
720
|
+
const [state, actions] = get(userStore);
|
|
721
|
+
|
|
722
|
+
// No deps array = fetch once, never refetch
|
|
723
|
+
trigger(actions.currentUser.dispatch, []);
|
|
724
|
+
|
|
725
|
+
return { user: state.currentUser };
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
if (user.status === "pending") return <Spinner />;
|
|
729
|
+
return <div>{user.data?.name}</div>;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// 3b. With context.id - refetch EVERY TIME component visits
|
|
733
|
+
function Dashboard() {
|
|
734
|
+
const { user } = useStore(({ get, trigger, id }) => {
|
|
735
|
+
const [state, actions] = get(userStore);
|
|
736
|
+
|
|
737
|
+
// `id` changes each time component mounts = refetch on every visit
|
|
738
|
+
trigger(actions.currentUser.dispatch, [id]);
|
|
739
|
+
|
|
740
|
+
return { user: state.currentUser };
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
return <div>Welcome back, {user.data?.name}</div>;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// 3c. With custom deps - refetch when deps change
|
|
747
|
+
function UserById({ userId }: { userId: string }) {
|
|
748
|
+
const { user } = useStore(
|
|
749
|
+
({ get, trigger }) => {
|
|
750
|
+
const [state, actions] = get(userStore);
|
|
751
|
+
|
|
752
|
+
// Refetch when userId prop changes
|
|
753
|
+
trigger(() => actions.currentUser.dispatch(), [userId]);
|
|
754
|
+
|
|
755
|
+
return { user: state.currentUser };
|
|
756
|
+
},
|
|
757
|
+
[userId] // selector deps for proper tracking
|
|
758
|
+
);
|
|
759
|
+
|
|
760
|
+
return <div>{user.data?.name}</div>;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
764
|
+
// Pattern 4: useEffect - standard React pattern
|
|
765
|
+
// For compatibility or when you need more control
|
|
766
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
767
|
+
function UserListWithEffect() {
|
|
768
|
+
const { search } = useStore(({ get }) => {
|
|
769
|
+
const [, actions] = get(userStore);
|
|
770
|
+
return { search: actions.search };
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
useEffect(() => {
|
|
774
|
+
search("initial");
|
|
775
|
+
}, []);
|
|
776
|
+
|
|
777
|
+
return <div>...</div>;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
781
|
+
// Pattern 5: USER INTERACTION - on-demand fetching
|
|
782
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
783
|
+
function SearchBox() {
|
|
784
|
+
const [query, setQuery] = useState("");
|
|
785
|
+
const { results, search, cancel } = useStore(({ get }) => {
|
|
786
|
+
const [state, actions] = get(userStore);
|
|
787
|
+
return {
|
|
788
|
+
results: state.searchResults,
|
|
789
|
+
search: actions.search,
|
|
790
|
+
cancel: actions.cancelSearch,
|
|
791
|
+
};
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
const handleSearch = () => {
|
|
795
|
+
cancel(); // Cancel previous search
|
|
796
|
+
search(query);
|
|
797
|
+
};
|
|
798
|
+
|
|
799
|
+
return (
|
|
800
|
+
<div>
|
|
801
|
+
<input value={query} onChange={(e) => setQuery(e.target.value)} />
|
|
802
|
+
<button onClick={handleSearch}>Search</button>
|
|
803
|
+
<button onClick={cancel}>Cancel</button>
|
|
804
|
+
|
|
805
|
+
{results.status === "pending" && <Spinner />}
|
|
806
|
+
{results.data?.map((user) => (
|
|
807
|
+
<div key={user.id}>{user.name}</div>
|
|
808
|
+
))}
|
|
809
|
+
</div>
|
|
810
|
+
);
|
|
811
|
+
}
|
|
812
|
+
```
|
|
813
|
+
|
|
814
|
+
**Summary: Choosing the right pattern**
|
|
815
|
+
|
|
816
|
+
```
|
|
817
|
+
┌─────────────────────────────────────────────────────────────────────────┐
|
|
818
|
+
│ When should data be fetched? │
|
|
819
|
+
├─────────────────────────────────────────────────────────────────────────┤
|
|
820
|
+
│ │
|
|
821
|
+
│ App starts? ──────────► Setup time (dispatch in setup) │
|
|
822
|
+
│ │
|
|
823
|
+
│ Component mounts? │
|
|
824
|
+
│ │ │
|
|
825
|
+
│ ├── Once ever? ────► trigger(fn, []) │
|
|
826
|
+
│ │ │
|
|
827
|
+
│ ├── Every visit? ──► trigger(fn, [id]) │
|
|
828
|
+
│ │ │
|
|
829
|
+
│ └── When deps change? ► trigger(fn, [dep1, dep2]) │
|
|
830
|
+
│ │
|
|
831
|
+
│ User clicks? ──────────► onClick handler calls action │
|
|
832
|
+
│ │
|
|
833
|
+
└─────────────────────────────────────────────────────────────────────────┘
|
|
834
|
+
```
|
|
835
|
+
|
|
836
|
+
### Suspense Pattern with `async.wait()`
|
|
837
|
+
|
|
838
|
+
**The problem:** You want to use React Suspense for loading states, but managing the "throw promise" pattern manually is complex and error-prone.
|
|
839
|
+
|
|
840
|
+
**With Storion:** Use `async.wait()` to extract data from async state — it throws a promise if pending (triggering Suspense) or throws the error if failed.
|
|
841
|
+
|
|
842
|
+
```tsx
|
|
843
|
+
import { Suspense } from "react";
|
|
844
|
+
import { async } from "storion/async";
|
|
845
|
+
import { useStore } from "storion/react";
|
|
846
|
+
|
|
847
|
+
// Component that uses Suspense - no loading/error handling needed!
|
|
848
|
+
function UserProfile() {
|
|
849
|
+
const { user } = useStore(({ get, trigger }) => {
|
|
850
|
+
const [state, actions] = get(userStore);
|
|
851
|
+
|
|
852
|
+
// Trigger fetch on mount
|
|
853
|
+
trigger(actions.fetchUser, []);
|
|
854
|
+
|
|
855
|
+
return {
|
|
856
|
+
// async.wait() throws if pending/error, returns data if success
|
|
857
|
+
user: async.wait(state.currentUser),
|
|
858
|
+
};
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
// This only renders when data is ready
|
|
862
|
+
return (
|
|
863
|
+
<div>
|
|
864
|
+
<h1>{user.name}</h1>
|
|
865
|
+
<p>{user.email}</p>
|
|
866
|
+
</div>
|
|
867
|
+
);
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// Parent wraps with Suspense and ErrorBoundary
|
|
871
|
+
function App() {
|
|
872
|
+
return (
|
|
873
|
+
<ErrorBoundary fallback={<div>Something went wrong</div>}>
|
|
874
|
+
<Suspense fallback={<Spinner />}>
|
|
875
|
+
<UserProfile />
|
|
876
|
+
</Suspense>
|
|
877
|
+
</ErrorBoundary>
|
|
878
|
+
);
|
|
879
|
+
}
|
|
880
|
+
```
|
|
881
|
+
|
|
882
|
+
**Multiple async states with `async.all()`:**
|
|
883
|
+
|
|
884
|
+
```tsx
|
|
885
|
+
function Dashboard() {
|
|
886
|
+
const { user, posts, comments } = useStore(({ get, trigger }) => {
|
|
887
|
+
const [userState, userActions] = get(userStore);
|
|
888
|
+
const [postState, postActions] = get(postStore);
|
|
889
|
+
const [commentState, commentActions] = get(commentStore);
|
|
890
|
+
|
|
891
|
+
trigger(userActions.fetch, []);
|
|
892
|
+
trigger(postActions.fetch, []);
|
|
893
|
+
trigger(commentActions.fetch, []);
|
|
894
|
+
|
|
895
|
+
// Wait for ALL async states - suspends until all are ready
|
|
896
|
+
const [user, posts, comments] = async.all(
|
|
897
|
+
userState.current,
|
|
898
|
+
postState.list,
|
|
899
|
+
commentState.recent
|
|
900
|
+
);
|
|
901
|
+
|
|
902
|
+
return { user, posts, comments };
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
return (
|
|
906
|
+
<div>
|
|
907
|
+
<h1>Welcome, {user.name}</h1>
|
|
908
|
+
<PostList posts={posts} />
|
|
909
|
+
<CommentList comments={comments} />
|
|
910
|
+
</div>
|
|
911
|
+
);
|
|
912
|
+
}
|
|
913
|
+
```
|
|
914
|
+
|
|
915
|
+
**Race pattern with `async.race()`:**
|
|
916
|
+
|
|
917
|
+
```tsx
|
|
918
|
+
function FastestResult() {
|
|
919
|
+
const { result } = useStore(({ get, trigger }) => {
|
|
920
|
+
const [state, actions] = get(searchStore);
|
|
921
|
+
|
|
922
|
+
trigger(() => {
|
|
923
|
+
actions.searchAPI1(query);
|
|
924
|
+
actions.searchAPI2(query);
|
|
925
|
+
}, [query]);
|
|
926
|
+
|
|
927
|
+
// Returns whichever finishes first
|
|
928
|
+
return {
|
|
929
|
+
result: async.race(state.api1Results, state.api2Results),
|
|
930
|
+
};
|
|
931
|
+
});
|
|
932
|
+
|
|
933
|
+
return <ResultList items={result} />;
|
|
934
|
+
}
|
|
935
|
+
```
|
|
936
|
+
|
|
937
|
+
**Async helpers summary:**
|
|
938
|
+
|
|
939
|
+
| Helper | Behavior | Use case |
|
|
940
|
+
| ------------------------ | ------------------------------------- | ------------------------- |
|
|
941
|
+
| `async.wait(state)` | Throws if pending/error, returns data | Single Suspense resource |
|
|
942
|
+
| `async.all(...states)` | Waits for all, returns tuple | Multiple parallel fetches |
|
|
943
|
+
| `async.any(...states)` | Returns first successful | Fallback sources |
|
|
944
|
+
| `async.race(states)` | Returns fastest | Competitive fetching |
|
|
945
|
+
| `async.hasData(state)` | `boolean` | Check without suspending |
|
|
946
|
+
| `async.isLoading(state)` | `boolean` | Loading indicator |
|
|
947
|
+
| `async.isError(state)` | `boolean` | Error check |
|
|
948
|
+
|
|
497
949
|
### Dependency Injection
|
|
498
950
|
|
|
499
|
-
**The problem:** Your stores need shared services (API clients, loggers, config) but
|
|
951
|
+
**The problem:** Your stores need shared services (API clients, loggers, config) but importing singletons directly causes issues:
|
|
952
|
+
|
|
953
|
+
- **No lifecycle management** — ES imports are forever; you can't dispose or recreate instances
|
|
954
|
+
- **Testing is painful** — Mocking ES modules requires awkward workarounds
|
|
955
|
+
- **No cleanup** — Resources like connections, intervals, or subscriptions leak between tests
|
|
956
|
+
|
|
957
|
+
**With Storion:** The container is a full DI system that manages the complete lifecycle:
|
|
500
958
|
|
|
501
|
-
**
|
|
959
|
+
- **Automatic caching** — Services are singletons by default, created on first use
|
|
960
|
+
- **Dispose & cleanup** — Call `dispose()` to clean up resources, `delete()` to remove and recreate
|
|
961
|
+
- **Override for testing** — Swap implementations with `set()` without touching module imports
|
|
962
|
+
- **Hierarchical containers** — Create child containers for scoped dependencies
|
|
502
963
|
|
|
503
964
|
```ts
|
|
504
965
|
import { container, type Resolver } from "storion";
|
|
@@ -545,6 +1006,24 @@ const userStore = store({
|
|
|
545
1006
|
};
|
|
546
1007
|
},
|
|
547
1008
|
});
|
|
1009
|
+
|
|
1010
|
+
// Testing - easy to mock without module mocking
|
|
1011
|
+
const mockApi: ApiService = {
|
|
1012
|
+
get: async () => ({ id: "1", name: "Test User" }),
|
|
1013
|
+
post: async () => ({}),
|
|
1014
|
+
};
|
|
1015
|
+
|
|
1016
|
+
const testApp = container();
|
|
1017
|
+
testApp.set(createApiService, () => mockApi); // Override with mock
|
|
1018
|
+
|
|
1019
|
+
// Now userStore will use mockApi instead of real API
|
|
1020
|
+
const { actions } = testApp.get(userStore);
|
|
1021
|
+
await actions.fetchUser("1"); // Uses mockApi.get()
|
|
1022
|
+
|
|
1023
|
+
// Lifecycle management
|
|
1024
|
+
testApp.delete(createApiService); // Remove cached instance
|
|
1025
|
+
testApp.clear(); // Clear all cached instances
|
|
1026
|
+
testApp.dispose(); // Dispose container and all instances
|
|
548
1027
|
```
|
|
549
1028
|
|
|
550
1029
|
### Middleware
|
|
@@ -555,18 +1034,24 @@ const userStore = store({
|
|
|
555
1034
|
|
|
556
1035
|
```ts
|
|
557
1036
|
import { container, compose, applyFor, applyExcept } from "storion";
|
|
1037
|
+
import type { StoreMiddleware } from "storion";
|
|
558
1038
|
|
|
559
|
-
// Logging middleware
|
|
560
|
-
const loggingMiddleware = (
|
|
561
|
-
|
|
562
|
-
|
|
1039
|
+
// Logging middleware - ctx.spec is always available
|
|
1040
|
+
const loggingMiddleware: StoreMiddleware = (ctx) => {
|
|
1041
|
+
console.log(`Creating store: ${ctx.displayName}`);
|
|
1042
|
+
const instance = ctx.next();
|
|
1043
|
+
console.log(`Created: ${instance.id}`);
|
|
563
1044
|
return instance;
|
|
564
1045
|
};
|
|
565
1046
|
|
|
566
1047
|
// Persistence middleware
|
|
567
|
-
const persistMiddleware = (
|
|
568
|
-
const instance = next(
|
|
569
|
-
//
|
|
1048
|
+
const persistMiddleware: StoreMiddleware = (ctx) => {
|
|
1049
|
+
const instance = ctx.next();
|
|
1050
|
+
// Access store-specific options directly
|
|
1051
|
+
const isPersistent = ctx.spec.options.meta?.persist === true;
|
|
1052
|
+
if (isPersistent) {
|
|
1053
|
+
// Add persistence logic...
|
|
1054
|
+
}
|
|
570
1055
|
return instance;
|
|
571
1056
|
};
|
|
572
1057
|
|
|
@@ -582,7 +1067,10 @@ const app = container({
|
|
|
582
1067
|
applyFor(["authStore", "settingsStore"], loggingMiddleware),
|
|
583
1068
|
|
|
584
1069
|
// Apply based on custom condition
|
|
585
|
-
applyFor(
|
|
1070
|
+
applyFor(
|
|
1071
|
+
(ctx) => ctx.spec.options.meta?.persist === true,
|
|
1072
|
+
persistMiddleware
|
|
1073
|
+
)
|
|
586
1074
|
),
|
|
587
1075
|
});
|
|
588
1076
|
```
|
|
@@ -606,7 +1094,7 @@ const app = container({
|
|
|
606
1094
|
|
|
607
1095
|
```ts
|
|
608
1096
|
interface StoreOptions<TState, TActions> {
|
|
609
|
-
name?: string; // Store name for debugging
|
|
1097
|
+
name?: string; // Store display name for debugging (becomes spec.displayName)
|
|
610
1098
|
state: TState; // Initial state
|
|
611
1099
|
setup: (ctx: StoreContext) => TActions; // Setup function
|
|
612
1100
|
lifetime?: "singleton" | "autoDispose"; // Instance lifetime
|
|
@@ -616,6 +1104,42 @@ interface StoreOptions<TState, TActions> {
|
|
|
616
1104
|
}
|
|
617
1105
|
```
|
|
618
1106
|
|
|
1107
|
+
**Per-property equality** — Configure different equality checks for each state property:
|
|
1108
|
+
|
|
1109
|
+
```ts
|
|
1110
|
+
const myStore = store({
|
|
1111
|
+
name: "settings",
|
|
1112
|
+
state: {
|
|
1113
|
+
theme: "light",
|
|
1114
|
+
coords: { x: 0, y: 0 },
|
|
1115
|
+
items: [] as string[],
|
|
1116
|
+
config: { nested: { deep: true } },
|
|
1117
|
+
},
|
|
1118
|
+
// Per-property equality configuration
|
|
1119
|
+
equality: {
|
|
1120
|
+
theme: "strict", // Default (===)
|
|
1121
|
+
coords: "shallow", // Compare { x, y } properties
|
|
1122
|
+
items: "shallow", // Compare array elements
|
|
1123
|
+
config: "deep", // Deep recursive comparison
|
|
1124
|
+
},
|
|
1125
|
+
setup({ state }) {
|
|
1126
|
+
return {
|
|
1127
|
+
setCoords: (x: number, y: number) => {
|
|
1128
|
+
// Only triggers subscribers if x or y actually changed (shallow compare)
|
|
1129
|
+
state.coords = { x, y };
|
|
1130
|
+
},
|
|
1131
|
+
};
|
|
1132
|
+
},
|
|
1133
|
+
});
|
|
1134
|
+
```
|
|
1135
|
+
|
|
1136
|
+
| Equality | Description |
|
|
1137
|
+
| ------------------- | ----------------------------------------------- |
|
|
1138
|
+
| `"strict"` | Default `===` comparison |
|
|
1139
|
+
| `"shallow"` | Compares object/array properties one level deep |
|
|
1140
|
+
| `"deep"` | Recursively compares nested structures |
|
|
1141
|
+
| `(a, b) => boolean` | Custom comparison function |
|
|
1142
|
+
|
|
619
1143
|
#### StoreContext (in setup)
|
|
620
1144
|
|
|
621
1145
|
```ts
|
|
@@ -686,6 +1210,71 @@ interface AsyncState<T, M extends "fresh" | "stale"> {
|
|
|
686
1210
|
}
|
|
687
1211
|
```
|
|
688
1212
|
|
|
1213
|
+
### Middleware
|
|
1214
|
+
|
|
1215
|
+
| Export | Description |
|
|
1216
|
+
| ------------- | -------------------------------------------------- |
|
|
1217
|
+
| `compose` | Compose multiple StoreMiddleware into one |
|
|
1218
|
+
| `applyFor` | Apply middleware conditionally (pattern/predicate) |
|
|
1219
|
+
| `applyExcept` | Apply middleware except for matching patterns |
|
|
1220
|
+
|
|
1221
|
+
#### Middleware Context (Discriminated Union)
|
|
1222
|
+
|
|
1223
|
+
Middleware context uses a discriminated union with `type` field:
|
|
1224
|
+
|
|
1225
|
+
```ts
|
|
1226
|
+
// For stores (container middleware)
|
|
1227
|
+
interface StoreMiddlewareContext {
|
|
1228
|
+
type: "store"; // Discriminant
|
|
1229
|
+
spec: StoreSpec; // Always present for stores
|
|
1230
|
+
factory: Factory;
|
|
1231
|
+
resolver: Resolver;
|
|
1232
|
+
next: () => unknown;
|
|
1233
|
+
displayName: string; // Always present for stores
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
// For plain factories (resolver middleware)
|
|
1237
|
+
interface FactoryMiddlewareContext {
|
|
1238
|
+
type: "factory"; // Discriminant
|
|
1239
|
+
factory: Factory;
|
|
1240
|
+
resolver: Resolver;
|
|
1241
|
+
next: () => unknown;
|
|
1242
|
+
displayName: string | undefined;
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
type MiddlewareContext = FactoryMiddlewareContext | StoreMiddlewareContext;
|
|
1246
|
+
```
|
|
1247
|
+
|
|
1248
|
+
**Store-specific middleware** (for containers):
|
|
1249
|
+
|
|
1250
|
+
```ts
|
|
1251
|
+
// No generics needed - simple and clean
|
|
1252
|
+
type StoreMiddleware = (ctx: StoreMiddlewareContext) => StoreInstance;
|
|
1253
|
+
|
|
1254
|
+
const loggingMiddleware: StoreMiddleware = (ctx) => {
|
|
1255
|
+
console.log(`Creating: ${ctx.displayName}`);
|
|
1256
|
+
const instance = ctx.next();
|
|
1257
|
+
console.log(`Created: ${instance.id}`);
|
|
1258
|
+
return instance as StoreInstance;
|
|
1259
|
+
};
|
|
1260
|
+
```
|
|
1261
|
+
|
|
1262
|
+
**Generic middleware** (for resolver, works with both stores and factories):
|
|
1263
|
+
|
|
1264
|
+
```ts
|
|
1265
|
+
type Middleware = (ctx: MiddlewareContext) => unknown;
|
|
1266
|
+
|
|
1267
|
+
const loggingMiddleware: Middleware = (ctx) => {
|
|
1268
|
+
// Use type narrowing
|
|
1269
|
+
if (ctx.type === "store") {
|
|
1270
|
+
console.log(`Store: ${ctx.spec.displayName}`);
|
|
1271
|
+
} else {
|
|
1272
|
+
console.log(`Factory: ${ctx.displayName ?? "anonymous"}`);
|
|
1273
|
+
}
|
|
1274
|
+
return ctx.next();
|
|
1275
|
+
};
|
|
1276
|
+
```
|
|
1277
|
+
|
|
689
1278
|
### Devtools (`storion/devtools`)
|
|
690
1279
|
|
|
691
1280
|
```ts
|