storion 0.4.0 → 0.6.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 +636 -32
- 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/collection.d.ts +13 -21
- package/dist/collection.d.ts.map +1 -1
- 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/disposable.d.ts +18 -0
- package/dist/core/disposable.d.ts.map +1 -0
- package/dist/core/middleware.d.ts +25 -12
- package/dist/core/middleware.d.ts.map +1 -1
- package/dist/core/storeContext.d.ts.map +1 -1
- package/dist/devtools/index.js +4 -1
- 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 +8 -3
- package/dist/react/useStore.d.ts.map +1 -1
- package/dist/{store-Yv-9gPVf.js → store-DS-4XdM6.js} +33 -9
- package/dist/storion.js +177 -233
- package/dist/test/util.d.ts +2 -0
- package/dist/test/util.d.ts.map +1 -0
- package/dist/types.d.ts +139 -20
- package/dist/types.d.ts.map +1 -1
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -282,9 +282,7 @@ export const settingsStore = store({
|
|
|
282
282
|
|
|
283
283
|
return {
|
|
284
284
|
// Direct value
|
|
285
|
-
setTheme
|
|
286
|
-
setTheme(theme);
|
|
287
|
-
},
|
|
285
|
+
setTheme,
|
|
288
286
|
|
|
289
287
|
// Reducer - returns new value
|
|
290
288
|
toggleTheme: () => {
|
|
@@ -422,23 +420,141 @@ effect((ctx) => {
|
|
|
422
420
|
```tsx
|
|
423
421
|
import { pick } from "storion";
|
|
424
422
|
|
|
425
|
-
function
|
|
423
|
+
function UserProfile() {
|
|
426
424
|
// Without pick: re-renders when ANY profile property changes
|
|
427
425
|
const { name } = useStore(({ get }) => {
|
|
428
426
|
const [state] = get(userStore);
|
|
429
427
|
return { name: state.profile.name };
|
|
430
428
|
});
|
|
431
429
|
|
|
432
|
-
// With pick: re-renders ONLY when
|
|
433
|
-
|
|
430
|
+
// With pick: re-renders ONLY when the picked value changes
|
|
431
|
+
// Multiple picks can be used in one selector
|
|
432
|
+
const { name, fullName, coords, settings, nested } = useStore(({ get }) => {
|
|
434
433
|
const [state] = get(userStore);
|
|
435
|
-
return {
|
|
434
|
+
return {
|
|
435
|
+
// Simple pick - uses default equality (===)
|
|
436
|
+
name: pick(() => state.profile.name),
|
|
437
|
+
|
|
438
|
+
// Computed value - only re-renders when result changes
|
|
439
|
+
fullName: pick(() => `${state.profile.first} ${state.profile.last}`),
|
|
440
|
+
|
|
441
|
+
// 'shallow' - compares object properties one level deep
|
|
442
|
+
coords: pick(
|
|
443
|
+
() => ({ x: state.position.x, y: state.position.y }),
|
|
444
|
+
"shallow"
|
|
445
|
+
),
|
|
446
|
+
|
|
447
|
+
// 'deep' - recursively compares nested objects/arrays
|
|
448
|
+
settings: pick(() => state.userSettings, "deep"),
|
|
449
|
+
|
|
450
|
+
// Custom equality function - full control
|
|
451
|
+
nested: pick(
|
|
452
|
+
() => state.data.items.map((i) => i.id),
|
|
453
|
+
(a, b) => a.length === b.length && a.every((id, i) => id === b[i])
|
|
454
|
+
),
|
|
455
|
+
};
|
|
436
456
|
});
|
|
437
457
|
|
|
438
458
|
return <h1>{name}</h1>;
|
|
439
459
|
}
|
|
440
460
|
```
|
|
441
461
|
|
|
462
|
+
### Understanding Equality: Store vs Component Level
|
|
463
|
+
|
|
464
|
+
Storion provides two layers of equality control, each solving different problems:
|
|
465
|
+
|
|
466
|
+
| Layer | API | When it runs | Purpose |
|
|
467
|
+
| -------------------- | ----------------- | --------------------- | ------------------------------------------------ |
|
|
468
|
+
| **Store (write)** | `equality` option | When state is mutated | Prevent unnecessary notifications to subscribers |
|
|
469
|
+
| **Component (read)** | `pick(fn, eq)` | When selector runs | Prevent unnecessary re-renders |
|
|
470
|
+
|
|
471
|
+
```
|
|
472
|
+
┌─────────────────────────────────────────────────────────────────────┐
|
|
473
|
+
│ Store │
|
|
474
|
+
│ ┌──────────────────────────────────────────────────────────────┐ │
|
|
475
|
+
│ │ state.coords = { x: 1, y: 2 } │ │
|
|
476
|
+
│ │ │ │ │
|
|
477
|
+
│ │ ▼ │ │
|
|
478
|
+
│ │ equality: { coords: "shallow" } ──► Same x,y? Skip notify │ │
|
|
479
|
+
│ └──────────────────────────────────────────────────────────────┘ │
|
|
480
|
+
│ │ │
|
|
481
|
+
│ notify if changed │
|
|
482
|
+
│ ▼ │
|
|
483
|
+
│ ┌──────────────────────────────────────────────────────────────┐ │
|
|
484
|
+
│ │ Component A │ Component B │ │
|
|
485
|
+
│ │ pick(() => coords.x) │ pick(() => coords, "shallow")│ │
|
|
486
|
+
│ │ │ │ │ │ │
|
|
487
|
+
│ │ ▼ │ ▼ │ │
|
|
488
|
+
│ │ Re-render if x changed │ Re-render if x OR y changed │ │
|
|
489
|
+
│ └──────────────────────────────────────────────────────────────┘ │
|
|
490
|
+
└─────────────────────────────────────────────────────────────────────┘
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
**Example: Coordinates update**
|
|
494
|
+
|
|
495
|
+
```ts
|
|
496
|
+
// Store level - controls when subscribers get notified
|
|
497
|
+
const mapStore = store({
|
|
498
|
+
state: { coords: { x: 0, y: 0 }, zoom: 1 },
|
|
499
|
+
equality: {
|
|
500
|
+
coords: "shallow", // Don't notify if same { x, y } values
|
|
501
|
+
},
|
|
502
|
+
setup({ state }) {
|
|
503
|
+
return {
|
|
504
|
+
setCoords: (x: number, y: number) => {
|
|
505
|
+
state.coords = { x, y }; // New object, but shallow-equal = no notify
|
|
506
|
+
},
|
|
507
|
+
};
|
|
508
|
+
},
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
// Component level - controls when THIS component re-renders
|
|
512
|
+
function XCoordinate() {
|
|
513
|
+
const { x } = useStore(({ get }) => {
|
|
514
|
+
const [state] = get(mapStore);
|
|
515
|
+
return {
|
|
516
|
+
// Even if coords changed, only re-render if x specifically changed
|
|
517
|
+
x: pick(() => state.coords.x),
|
|
518
|
+
};
|
|
519
|
+
});
|
|
520
|
+
return <span>X: {x}</span>;
|
|
521
|
+
}
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
### Comparison with Other Libraries
|
|
525
|
+
|
|
526
|
+
| Feature | Storion | Redux | Zustand | Jotai | MobX |
|
|
527
|
+
| ------------------ | --------------------- | ------------------------ | ---------------- | ----------------- | --------------- |
|
|
528
|
+
| **Tracking** | Automatic | Manual selectors | Manual selectors | Automatic (atoms) | Automatic |
|
|
529
|
+
| **Write equality** | Per-property config | Reducer-based | Built-in shallow | Per-atom | Deep by default |
|
|
530
|
+
| **Read equality** | `pick()` with options | `useSelector` + equality | `shallow` helper | Atom-level | Computed |
|
|
531
|
+
| **Granularity** | Property + component | Selector-based | Selector-based | Atom-based | Property-based |
|
|
532
|
+
| **Bundle size** | ~4KB | ~2KB + toolkit | ~1KB | ~2KB | ~15KB |
|
|
533
|
+
| **DI / Lifecycle** | Built-in container | External (thunk/saga) | External | Provider-based | External |
|
|
534
|
+
|
|
535
|
+
**Key differences:**
|
|
536
|
+
|
|
537
|
+
- **Redux/Zustand**: You write selectors manually and pass equality functions to `useSelector`. Easy to forget and over-subscribe.
|
|
538
|
+
|
|
539
|
+
```ts
|
|
540
|
+
// Zustand - must remember to add shallow
|
|
541
|
+
const coords = useStore((s) => s.coords, shallow);
|
|
542
|
+
```
|
|
543
|
+
|
|
544
|
+
- **Jotai**: Fine-grained via atoms, but requires splitting state into many atoms upfront.
|
|
545
|
+
|
|
546
|
+
```ts
|
|
547
|
+
// Jotai - must create separate atoms
|
|
548
|
+
const xAtom = atom((get) => get(coordsAtom).x);
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
- **Storion**: Auto-tracking by default, `pick()` for opt-in fine-grained control, store-level equality for write optimization.
|
|
552
|
+
|
|
553
|
+
```ts
|
|
554
|
+
// Storion - automatic tracking, pick() when you need precision
|
|
555
|
+
const x = pick(() => state.coords.x);
|
|
556
|
+
```
|
|
557
|
+
|
|
442
558
|
### Async State Management
|
|
443
559
|
|
|
444
560
|
**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.
|
|
@@ -525,11 +641,323 @@ function ProductList() {
|
|
|
525
641
|
}
|
|
526
642
|
```
|
|
527
643
|
|
|
644
|
+
### When to Fetch Data
|
|
645
|
+
|
|
646
|
+
Storion provides multiple patterns for data fetching. Choose based on your use case:
|
|
647
|
+
|
|
648
|
+
| Pattern | When to use | Example |
|
|
649
|
+
| ----------------------- | ---------------------------------------------- | ---------------------------------- |
|
|
650
|
+
| **Setup time** | Data needed immediately when store initializes | App config, user session |
|
|
651
|
+
| **Trigger (no deps)** | One-time fetch when component mounts | Initial page data |
|
|
652
|
+
| **Trigger (with deps)** | Refetch when component visits or deps change | Dashboard refresh |
|
|
653
|
+
| **useEffect** | Standard React pattern, explicit control | Compatibility with existing code |
|
|
654
|
+
| **User interaction** | On-demand fetching | Search, pagination, refresh button |
|
|
655
|
+
|
|
656
|
+
```tsx
|
|
657
|
+
import { store } from "storion";
|
|
658
|
+
import { async, type AsyncState } from "storion/async";
|
|
659
|
+
import { useStore } from "storion/react";
|
|
660
|
+
import { useEffect } from "react";
|
|
661
|
+
|
|
662
|
+
interface User {
|
|
663
|
+
id: string;
|
|
664
|
+
name: string;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
export const userStore = store({
|
|
668
|
+
name: "users",
|
|
669
|
+
state: {
|
|
670
|
+
currentUser: async.fresh<User>(),
|
|
671
|
+
searchResults: async.stale<User[]>([]),
|
|
672
|
+
},
|
|
673
|
+
setup({ focus, effect }) {
|
|
674
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
675
|
+
// Pattern 1: Fetch at SETUP TIME
|
|
676
|
+
// Data is fetched immediately when store is created
|
|
677
|
+
// Good for: App config, auth state, critical data
|
|
678
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
679
|
+
const currentUserAsync = async(focus("currentUser"), async (ctx) => {
|
|
680
|
+
const res = await fetch("/api/me", { signal: ctx.signal });
|
|
681
|
+
return res.json();
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
// Fetch immediately during setup
|
|
685
|
+
currentUserAsync.dispatch();
|
|
686
|
+
|
|
687
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
688
|
+
// Pattern 2: Expose DISPATCH for UI control
|
|
689
|
+
// Store provides action, UI decides when to call
|
|
690
|
+
// Good for: Search, pagination, user-triggered refresh
|
|
691
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
692
|
+
const searchAsync = async(
|
|
693
|
+
focus("searchResults"),
|
|
694
|
+
async (ctx, query: string) => {
|
|
695
|
+
const res = await fetch(`/api/users/search?q=${query}`, {
|
|
696
|
+
signal: ctx.signal,
|
|
697
|
+
});
|
|
698
|
+
return res.json();
|
|
699
|
+
}
|
|
700
|
+
);
|
|
701
|
+
|
|
702
|
+
return {
|
|
703
|
+
currentUser: currentUserAsync,
|
|
704
|
+
search: searchAsync.dispatch,
|
|
705
|
+
cancelSearch: searchAsync.cancel,
|
|
706
|
+
};
|
|
707
|
+
},
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
711
|
+
// Pattern 3: TRIGGER with dependencies
|
|
712
|
+
// Uses useStore's `trigger` for declarative data fetching
|
|
713
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
714
|
+
|
|
715
|
+
// 3a. No deps - fetch ONCE when component mounts
|
|
716
|
+
function UserProfile() {
|
|
717
|
+
const { user } = useStore(({ get, trigger }) => {
|
|
718
|
+
const [state, actions] = get(userStore);
|
|
719
|
+
|
|
720
|
+
// No deps array = fetch once, never refetch
|
|
721
|
+
trigger(actions.currentUser.dispatch, []);
|
|
722
|
+
|
|
723
|
+
return { user: state.currentUser };
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
if (user.status === "pending") return <Spinner />;
|
|
727
|
+
return <div>{user.data?.name}</div>;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// 3b. With context.id - refetch EVERY TIME component visits
|
|
731
|
+
function Dashboard() {
|
|
732
|
+
const { user } = useStore(({ get, trigger, id }) => {
|
|
733
|
+
const [state, actions] = get(userStore);
|
|
734
|
+
|
|
735
|
+
// `id` changes each time component mounts = refetch on every visit
|
|
736
|
+
trigger(actions.currentUser.dispatch, [id]);
|
|
737
|
+
|
|
738
|
+
return { user: state.currentUser };
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
return <div>Welcome back, {user.data?.name}</div>;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// 3c. With custom deps - refetch when deps change
|
|
745
|
+
function UserById({ userId }: { userId: string }) {
|
|
746
|
+
const { user } = useStore(
|
|
747
|
+
({ get, trigger }) => {
|
|
748
|
+
const [state, actions] = get(userStore);
|
|
749
|
+
|
|
750
|
+
// Refetch when userId prop changes
|
|
751
|
+
trigger(() => actions.currentUser.dispatch(), [userId]);
|
|
752
|
+
|
|
753
|
+
return { user: state.currentUser };
|
|
754
|
+
},
|
|
755
|
+
[userId] // selector deps for proper tracking
|
|
756
|
+
);
|
|
757
|
+
|
|
758
|
+
return <div>{user.data?.name}</div>;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
762
|
+
// Pattern 4: useEffect - standard React pattern
|
|
763
|
+
// For compatibility or when you need more control
|
|
764
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
765
|
+
function UserListWithEffect() {
|
|
766
|
+
const { search } = useStore(({ get }) => {
|
|
767
|
+
const [, actions] = get(userStore);
|
|
768
|
+
return { search: actions.search };
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
useEffect(() => {
|
|
772
|
+
search("initial");
|
|
773
|
+
}, []);
|
|
774
|
+
|
|
775
|
+
return <div>...</div>;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
779
|
+
// Pattern 5: USER INTERACTION - on-demand fetching
|
|
780
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
781
|
+
function SearchBox() {
|
|
782
|
+
const [query, setQuery] = useState("");
|
|
783
|
+
const { results, search, cancel } = useStore(({ get }) => {
|
|
784
|
+
const [state, actions] = get(userStore);
|
|
785
|
+
return {
|
|
786
|
+
results: state.searchResults,
|
|
787
|
+
search: actions.search,
|
|
788
|
+
cancel: actions.cancelSearch,
|
|
789
|
+
};
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
const handleSearch = () => {
|
|
793
|
+
cancel(); // Cancel previous search
|
|
794
|
+
search(query);
|
|
795
|
+
};
|
|
796
|
+
|
|
797
|
+
return (
|
|
798
|
+
<div>
|
|
799
|
+
<input value={query} onChange={(e) => setQuery(e.target.value)} />
|
|
800
|
+
<button onClick={handleSearch}>Search</button>
|
|
801
|
+
<button onClick={cancel}>Cancel</button>
|
|
802
|
+
|
|
803
|
+
{results.status === "pending" && <Spinner />}
|
|
804
|
+
{results.data?.map((user) => (
|
|
805
|
+
<div key={user.id}>{user.name}</div>
|
|
806
|
+
))}
|
|
807
|
+
</div>
|
|
808
|
+
);
|
|
809
|
+
}
|
|
810
|
+
```
|
|
811
|
+
|
|
812
|
+
**Summary: Choosing the right pattern**
|
|
813
|
+
|
|
814
|
+
```
|
|
815
|
+
┌─────────────────────────────────────────────────────────────────────────┐
|
|
816
|
+
│ When should data be fetched? │
|
|
817
|
+
├─────────────────────────────────────────────────────────────────────────┤
|
|
818
|
+
│ │
|
|
819
|
+
│ App starts? ──────────► Setup time (dispatch in setup) │
|
|
820
|
+
│ │
|
|
821
|
+
│ Component mounts? │
|
|
822
|
+
│ │ │
|
|
823
|
+
│ ├── Once ever? ────► trigger(fn, []) │
|
|
824
|
+
│ │ │
|
|
825
|
+
│ ├── Every visit? ──► trigger(fn, [id]) │
|
|
826
|
+
│ │ │
|
|
827
|
+
│ └── When deps change? ► trigger(fn, [dep1, dep2]) │
|
|
828
|
+
│ │
|
|
829
|
+
│ User clicks? ──────────► onClick handler calls action │
|
|
830
|
+
│ │
|
|
831
|
+
└─────────────────────────────────────────────────────────────────────────┘
|
|
832
|
+
```
|
|
833
|
+
|
|
834
|
+
### Suspense Pattern with `async.wait()`
|
|
835
|
+
|
|
836
|
+
**The problem:** You want to use React Suspense for loading states, but managing the "throw promise" pattern manually is complex and error-prone.
|
|
837
|
+
|
|
838
|
+
**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.
|
|
839
|
+
|
|
840
|
+
```tsx
|
|
841
|
+
import { Suspense } from "react";
|
|
842
|
+
import { async } from "storion/async";
|
|
843
|
+
import { useStore } from "storion/react";
|
|
844
|
+
|
|
845
|
+
// Component that uses Suspense - no loading/error handling needed!
|
|
846
|
+
function UserProfile() {
|
|
847
|
+
const { user } = useStore(({ get, trigger }) => {
|
|
848
|
+
const [state, actions] = get(userStore);
|
|
849
|
+
|
|
850
|
+
// Trigger fetch on mount
|
|
851
|
+
trigger(actions.fetchUser, []);
|
|
852
|
+
|
|
853
|
+
return {
|
|
854
|
+
// async.wait() throws if pending/error, returns data if success
|
|
855
|
+
user: async.wait(state.currentUser),
|
|
856
|
+
};
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
// This only renders when data is ready
|
|
860
|
+
return (
|
|
861
|
+
<div>
|
|
862
|
+
<h1>{user.name}</h1>
|
|
863
|
+
<p>{user.email}</p>
|
|
864
|
+
</div>
|
|
865
|
+
);
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// Parent wraps with Suspense and ErrorBoundary
|
|
869
|
+
function App() {
|
|
870
|
+
return (
|
|
871
|
+
<ErrorBoundary fallback={<div>Something went wrong</div>}>
|
|
872
|
+
<Suspense fallback={<Spinner />}>
|
|
873
|
+
<UserProfile />
|
|
874
|
+
</Suspense>
|
|
875
|
+
</ErrorBoundary>
|
|
876
|
+
);
|
|
877
|
+
}
|
|
878
|
+
```
|
|
879
|
+
|
|
880
|
+
**Multiple async states with `async.all()`:**
|
|
881
|
+
|
|
882
|
+
```tsx
|
|
883
|
+
function Dashboard() {
|
|
884
|
+
const { user, posts, comments } = useStore(({ get, trigger }) => {
|
|
885
|
+
const [userState, userActions] = get(userStore);
|
|
886
|
+
const [postState, postActions] = get(postStore);
|
|
887
|
+
const [commentState, commentActions] = get(commentStore);
|
|
888
|
+
|
|
889
|
+
trigger(userActions.fetch, []);
|
|
890
|
+
trigger(postActions.fetch, []);
|
|
891
|
+
trigger(commentActions.fetch, []);
|
|
892
|
+
|
|
893
|
+
// Wait for ALL async states - suspends until all are ready
|
|
894
|
+
const [user, posts, comments] = async.all(
|
|
895
|
+
userState.current,
|
|
896
|
+
postState.list,
|
|
897
|
+
commentState.recent
|
|
898
|
+
);
|
|
899
|
+
|
|
900
|
+
return { user, posts, comments };
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
return (
|
|
904
|
+
<div>
|
|
905
|
+
<h1>Welcome, {user.name}</h1>
|
|
906
|
+
<PostList posts={posts} />
|
|
907
|
+
<CommentList comments={comments} />
|
|
908
|
+
</div>
|
|
909
|
+
);
|
|
910
|
+
}
|
|
911
|
+
```
|
|
912
|
+
|
|
913
|
+
**Race pattern with `async.race()`:**
|
|
914
|
+
|
|
915
|
+
```tsx
|
|
916
|
+
function FastestResult() {
|
|
917
|
+
const { result } = useStore(({ get, trigger }) => {
|
|
918
|
+
const [state, actions] = get(searchStore);
|
|
919
|
+
|
|
920
|
+
trigger(() => {
|
|
921
|
+
actions.searchAPI1(query);
|
|
922
|
+
actions.searchAPI2(query);
|
|
923
|
+
}, [query]);
|
|
924
|
+
|
|
925
|
+
// Returns whichever finishes first
|
|
926
|
+
return {
|
|
927
|
+
result: async.race(state.api1Results, state.api2Results),
|
|
928
|
+
};
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
return <ResultList items={result} />;
|
|
932
|
+
}
|
|
933
|
+
```
|
|
934
|
+
|
|
935
|
+
**Async helpers summary:**
|
|
936
|
+
|
|
937
|
+
| Helper | Behavior | Use case |
|
|
938
|
+
| ------------------------ | ------------------------------------- | ------------------------- |
|
|
939
|
+
| `async.wait(state)` | Throws if pending/error, returns data | Single Suspense resource |
|
|
940
|
+
| `async.all(...states)` | Waits for all, returns tuple | Multiple parallel fetches |
|
|
941
|
+
| `async.any(...states)` | Returns first successful | Fallback sources |
|
|
942
|
+
| `async.race(states)` | Returns fastest | Competitive fetching |
|
|
943
|
+
| `async.hasData(state)` | `boolean` | Check without suspending |
|
|
944
|
+
| `async.isLoading(state)` | `boolean` | Loading indicator |
|
|
945
|
+
| `async.isError(state)` | `boolean` | Error check |
|
|
946
|
+
|
|
528
947
|
### Dependency Injection
|
|
529
948
|
|
|
530
|
-
**The problem:** Your stores need shared services (API clients, loggers, config) but
|
|
949
|
+
**The problem:** Your stores need shared services (API clients, loggers, config) but importing singletons directly causes issues:
|
|
531
950
|
|
|
532
|
-
**
|
|
951
|
+
- **No lifecycle management** — ES imports are forever; you can't dispose or recreate instances
|
|
952
|
+
- **Testing is painful** — Mocking ES modules requires awkward workarounds
|
|
953
|
+
- **No cleanup** — Resources like connections, intervals, or subscriptions leak between tests
|
|
954
|
+
|
|
955
|
+
**With Storion:** The container is a full DI system that manages the complete lifecycle:
|
|
956
|
+
|
|
957
|
+
- **Automatic caching** — Services are singletons by default, created on first use
|
|
958
|
+
- **Dispose & cleanup** — Call `dispose()` to clean up resources, `delete()` to remove and recreate
|
|
959
|
+
- **Override for testing** — Swap implementations with `set()` without touching module imports
|
|
960
|
+
- **Hierarchical containers** — Create child containers for scoped dependencies
|
|
533
961
|
|
|
534
962
|
```ts
|
|
535
963
|
import { container, type Resolver } from "storion";
|
|
@@ -576,8 +1004,92 @@ const userStore = store({
|
|
|
576
1004
|
};
|
|
577
1005
|
},
|
|
578
1006
|
});
|
|
1007
|
+
|
|
1008
|
+
// Testing - easy to mock without module mocking
|
|
1009
|
+
const mockApi: ApiService = {
|
|
1010
|
+
get: async () => ({ id: "1", name: "Test User" }),
|
|
1011
|
+
post: async () => ({}),
|
|
1012
|
+
};
|
|
1013
|
+
|
|
1014
|
+
const testApp = container();
|
|
1015
|
+
testApp.set(createApiService, () => mockApi); // Override with mock
|
|
1016
|
+
|
|
1017
|
+
// Now userStore will use mockApi instead of real API
|
|
1018
|
+
const { actions } = testApp.get(userStore);
|
|
1019
|
+
await actions.fetchUser("1"); // Uses mockApi.get()
|
|
1020
|
+
|
|
1021
|
+
// Lifecycle management
|
|
1022
|
+
testApp.delete(createApiService); // Remove cached instance
|
|
1023
|
+
testApp.clear(); // Clear all cached instances
|
|
1024
|
+
testApp.dispose(); // Dispose container and all instances
|
|
579
1025
|
```
|
|
580
1026
|
|
|
1027
|
+
### Parameterized Factories with `create()`
|
|
1028
|
+
|
|
1029
|
+
**The problem:** Some services need configuration at creation time (database connections, loggers with namespaces, API clients with different endpoints). But `get()` only works with parameterless factories since it caches instances.
|
|
1030
|
+
|
|
1031
|
+
**With Storion:** Use `create()` for parameterized factories. Unlike `get()`, `create()` always returns fresh instances and supports additional arguments.
|
|
1032
|
+
|
|
1033
|
+
```ts
|
|
1034
|
+
import { store, container, type Resolver } from "storion";
|
|
1035
|
+
|
|
1036
|
+
// Parameterized factory - receives resolver + custom args
|
|
1037
|
+
function createLogger(resolver: Resolver, namespace: string) {
|
|
1038
|
+
return {
|
|
1039
|
+
info: (msg: string) => console.log(`[${namespace}] INFO: ${msg}`),
|
|
1040
|
+
error: (msg: string) => console.error(`[${namespace}] ERROR: ${msg}`),
|
|
1041
|
+
};
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
function createDatabase(
|
|
1045
|
+
resolver: Resolver,
|
|
1046
|
+
config: { host: string; port: number }
|
|
1047
|
+
) {
|
|
1048
|
+
return {
|
|
1049
|
+
query: (sql: string) =>
|
|
1050
|
+
fetch(`http://${config.host}:${config.port}/query`, {
|
|
1051
|
+
method: "POST",
|
|
1052
|
+
body: sql,
|
|
1053
|
+
}),
|
|
1054
|
+
close: () => {
|
|
1055
|
+
/* cleanup */
|
|
1056
|
+
},
|
|
1057
|
+
};
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
// Use in store setup
|
|
1061
|
+
const userStore = store({
|
|
1062
|
+
name: "user",
|
|
1063
|
+
state: { users: [] as User[] },
|
|
1064
|
+
setup({ create }) {
|
|
1065
|
+
// Each call creates a fresh instance with specific config
|
|
1066
|
+
const logger = create(createLogger, "user-store");
|
|
1067
|
+
const db = create(createDatabase, { host: "localhost", port: 5432 });
|
|
1068
|
+
|
|
1069
|
+
return {
|
|
1070
|
+
fetchUsers: async () => {
|
|
1071
|
+
logger.info("Fetching users...");
|
|
1072
|
+
await db.query("SELECT * FROM users");
|
|
1073
|
+
},
|
|
1074
|
+
};
|
|
1075
|
+
},
|
|
1076
|
+
});
|
|
1077
|
+
|
|
1078
|
+
// Also works with container directly
|
|
1079
|
+
const app = container();
|
|
1080
|
+
const authLogger = app.create(createLogger, "auth");
|
|
1081
|
+
const adminDb = app.create(createDatabase, { host: "admin.db", port: 5433 });
|
|
1082
|
+
```
|
|
1083
|
+
|
|
1084
|
+
**Key differences between `get()` and `create()`:**
|
|
1085
|
+
|
|
1086
|
+
| Feature | `get()` | `create()` |
|
|
1087
|
+
| ---------- | --------------------------- | --------------------------------------------- |
|
|
1088
|
+
| Caching | Yes (singleton per factory) | No (always fresh) |
|
|
1089
|
+
| Arguments | None (parameterless only) | Supports additional arguments |
|
|
1090
|
+
| Use case | Shared services | Configured instances, child stores |
|
|
1091
|
+
| Middleware | Applied | Applied (without args) / Bypassed (with args) |
|
|
1092
|
+
|
|
581
1093
|
### Middleware
|
|
582
1094
|
|
|
583
1095
|
**The problem:** You need cross-cutting behavior (logging, persistence, devtools) applied to some or all stores, without modifying each store individually.
|
|
@@ -656,13 +1168,52 @@ interface StoreOptions<TState, TActions> {
|
|
|
656
1168
|
}
|
|
657
1169
|
```
|
|
658
1170
|
|
|
1171
|
+
**Per-property equality** — Configure different equality checks for each state property:
|
|
1172
|
+
|
|
1173
|
+
```ts
|
|
1174
|
+
const myStore = store({
|
|
1175
|
+
name: "settings",
|
|
1176
|
+
state: {
|
|
1177
|
+
theme: "light",
|
|
1178
|
+
coords: { x: 0, y: 0 },
|
|
1179
|
+
items: [] as string[],
|
|
1180
|
+
config: { nested: { deep: true } },
|
|
1181
|
+
},
|
|
1182
|
+
// Per-property equality configuration
|
|
1183
|
+
equality: {
|
|
1184
|
+
theme: "strict", // Default (===)
|
|
1185
|
+
coords: "shallow", // Compare { x, y } properties
|
|
1186
|
+
items: "shallow", // Compare array elements
|
|
1187
|
+
config: "deep", // Deep recursive comparison
|
|
1188
|
+
},
|
|
1189
|
+
setup({ state }) {
|
|
1190
|
+
return {
|
|
1191
|
+
setCoords: (x: number, y: number) => {
|
|
1192
|
+
// Only triggers subscribers if x or y actually changed (shallow compare)
|
|
1193
|
+
state.coords = { x, y };
|
|
1194
|
+
},
|
|
1195
|
+
};
|
|
1196
|
+
},
|
|
1197
|
+
});
|
|
1198
|
+
```
|
|
1199
|
+
|
|
1200
|
+
| Equality | Description |
|
|
1201
|
+
| ------------------- | ----------------------------------------------- |
|
|
1202
|
+
| `"strict"` | Default `===` comparison |
|
|
1203
|
+
| `"shallow"` | Compares object/array properties one level deep |
|
|
1204
|
+
| `"deep"` | Recursively compares nested structures |
|
|
1205
|
+
| `(a, b) => boolean` | Custom comparison function |
|
|
1206
|
+
|
|
659
1207
|
#### StoreContext (in setup)
|
|
660
1208
|
|
|
661
1209
|
```ts
|
|
662
1210
|
interface StoreContext<TState, TActions> {
|
|
663
1211
|
state: TState; // First-level props only (state.x = y)
|
|
664
|
-
get<T>(spec: StoreSpec<T>): StoreTuple; // Get dependency store
|
|
665
|
-
get<T>(factory: Factory<T>): T; // Get DI service
|
|
1212
|
+
get<T>(spec: StoreSpec<T>): StoreTuple; // Get dependency store (cached)
|
|
1213
|
+
get<T>(factory: Factory<T>): T; // Get DI service (cached)
|
|
1214
|
+
create<T>(spec: StoreSpec<T>): StoreInstance<T>; // Create child store (fresh)
|
|
1215
|
+
create<T>(factory: Factory<T>): T; // Create service (fresh)
|
|
1216
|
+
create<R, A>(factory: (r, ...a: A) => R, ...a: A): R; // Parameterized factory
|
|
666
1217
|
focus<P extends Path>(path: P): Focus; // Lens-like accessor
|
|
667
1218
|
update(fn: (draft: TState) => void): void; // For nested/array mutations
|
|
668
1219
|
dirty(prop?: keyof TState): boolean; // Check if state changed
|
|
@@ -673,6 +1224,29 @@ interface StoreContext<TState, TActions> {
|
|
|
673
1224
|
|
|
674
1225
|
> **Note:** `state` allows direct assignment only for first-level properties. Use `update()` for nested objects, arrays, or batch updates.
|
|
675
1226
|
|
|
1227
|
+
**`get()` vs `create()` — When to use each:**
|
|
1228
|
+
|
|
1229
|
+
| Method | Caching | Use case |
|
|
1230
|
+
| ---------- | -------- | ------------------------------------------------------ |
|
|
1231
|
+
| `get()` | Cached | Shared dependencies, singleton services |
|
|
1232
|
+
| `create()` | No cache | Child stores, parameterized factories, fresh instances |
|
|
1233
|
+
|
|
1234
|
+
```ts
|
|
1235
|
+
setup({ get, create }) {
|
|
1236
|
+
// get() - cached, same instance every time
|
|
1237
|
+
const api = get(apiService); // Singleton
|
|
1238
|
+
|
|
1239
|
+
// create() - fresh instance each call
|
|
1240
|
+
const childStore = create(childSpec); // New store instance
|
|
1241
|
+
|
|
1242
|
+
// create() with arguments - parameterized factory
|
|
1243
|
+
const db = create(createDatabase, { host: 'localhost', port: 5432 });
|
|
1244
|
+
const logger = create(createLogger, 'auth-store');
|
|
1245
|
+
|
|
1246
|
+
return { /* ... */ };
|
|
1247
|
+
}
|
|
1248
|
+
```
|
|
1249
|
+
|
|
676
1250
|
### React (`storion/react`)
|
|
677
1251
|
|
|
678
1252
|
| Export | Description |
|
|
@@ -687,10 +1261,13 @@ interface StoreContext<TState, TActions> {
|
|
|
687
1261
|
#### useStore Selector
|
|
688
1262
|
|
|
689
1263
|
```ts
|
|
690
|
-
// Selector receives context with get()
|
|
691
|
-
const result = useStore(({ get, mixin, once }) => {
|
|
1264
|
+
// Selector receives context with get(), create(), mixin(), once()
|
|
1265
|
+
const result = useStore(({ get, create, mixin, once }) => {
|
|
692
1266
|
const [state, actions] = get(myStore);
|
|
693
|
-
const service = get(myFactory);
|
|
1267
|
+
const service = get(myFactory); // Cached
|
|
1268
|
+
|
|
1269
|
+
// create() for parameterized factories (fresh instance each render)
|
|
1270
|
+
const logger = create(createLogger, "my-component");
|
|
694
1271
|
|
|
695
1272
|
// Run once on mount
|
|
696
1273
|
once(() => actions.init());
|
|
@@ -734,34 +1311,61 @@ interface AsyncState<T, M extends "fresh" | "stale"> {
|
|
|
734
1311
|
| `applyFor` | Apply middleware conditionally (pattern/predicate) |
|
|
735
1312
|
| `applyExcept` | Apply middleware except for matching patterns |
|
|
736
1313
|
|
|
737
|
-
####
|
|
1314
|
+
#### Middleware Context (Discriminated Union)
|
|
738
1315
|
|
|
739
|
-
|
|
1316
|
+
Middleware context uses a discriminated union with `type` field:
|
|
740
1317
|
|
|
741
1318
|
```ts
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
1319
|
+
// For stores (container middleware)
|
|
1320
|
+
interface StoreMiddlewareContext {
|
|
1321
|
+
type: "store"; // Discriminant
|
|
1322
|
+
spec: StoreSpec; // Always present for stores
|
|
1323
|
+
factory: Factory;
|
|
1324
|
+
resolver: Resolver;
|
|
1325
|
+
next: () => unknown;
|
|
1326
|
+
displayName: string; // Always present for stores
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
// For plain factories (resolver middleware)
|
|
1330
|
+
interface FactoryMiddlewareContext {
|
|
1331
|
+
type: "factory"; // Discriminant
|
|
1332
|
+
factory: Factory;
|
|
1333
|
+
resolver: Resolver;
|
|
1334
|
+
next: () => unknown;
|
|
1335
|
+
displayName: string | undefined;
|
|
747
1336
|
}
|
|
748
1337
|
|
|
749
|
-
type
|
|
750
|
-
ctx: StoreMiddlewareContext<S, A>
|
|
751
|
-
) => StoreInstance<S, A>;
|
|
1338
|
+
type MiddlewareContext = FactoryMiddlewareContext | StoreMiddlewareContext;
|
|
752
1339
|
```
|
|
753
1340
|
|
|
754
|
-
|
|
1341
|
+
**Store-specific middleware** (for containers):
|
|
755
1342
|
|
|
756
1343
|
```ts
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
1344
|
+
// No generics needed - simple and clean
|
|
1345
|
+
type StoreMiddleware = (ctx: StoreMiddlewareContext) => StoreInstance;
|
|
1346
|
+
|
|
1347
|
+
const loggingMiddleware: StoreMiddleware = (ctx) => {
|
|
1348
|
+
console.log(`Creating: ${ctx.displayName}`);
|
|
1349
|
+
const instance = ctx.next();
|
|
1350
|
+
console.log(`Created: ${instance.id}`);
|
|
1351
|
+
return instance as StoreInstance;
|
|
1352
|
+
};
|
|
1353
|
+
```
|
|
1354
|
+
|
|
1355
|
+
**Generic middleware** (for resolver, works with both stores and factories):
|
|
763
1356
|
|
|
764
|
-
|
|
1357
|
+
```ts
|
|
1358
|
+
type Middleware = (ctx: MiddlewareContext) => unknown;
|
|
1359
|
+
|
|
1360
|
+
const loggingMiddleware: Middleware = (ctx) => {
|
|
1361
|
+
// Use type narrowing
|
|
1362
|
+
if (ctx.type === "store") {
|
|
1363
|
+
console.log(`Store: ${ctx.spec.displayName}`);
|
|
1364
|
+
} else {
|
|
1365
|
+
console.log(`Factory: ${ctx.displayName ?? "anonymous"}`);
|
|
1366
|
+
}
|
|
1367
|
+
return ctx.next();
|
|
1368
|
+
};
|
|
765
1369
|
```
|
|
766
1370
|
|
|
767
1371
|
### Devtools (`storion/devtools`)
|