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 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, event listeners) when state changes, and properly clean up when the state changes again or the component unmounts.
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. Register cleanup with `ctx.onCleanup()`.
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 UserName() {
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 profile.name changes
402
- const { name } = useStore(({ get }) => {
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 { name: pick(() => state.profile.name) };
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 you don't want to import singletons directly—it makes testing hard and creates tight coupling.
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
- **With Storion:** The container acts as a DI container. Define factory functions and resolve them with `get()`. Services are cached as singletons automatically.
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 = (spec, next) => {
561
- const instance = next(spec);
562
- console.log(`Store created: ${spec.name}`);
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 = (spec, next) => {
568
- const instance = next(spec);
569
- // Add persistence logic...
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((spec) => spec.options.meta?.persist === true, persistMiddleware)
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