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 CHANGED
@@ -282,9 +282,7 @@ export const settingsStore = store({
282
282
 
283
283
  return {
284
284
  // Direct value
285
- setTheme: (theme: "light" | "dark") => {
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 UserName() {
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 profile.name changes
433
- const { name } = useStore(({ get }) => {
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 { name: pick(() => state.profile.name) };
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 you don't want to import singletons directly—it makes testing hard and creates tight coupling.
949
+ **The problem:** Your stores need shared services (API clients, loggers, config) but importing singletons directly causes issues:
531
950
 
532
- **With Storion:** The container acts as a DI container. Define factory functions and resolve them with `get()`. Services are cached as singletons automatically.
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() for accessing stores
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
- #### StoreMiddlewareContext
1314
+ #### Middleware Context (Discriminated Union)
738
1315
 
739
- Container middleware uses `StoreMiddlewareContext` where `spec` is always available:
1316
+ Middleware context uses a discriminated union with `type` field:
740
1317
 
741
1318
  ```ts
742
- interface StoreMiddlewareContext<S, A> {
743
- spec: StoreSpec<S, A>; // The store spec (always present)
744
- resolver: Resolver; // The resolver/container instance
745
- next: () => StoreInstance<S, A>; // Call next middleware or create the store
746
- displayName: string; // Store name (always present for stores)
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 StoreMiddleware = <S, A>(
750
- ctx: StoreMiddlewareContext<S, A>
751
- ) => StoreInstance<S, A>;
1338
+ type MiddlewareContext = FactoryMiddlewareContext | StoreMiddlewareContext;
752
1339
  ```
753
1340
 
754
- For generic resolver middleware (non-container), use `Middleware` with `MiddlewareContext`:
1341
+ **Store-specific middleware** (for containers):
755
1342
 
756
1343
  ```ts
757
- interface MiddlewareContext<T> {
758
- factory: Factory<T>; // The factory being invoked
759
- resolver: Resolver; // The resolver instance
760
- next: () => T; // Call next middleware or the factory
761
- displayName?: string; // Name (from factory.displayName or factory.name)
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
- type Middleware = <T>(ctx: MiddlewareContext<T>) => T;
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`)