storion 0.7.4 → 0.7.6

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
@@ -20,24 +20,51 @@
20
20
  <a href="#features">Features</a> •
21
21
  <a href="#installation">Installation</a> •
22
22
  <a href="#quick-start">Quick Start</a> •
23
- <a href="#usage">Usage</a> •
23
+ <a href="#core-concepts">Core Concepts</a> •
24
24
  <a href="#api-reference">API Reference</a> •
25
- <a href="#contributing">Contributing</a>
25
+ <a href="#advanced-patterns">Advanced Patterns</a>
26
+ <a href="#limitations--anti-patterns">Limitations</a>
26
27
  </p>
27
28
 
28
29
  ---
29
30
 
31
+ ## Table of Contents
32
+
33
+ - [What is Storion?](#what-is-storion)
34
+ - [Features](#features)
35
+ - [Installation](#installation)
36
+ - [Quick Start](#quick-start)
37
+ - [Core Concepts](#core-concepts)
38
+ - [Stores](#stores)
39
+ - [Services](#services)
40
+ - [Container](#container)
41
+ - [Reactivity](#reactivity)
42
+ - [Working with State](#working-with-state)
43
+ - [Direct Mutation](#direct-mutation)
44
+ - [Nested State with update()](#nested-state-with-update)
45
+ - [Focus (Lens-like Access)](#focus-lens-like-access)
46
+ - [Reactive Effects](#reactive-effects)
47
+ - [Async State Management](#async-state-management)
48
+ - [Using Stores in React](#using-stores-in-react)
49
+ - [API Reference](#api-reference)
50
+ - [Advanced Patterns](#advanced-patterns)
51
+ - [Limitations & Anti-patterns](#limitations--anti-patterns)
52
+ - [Contributing](#contributing)
53
+
54
+ ---
55
+
30
56
  ## What is Storion?
31
57
 
32
- Storion is a lightweight state management library with **automatic dependency tracking**:
58
+ Storion is a lightweight state management library that automatically tracks which parts of your state you use and only updates when those parts change.
59
+
60
+ **The core idea is simple:**
33
61
 
34
- - **You read state** → Storion tracks the read
35
- - **That read changes** → only then your effect/component updates
62
+ 1. You read state → Storion remembers what you read
63
+ 2. That state changes → Storion updates only the components that need it
36
64
 
37
- No manual selectors to "optimize", no accidental over-subscription to large objects. Just write natural code and let Storion handle the reactivity.
65
+ No manual selectors. No accidental over-rendering. Just write natural code.
38
66
 
39
67
  ```tsx
40
- // Component only re-renders when `count` actually changes
41
68
  function Counter() {
42
69
  const { count, inc } = useStore(({ get }) => {
43
70
  const [state, actions] = get(counterStore);
@@ -48,39 +75,43 @@ function Counter() {
48
75
  }
49
76
  ```
50
77
 
78
+ **What Storion does:**
79
+
80
+ - When you access `state.count`, Storion notes that this component depends on `count`
81
+ - When `count` changes, Storion re-renders only this component
82
+ - If other state properties change, this component stays untouched
83
+
51
84
  ---
52
85
 
53
86
  ## Features
54
87
 
55
- - 🎯 **Auto-tracking** — Dependencies tracked automatically when you read state
56
- - 🔒 **Type-safe** Full TypeScript support with excellent inference
57
- - **Fine-grained updates** Only re-render what actually changed
58
- - 🧩 **Composable** Mix stores, use DI, create derived values
59
- - 🔄 **Reactive effects** Side effects that automatically respond to state changes
60
- - 📦 **Tiny footprint** ~4KB minified + gzipped
61
- - 🛠️ **DevTools** Built-in devtools panel for debugging
62
- - 🔌 **Middleware** Extensible with conditional middleware patterns
63
- - **Async helpers** — First-class async state management with cancellation
88
+ | Feature | Description |
89
+ | --------------------------- | ----------------------------------------------------------- |
90
+ | 🎯 **Auto-tracking** | Dependencies tracked automatically when you read state |
91
+ | 🔒 **Type-safe** | Full TypeScript support with excellent inference |
92
+ | **Fine-grained updates** | Only re-render what actually changed |
93
+ | 🧩 **Composable** | Mix stores, use dependency injection, create derived values |
94
+ | 🔄 **Reactive effects** | Side effects that automatically respond to state changes |
95
+ | 📦 **Tiny footprint** | ~4KB minified + gzipped |
96
+ | 🛠️ **DevTools** | Built-in devtools panel for debugging |
97
+ | 🔌 **Middleware** | Extensible with conditional middleware patterns |
98
+ | ⏳ **Async helpers** | First-class async state management with cancellation |
64
99
 
65
100
  ---
66
101
 
67
102
  ## Installation
68
103
 
69
104
  ```bash
70
- # npm
71
105
  npm install storion
72
-
73
- # pnpm
106
+ # or
74
107
  pnpm add storion
75
-
76
- # yarn
108
+ # or
77
109
  yarn add storion
78
110
  ```
79
111
 
80
- **Peer dependency:** React is optional, required only if using `storion/react`.
112
+ For React integration:
81
113
 
82
114
  ```bash
83
- # If using React integration
84
115
  npm install storion react
85
116
  ```
86
117
 
@@ -88,14 +119,15 @@ npm install storion react
88
119
 
89
120
  ## Quick Start
90
121
 
91
- ### Option 1: Single Store with `create()` (Simplest)
122
+ ### Single Store (Simplest Approach)
92
123
 
93
- Perfect for small apps or isolated features:
124
+ Best for small apps or isolated features.
94
125
 
95
126
  ```tsx
96
127
  import { create } from "storion/react";
97
128
 
98
- const [counter, useCounter] = create({
129
+ // Define store + hook in one call
130
+ const [counterStore, useCounter] = create({
99
131
  name: "counter",
100
132
  state: { count: 0 },
101
133
  setup({ state }) {
@@ -108,35 +140,35 @@ const [counter, useCounter] = create({
108
140
 
109
141
  // Use in React
110
142
  function Counter() {
111
- const { count, inc, dec } = useCounter((state, actions) => ({
143
+ const { count, inc } = useCounter((state, actions) => ({
112
144
  count: state.count,
113
145
  inc: actions.inc,
114
- dec: actions.dec,
115
146
  }));
116
147
 
117
- return (
118
- <div>
119
- <button onClick={dec}>-</button>
120
- <span>{count}</span>
121
- <button onClick={inc}>+</button>
122
- </div>
123
- );
148
+ return <button onClick={inc}>{count}</button>;
124
149
  }
125
150
 
126
151
  // Use outside React
127
- counter.actions.inc();
128
- console.log(counter.state.count);
152
+ counterStore.actions.inc();
153
+ console.log(counterStore.state.count);
129
154
  ```
130
155
 
131
- ### Option 2: Multi-Store with Container (Scalable)
156
+ **What Storion does:**
157
+
158
+ 1. Creates a reactive state container with `{ count: 0 }`
159
+ 2. Wraps the state so any read is tracked
160
+ 3. When `inc()` changes `count`, Storion notifies only subscribers using `count`
161
+ 4. The React hook connects the component to the store and handles cleanup automatically
132
162
 
133
- Best for larger apps with multiple stores:
163
+ ### Multi-Store with Container (Scalable Approach)
164
+
165
+ Best for larger apps with multiple stores.
134
166
 
135
167
  ```tsx
136
168
  import { store, container } from "storion";
137
169
  import { StoreProvider, useStore } from "storion/react";
138
170
 
139
- // Define stores
171
+ // Define stores separately
140
172
  const authStore = store({
141
173
  name: "auth",
142
174
  state: { userId: null as string | null },
@@ -162,19 +194,14 @@ const todosStore = store({
162
194
  draft.items.push(text);
163
195
  });
164
196
  },
165
- remove: (index: number) => {
166
- update((draft) => {
167
- draft.items.splice(index, 1);
168
- });
169
- },
170
197
  };
171
198
  },
172
199
  });
173
200
 
174
- // Create container
201
+ // Create container (manages all store instances)
175
202
  const app = container();
176
203
 
177
- // Provide to React
204
+ // Provide to React tree
178
205
  function App() {
179
206
  return (
180
207
  <StoreProvider container={app}>
@@ -211,43 +238,220 @@ function Screen() {
211
238
  }
212
239
  ```
213
240
 
214
- ---
241
+ **What Storion does:**
242
+
243
+ 1. Each `store()` call creates a store specification (a blueprint)
244
+ 2. The `container()` manages store instances and their lifecycles
245
+ 3. When you call `get(authStore)`, the container either returns an existing instance or creates one
246
+ 4. All stores share the same container, enabling cross-store communication
247
+ 5. The container handles cleanup when the app unmounts
215
248
 
216
- ## Usage
249
+ ---
217
250
 
218
- ### Defining a Store
251
+ ## Core Concepts
219
252
 
220
- **The problem:** You have related pieces of state and operations that belong together, but managing them with `useState` leads to scattered logic and prop drilling.
253
+ ### Stores
221
254
 
222
- **With Storion:** Group state and actions in a single store. Actions have direct access to state, and the store can be shared across your app.
255
+ A **store** is a container for related state and actions. Think of it as a module that owns a piece of your application's data.
223
256
 
224
257
  ```ts
225
258
  import { store } from "storion";
226
259
 
227
- export const userStore = store({
260
+ const userStore = store({
261
+ name: "user", // Identifier for debugging
262
+ state: {
263
+ // Initial state
264
+ name: "",
265
+ email: "",
266
+ },
267
+ setup({ state }) {
268
+ // Setup function returns actions
269
+ return {
270
+ setName: (name: string) => {
271
+ state.name = name;
272
+ },
273
+ setEmail: (email: string) => {
274
+ state.email = email;
275
+ },
276
+ };
277
+ },
278
+ });
279
+ ```
280
+
281
+ **Naming convention:** Use `xxxStore` for store specifications (e.g., `userStore`, `authStore`, `cartStore`).
282
+
283
+ ### Services
284
+
285
+ A **service** is a factory function that creates dependencies like API clients, loggers, or utilities. Services are cached by the container.
286
+
287
+ ```ts
288
+ // Service factory (use xxxService naming)
289
+ function apiService(resolver) {
290
+ return {
291
+ get: (url: string) => fetch(url).then((r) => r.json()),
292
+ post: (url: string, data: unknown) =>
293
+ fetch(url, { method: "POST", body: JSON.stringify(data) }).then((r) =>
294
+ r.json()
295
+ ),
296
+ };
297
+ }
298
+
299
+ function loggerService(resolver) {
300
+ return {
301
+ info: (msg: string) => console.log(`[INFO] ${msg}`),
302
+ error: (msg: string) => console.error(`[ERROR] ${msg}`),
303
+ };
304
+ }
305
+ ```
306
+
307
+ **Naming convention:** Use `xxxService` for service factories (e.g., `apiService`, `loggerService`, `authService`).
308
+
309
+ ### Using Services in Stores
310
+
311
+ ```ts
312
+ const userStore = store({
313
+ name: "user",
314
+ state: { user: null },
315
+ setup({ get }) {
316
+ // Get services (cached automatically)
317
+ const api = get(apiService);
318
+ const logger = get(loggerService);
319
+
320
+ return {
321
+ fetchUser: async (id: string) => {
322
+ logger.info(`Fetching user ${id}`);
323
+ return api.get(`/users/${id}`);
324
+ },
325
+ };
326
+ },
327
+ });
328
+ ```
329
+
330
+ **What Storion does:**
331
+
332
+ 1. When `get(apiService)` is called, the container checks if an instance exists
333
+ 2. If not, it calls `apiService()` to create one and caches it
334
+ 3. Future calls to `get(apiService)` return the same instance
335
+ 4. This gives you dependency injection without complex configuration
336
+
337
+ ### Container
338
+
339
+ The **container** is the central hub that:
340
+
341
+ - Creates and caches store instances
342
+ - Creates and caches service instances
343
+ - Provides dependency injection
344
+ - Manages cleanup and disposal
345
+
346
+ ```ts
347
+ import { container } from "storion";
348
+
349
+ const app = container();
350
+
351
+ // Get store instance
352
+ const { state, actions } = app.get(userStore);
353
+
354
+ // Get service instance
355
+ const api = app.get(apiService);
356
+
357
+ // Clear all instances (useful for testing)
358
+ app.clear();
359
+
360
+ // Dispose container (cleanup all resources)
361
+ app.dispose();
362
+ ```
363
+
364
+ ### Reactivity
365
+
366
+ Storion's reactivity is built on a simple principle: **track reads, notify on writes**.
367
+
368
+ ```ts
369
+ // When you read state.count, Storion tracks this access
370
+ const value = state.count;
371
+
372
+ // When you write state.count, Storion notifies all trackers
373
+ state.count = value + 1;
374
+ ```
375
+
376
+ **What Storion does behind the scenes:**
377
+
378
+ 1. State is wrapped in a tracking layer
379
+ 2. Each read is recorded: "Component A depends on `count`"
380
+ 3. Each write triggers a check: "Who depends on `count`? Notify them."
381
+ 4. Only affected subscribers are notified, keeping updates minimal
382
+
383
+ ---
384
+
385
+ ## Working with State
386
+
387
+ ### Direct Mutation
388
+
389
+ For **first-level properties**, you can assign directly:
390
+
391
+ ```ts
392
+ const userStore = store({
393
+ name: "user",
394
+ state: {
395
+ name: "",
396
+ age: 0,
397
+ isActive: false,
398
+ },
399
+ setup({ state }) {
400
+ return {
401
+ setName: (name: string) => {
402
+ state.name = name;
403
+ },
404
+ setAge: (age: number) => {
405
+ state.age = age;
406
+ },
407
+ activate: () => {
408
+ state.isActive = true;
409
+ },
410
+ };
411
+ },
412
+ });
413
+ ```
414
+
415
+ **Use case:** Simple state updates where you're changing a top-level property.
416
+
417
+ **What Storion does:**
418
+
419
+ 1. Intercepts the assignment `state.name = name`
420
+ 2. Compares old and new values
421
+ 3. If different, notifies all subscribers watching `name`
422
+
423
+ ### Nested State with update()
424
+
425
+ For **nested objects or arrays**, use `update()` with an immer-style draft:
426
+
427
+ ```ts
428
+ const userStore = store({
228
429
  name: "user",
229
430
  state: {
230
431
  profile: { name: "", email: "" },
231
- theme: "light" as "light" | "dark",
432
+ tags: [] as string[],
232
433
  },
233
434
  setup({ state, update }) {
234
435
  return {
235
- // Direct mutation - only works for first-level properties
236
- setTheme: (theme: "light" | "dark") => {
237
- state.theme = theme;
436
+ // Update nested object
437
+ setProfileName: (name: string) => {
438
+ update((draft) => {
439
+ draft.profile.name = name;
440
+ });
238
441
  },
239
442
 
240
- // For nested state, use update() with immer-style draft
241
- setName: (name: string) => {
443
+ // Update array
444
+ addTag: (tag: string) => {
242
445
  update((draft) => {
243
- draft.profile.name = name;
446
+ draft.tags.push(tag);
244
447
  });
245
448
  },
246
449
 
247
- // Batch update multiple nested properties
248
- updateProfile: (profile: Partial<typeof state.profile>) => {
450
+ // Batch multiple changes
451
+ updateProfile: (name: string, email: string) => {
249
452
  update((draft) => {
250
- Object.assign(draft.profile, profile);
453
+ draft.profile.name = name;
454
+ draft.profile.email = email;
251
455
  });
252
456
  },
253
457
  };
@@ -255,18 +459,22 @@ export const userStore = store({
255
459
  });
256
460
  ```
257
461
 
258
- > **Important:** Direct mutation (`state.prop = value`) only works for **first-level properties**. For nested state or array mutations, always use `update()` which provides an immer-powered draft.
462
+ **Use case:** Any mutation to nested objects, arrays, or when you need to update multiple properties atomically.
259
463
 
260
- ### Using Focus (Lens-like State Access)
464
+ **What Storion does:**
261
465
 
262
- **The problem:** Updating deeply nested state is verbose. You end up writing `update(draft => { draft.a.b.c = value })` repeatedly, or creating many small `update()` calls.
466
+ 1. Creates a draft copy of the state
467
+ 2. Lets you mutate the draft freely
468
+ 3. Compares the draft to the original state
469
+ 4. Applies only the changes and notifies affected subscribers
470
+ 5. All changes within one `update()` call are batched into a single notification
263
471
 
264
- **With Storion:** `focus()` gives you a getter/setter pair for any path. The setter supports direct values, reducers, and immer-style mutations.
472
+ ### Focus (Lens-like Access)
265
473
 
266
- ```ts
267
- import { store } from "storion";
474
+ `focus()` creates a getter/setter pair for any state path:
268
475
 
269
- export const settingsStore = store({
476
+ ```ts
477
+ const settingsStore = store({
270
478
  name: "settings",
271
479
  state: {
272
480
  user: { name: "", email: "" },
@@ -276,7 +484,7 @@ export const settingsStore = store({
276
484
  },
277
485
  },
278
486
  setup({ focus }) {
279
- // Focus on nested paths - returns [getter, setter]
487
+ // Create focused accessors
280
488
  const [getTheme, setTheme] = focus("preferences.theme");
281
489
  const [getUser, setUser] = focus("user");
282
490
 
@@ -284,53 +492,64 @@ export const settingsStore = store({
284
492
  // Direct value
285
493
  setTheme,
286
494
 
287
- // Reducer - returns new value
495
+ // Computed from previous value
288
496
  toggleTheme: () => {
289
497
  setTheme((prev) => (prev === "light" ? "dark" : "light"));
290
498
  },
291
499
 
292
- // Produce - immer-style mutation (no return)
500
+ // Immer-style mutation on focused path
293
501
  updateUserName: (name: string) => {
294
502
  setUser((draft) => {
295
503
  draft.name = name;
296
504
  });
297
505
  },
298
506
 
299
- // Getter is reactive - can be used in effects
507
+ // Getter for use in effects
300
508
  getTheme,
301
509
  };
302
510
  },
303
511
  });
304
512
  ```
305
513
 
306
- **Focus setter supports three patterns:**
514
+ **Use case:** When you frequently access a deep path and want cleaner code.
307
515
 
308
- | Pattern | Example | Use when |
516
+ **What Storion does:**
517
+
518
+ 1. Parses the path `"preferences.theme"` once at setup time
519
+ 2. The getter reads directly from that path
520
+ 3. The setter determines the update type automatically:
521
+ - Direct value: `setTheme("dark")`
522
+ - Reducer (returns new value): `setTheme(prev => newValue)`
523
+ - Producer (mutates draft): `setTheme(draft => { draft.x = y })`
524
+
525
+ **Focus setter patterns:**
526
+
527
+ | Pattern | Example | When to use |
309
528
  | ------------ | ------------------------------- | ----------------------------- |
310
- | Direct value | `set(newValue)` | Replacing entire value |
311
- | Reducer | `set(prev => newValue)` | Computing from previous |
312
- | Produce | `set(draft => { draft.x = y })` | Partial updates (immer-style) |
529
+ | Direct value | `set("dark")` | Replacing the entire value |
530
+ | Reducer | `set(prev => prev + 1)` | Computing from previous value |
531
+ | Producer | `set(draft => { draft.x = 1 })` | Partial updates to objects |
313
532
 
314
- ### Reactive Effects
533
+ ---
315
534
 
316
- **The problem:** You need to sync with external systems (WebSocket, localStorage) or compute derived state when dependencies change, and properly clean up when needed.
535
+ ## Reactive Effects
317
536
 
318
- **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.
537
+ Effects are functions that run automatically when their dependencies change.
319
538
 
320
- **Example 1: Computed/Derived State**
539
+ ### Basic Effect
321
540
 
322
541
  ```ts
323
542
  import { store, effect } from "storion";
324
543
 
325
- export const userStore = store({
544
+ const userStore = store({
326
545
  name: "user",
327
546
  state: {
328
547
  firstName: "",
329
548
  lastName: "",
330
- fullName: "", // Computed from firstName + lastName
549
+ fullName: "",
331
550
  },
332
551
  setup({ state }) {
333
- // Auto-updates fullName when firstName or lastName changes
552
+ // Effect runs when firstName or lastName changes
334
553
  effect(() => {
335
554
  state.fullName = `${state.firstName} ${state.lastName}`.trim();
336
555
  });
@@ -347,30 +566,36 @@ export const userStore = store({
347
566
  });
348
567
  ```
349
568
 
350
- **Example 2: External System Sync**
569
+ **Use case:** Computed/derived state that should stay in sync with source data.
351
570
 
352
- ```ts
353
- import { store, effect } from "storion";
571
+ **What Storion does:**
572
+
573
+ 1. Runs the effect function immediately
574
+ 2. Tracks every state read during execution (`firstName`, `lastName`)
575
+ 3. When any tracked value changes, re-runs the effect
576
+ 4. The effect updates `fullName`, which notifies its own subscribers
354
577
 
355
- export const syncStore = store({
578
+ ### Effect with Cleanup
579
+
580
+ ```ts
581
+ const syncStore = store({
356
582
  name: "sync",
357
583
  state: {
358
584
  userId: null as string | null,
359
- syncStatus: "idle" as "idle" | "syncing" | "synced",
585
+ status: "idle" as "idle" | "connected" | "error",
360
586
  },
361
587
  setup({ state }) {
362
588
  effect((ctx) => {
363
589
  if (!state.userId) return;
364
590
 
365
591
  const ws = new WebSocket(`/ws?user=${state.userId}`);
366
- state.syncStatus = "syncing";
592
+ state.status = "connected";
367
593
 
368
- ws.onopen = () => {
369
- state.syncStatus = "synced";
370
- };
371
-
372
- // Cleanup when effect re-runs or store disposes
373
- ctx.onCleanup(() => ws.close());
594
+ // Cleanup runs before next effect or on dispose
595
+ ctx.onCleanup(() => {
596
+ ws.close();
597
+ state.status = "idle";
598
+ });
374
599
  });
375
600
 
376
601
  return {
@@ -385,74 +610,27 @@ export const syncStore = store({
385
610
  });
386
611
  ```
387
612
 
388
- ### Effect Re-run
389
-
390
- Effects automatically re-run when their tracked state changes. There are three ways an effect can be triggered to re-run:
391
-
392
- **1. Tracked state changes** — The most common case. When any state property read during the effect's execution changes, the effect re-runs automatically.
393
-
394
- ```ts
395
- effect(() => {
396
- // This effect tracks `state.count` and re-runs when it changes
397
- console.log("Count is:", state.count);
398
- });
399
- ```
400
-
401
- **2. Calling `ctx.refresh()` asynchronously** — You can manually trigger a re-run from async code (promises, setTimeout, event handlers).
402
-
403
- ```ts
404
- effect((ctx) => {
405
- // Schedule a refresh after some async work
406
- ctx.safe(fetchData()).then(() => {
407
- ctx.refresh(); // Re-runs the effect
408
- });
409
-
410
- // Or from a setTimeout
411
- setTimeout(() => {
412
- ctx.refresh();
413
- }, 1000);
414
- });
415
- ```
416
-
417
- **3. Returning `ctx.refresh`** — For synchronous refresh requests, return `ctx.refresh` from the effect. The effect will re-run after the current execution completes.
418
-
419
- ```ts
420
- effect((ctx) => {
421
- const data = async.wait(state.asyncData); // May throw a promise
422
- // If we get here, data is available
423
- state.result = transform(data);
424
-
425
- // Return ctx.refresh to request a re-run after this execution
426
- if (needsAnotherRun) {
427
- return ctx.refresh;
428
- }
429
- });
430
- ```
613
+ **Use case:** Managing resources like WebSocket connections, event listeners, or timers.
431
614
 
432
- > **Important:** Effects cannot re-run while already executing. Calling `ctx.refresh()` synchronously during effect execution throws an error:
433
- >
434
- > ```ts
435
- > effect((ctx) => {
436
- > ctx.refresh(); // ❌ Error: Effect is already running, cannot refresh
437
- > });
438
- > ```
439
- >
440
- > This prevents infinite loops and ensures predictable execution. Use the return pattern or async scheduling instead.
615
+ **What Storion does:**
441
616
 
442
- ### Effect with Safe Async
617
+ 1. Runs effect when `userId` changes
618
+ 2. Before re-running, calls the cleanup function from the previous run
619
+ 3. When the store is disposed, calls cleanup one final time
620
+ 4. This prevents resource leaks
443
621
 
444
- **The problem:** When an effect re-runs before an async operation completes, you get stale data or "state update on unmounted component" warnings. Managing this manually is error-prone.
622
+ ### Effect with Async Operations
445
623
 
446
- **With Storion:** Use `ctx.safe()` to wrap promises that should be ignored if stale, or `ctx.signal` for fetch cancellation.
624
+ Effects must be synchronous, but you can handle async operations safely:
447
625
 
448
626
  ```ts
449
627
  effect((ctx) => {
450
628
  const userId = state.userId;
451
629
  if (!userId) return;
452
630
 
453
- // ctx.safe() wraps promises to never resolve if stale
631
+ // ctx.safe() wraps a promise to ignore stale results
454
632
  ctx.safe(fetchUserData(userId)).then((data) => {
455
- // Only runs if effect hasn't re-run
633
+ // Only runs if this effect is still current
456
634
  state.userData = data;
457
635
  });
458
636
 
@@ -465,930 +643,762 @@ effect((ctx) => {
465
643
  });
466
644
  ```
467
645
 
468
- ### Fine-Grained Updates with `pick()`
469
-
470
- **The problem:** Your component re-renders when _any_ property of a nested object changes, even though you only use one field. For example, reading `state.profile.name` triggers re-renders when `profile.email` changes too.
471
-
472
- **With Storion:** Wrap computed values in `pick()` to track the _result_ instead of the _path_. Re-renders only happen when the picked value actually changes.
473
-
474
- ```tsx
475
- import { pick } from "storion";
476
-
477
- function UserProfile() {
478
- // Without pick: re-renders when ANY profile property changes
479
- const { name } = useStore(({ get }) => {
480
- const [state] = get(userStore);
481
- return { name: state.profile.name };
482
- });
483
-
484
- // With pick: re-renders ONLY when the picked value changes
485
- // Multiple picks can be used in one selector
486
- const { name, fullName, coords, settings, nested } = useStore(({ get }) => {
487
- const [state] = get(userStore);
488
- return {
489
- // Simple pick - uses default equality (===)
490
- name: pick(() => state.profile.name),
491
-
492
- // Computed value - only re-renders when result changes
493
- fullName: pick(() => `${state.profile.first} ${state.profile.last}`),
494
-
495
- // 'shallow' - compares object properties one level deep
496
- coords: pick(
497
- () => ({ x: state.position.x, y: state.position.y }),
498
- "shallow"
499
- ),
500
-
501
- // 'deep' - recursively compares nested objects/arrays
502
- settings: pick(() => state.userSettings, "deep"),
503
-
504
- // Custom equality function - full control
505
- nested: pick(
506
- () => state.data.items.map((i) => i.id),
507
- (a, b) => a.length === b.length && a.every((id, i) => id === b[i])
508
- ),
509
- };
510
- });
511
-
512
- return <h1>{name}</h1>;
513
- }
514
- ```
515
-
516
- ### Understanding Equality: Store vs Component Level
517
-
518
- Storion provides two layers of equality control, each solving different problems:
646
+ **Use case:** Data fetching that should be cancelled when dependencies change.
519
647
 
520
- | Layer | API | When it runs | Purpose |
521
- | -------------------- | ----------------- | --------------------- | ------------------------------------------------ |
522
- | **Store (write)** | `equality` option | When state is mutated | Prevent unnecessary notifications to subscribers |
523
- | **Component (read)** | `pick(fn, eq)` | When selector runs | Prevent unnecessary re-renders |
648
+ **What Storion does:**
524
649
 
525
- ```
526
- ┌─────────────────────────────────────────────────────────────────────┐
527
- │ Store │
528
- │ ┌──────────────────────────────────────────────────────────────┐ │
529
- │ │ state.coords = { x: 1, y: 2 } │ │
530
- │ │ │ │ │
531
- │ │ ▼ │ │
532
- │ │ equality: { coords: "shallow" } ──► Same x,y? Skip notify │ │
533
- │ └──────────────────────────────────────────────────────────────┘ │
534
- │ │ │
535
- │ notify if changed │
536
- │ ▼ │
537
- │ ┌──────────────────────────────────────────────────────────────┐ │
538
- │ │ Component A │ Component B │ │
539
- │ │ pick(() => coords.x) │ pick(() => coords, "shallow")│ │
540
- │ │ │ │ │ │ │
541
- │ │ ▼ │ ▼ │ │
542
- │ │ Re-render if x changed │ Re-render if x OR y changed │ │
543
- │ └──────────────────────────────────────────────────────────────┘ │
544
- └─────────────────────────────────────────────────────────────────────┘
545
- ```
650
+ 1. `ctx.safe()` wraps the promise in a guard
651
+ 2. If the effect re-runs before the promise resolves, the guard prevents the callback from executing
652
+ 3. `ctx.signal` is an AbortSignal that aborts when the effect re-runs
653
+ 4. This prevents race conditions and stale data updates
546
654
 
547
- **Example: Coordinates update**
655
+ ### Manual Effect Refresh
548
656
 
549
657
  ```ts
550
- // Store level - controls when subscribers get notified
551
- const mapStore = store({
552
- state: { coords: { x: 0, y: 0 }, zoom: 1 },
553
- equality: {
554
- coords: "shallow", // Don't notify if same { x, y } values
555
- },
556
- setup({ state }) {
557
- return {
558
- setCoords: (x: number, y: number) => {
559
- state.coords = { x, y }; // New object, but shallow-equal = no notify
560
- },
561
- };
562
- },
563
- });
658
+ effect((ctx) => {
659
+ // From async code
660
+ setTimeout(() => {
661
+ ctx.refresh(); // Triggers a re-run
662
+ }, 1000);
564
663
 
565
- // Component level - controls when THIS component re-renders
566
- function XCoordinate() {
567
- const { x } = useStore(({ get }) => {
568
- const [state] = get(mapStore);
569
- return {
570
- // Even if coords changed, only re-render if x specifically changed
571
- x: pick(() => state.coords.x),
572
- };
573
- });
574
- return <span>X: {x}</span>;
575
- }
664
+ // Or by returning ctx.refresh
665
+ if (needsAnotherRun) {
666
+ return ctx.refresh;
667
+ }
668
+ });
576
669
  ```
577
670
 
578
- ### Comparison with Other Libraries
579
-
580
- | Feature | Storion | Redux | Zustand | Jotai | MobX |
581
- | ------------------ | --------------------- | ------------------------ | ---------------- | ----------------- | --------------- |
582
- | **Tracking** | Automatic | Manual selectors | Manual selectors | Automatic (atoms) | Automatic |
583
- | **Write equality** | Per-property config | Reducer-based | Built-in shallow | Per-atom | Deep by default |
584
- | **Read equality** | `pick()` with options | `useSelector` + equality | `shallow` helper | Atom-level | Computed |
585
- | **Granularity** | Property + component | Selector-based | Selector-based | Atom-based | Property-based |
586
- | **Bundle size** | ~4KB | ~2KB + toolkit | ~1KB | ~2KB | ~15KB |
587
- | **DI / Lifecycle** | Built-in container | External (thunk/saga) | External | Provider-based | External |
588
-
589
- **Key differences:**
590
-
591
- - **Redux/Zustand**: You write selectors manually and pass equality functions to `useSelector`. Easy to forget and over-subscribe.
671
+ **Important:** You cannot call `ctx.refresh()` synchronously during effect execution. This throws an error to prevent infinite loops.
592
672
 
593
- ```ts
594
- // Zustand - must remember to add shallow
595
- const coords = useStore((s) => s.coords, shallow);
596
- ```
597
-
598
- - **Jotai**: Fine-grained via atoms, but requires splitting state into many atoms upfront.
599
-
600
- ```ts
601
- // Jotai - must create separate atoms
602
- const xAtom = atom((get) => get(coordsAtom).x);
603
- ```
604
-
605
- - **Storion**: Auto-tracking by default, `pick()` for opt-in fine-grained control, store-level equality for write optimization.
606
-
607
- ```ts
608
- // Storion - automatic tracking, pick() when you need precision
609
- const x = pick(() => state.coords.x);
610
- ```
673
+ ---
611
674
 
612
- ### Async State Management
675
+ ## Async State Management
613
676
 
614
- **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.
677
+ Storion provides helpers for managing async operations with loading, error, and success states.
615
678
 
616
- **With Storion:** The `async()` helper manages all async states automatically. Choose "fresh" mode (clear data while loading) or "stale" mode (keep previous data like SWR).
679
+ ### Defining Async State
617
680
 
618
681
  ```ts
619
682
  import { store } from "storion";
620
- import { async, type AsyncState } from "storion/async";
683
+ import { async } from "storion/async";
621
684
 
622
- interface Product {
685
+ interface User {
623
686
  id: string;
624
687
  name: string;
625
- price: number;
626
688
  }
627
689
 
628
- export const productStore = store({
629
- name: "products",
690
+ const userStore = store({
691
+ name: "user",
630
692
  state: {
631
693
  // Fresh mode: data is undefined during loading
632
- featured: async.fresh<Product>(),
694
+ currentUser: async.fresh<User>(),
695
+
633
696
  // Stale mode: preserves previous data during loading (SWR pattern)
634
- list: async.stale<Product[]>([]),
697
+ userList: async.stale<User[]>([]),
635
698
  },
636
699
  setup({ focus }) {
637
- const featuredActions = async<Product, "fresh", [string]>(
638
- focus("featured"),
639
- async (ctx, productId) => {
640
- const res = await fetch(`/api/products/${productId}`, {
641
- signal: ctx.signal,
642
- });
700
+ const currentUserAsync = async(
701
+ focus("currentUser"),
702
+ async (ctx, userId: string) => {
703
+ const res = await fetch(`/api/users/${userId}`, { signal: ctx.signal });
643
704
  return res.json();
644
705
  },
645
706
  {
646
707
  retry: { count: 3, delay: (attempt) => attempt * 1000 },
647
- onError: (err) => console.error("Failed to fetch product:", err),
648
- }
649
- );
650
-
651
- const listActions = async<Product[], "stale", []>(
652
- focus("list"),
653
- async () => {
654
- const res = await fetch("/api/products");
655
- return res.json();
708
+ onError: (err) => console.error("Failed:", err),
656
709
  }
657
710
  );
658
711
 
659
712
  return {
660
- fetchFeatured: featuredActions.dispatch,
661
- fetchList: listActions.dispatch,
662
- refreshList: listActions.refresh,
663
- cancelFeatured: featuredActions.cancel,
713
+ fetchUser: currentUserAsync.dispatch,
714
+ cancelFetch: currentUserAsync.cancel,
715
+ refreshUser: currentUserAsync.refresh,
664
716
  };
665
717
  },
666
718
  });
719
+ ```
720
+
721
+ **Use case:** API calls, data fetching, any async operation that needs loading/error states.
722
+
723
+ **What Storion does:**
724
+
725
+ 1. `async.fresh<User>()` creates initial state: `{ status: "idle", data: undefined, error: undefined }`
726
+ 2. When `dispatch()` is called:
727
+ - Sets status to `"pending"`
728
+ - In fresh mode, clears data; in stale mode, keeps previous data
729
+ 3. When the promise resolves:
730
+ - Sets status to `"success"` and stores the data
731
+ 4. When the promise rejects:
732
+ - Sets status to `"error"` and stores the error
733
+ 5. If `cancel()` is called, aborts the request via `ctx.signal`
667
734
 
668
- // In React - handle async states
669
- function ProductList() {
670
- const { list, fetchList } = useStore(({ get }) => {
671
- const [state, actions] = get(productStore);
672
- return { list: state.list, fetchList: actions.fetchList };
735
+ ### Consuming Async State
736
+
737
+ ```tsx
738
+ function UserProfile() {
739
+ const { user, fetchUser } = useStore(({ get }) => {
740
+ const [state, actions] = get(userStore);
741
+ return { user: state.currentUser, fetchUser: actions.fetchUser };
673
742
  });
674
743
 
675
744
  useEffect(() => {
676
- fetchList();
745
+ fetchUser("123");
677
746
  }, []);
678
747
 
679
- if (list.status === "pending" && !list.data?.length) {
680
- return <Spinner />;
681
- }
682
-
683
- if (list.status === "error") {
684
- return <Error message={list.error.message} />;
685
- }
748
+ if (user.status === "pending") return <Spinner />;
749
+ if (user.status === "error") return <Error message={user.error.message} />;
750
+ if (user.status === "idle") return null;
686
751
 
687
- return (
688
- <ul>
689
- {list.data?.map((p) => (
690
- <li key={p.id}>{p.name}</li>
691
- ))}
692
- {list.status === "pending" && <li>Loading more...</li>}
693
- </ul>
694
- );
752
+ return <div>{user.data.name}</div>;
695
753
  }
696
754
  ```
697
755
 
698
- ### When to Fetch Data
699
-
700
- Storion provides multiple patterns for data fetching. Choose based on your use case:
701
-
702
- | Pattern | When to use | Example |
703
- | ----------------------- | ---------------------------------------------- | ---------------------------------- |
704
- | **Setup time** | Data needed immediately when store initializes | App config, user session |
705
- | **Trigger (no deps)** | One-time fetch when component mounts | Initial page data |
706
- | **Trigger (with deps)** | Refetch when component visits or deps change | Dashboard refresh |
707
- | **useEffect** | Standard React pattern, explicit control | Compatibility with existing code |
708
- | **User interaction** | On-demand fetching | Search, pagination, refresh button |
756
+ ### Async State with Suspense
709
757
 
710
758
  ```tsx
711
- import { store } from "storion";
712
- import { async, type AsyncState } from "storion/async";
759
+ import { trigger } from "storion";
760
+ import { async } from "storion/async";
713
761
  import { useStore } from "storion/react";
714
- import { useEffect } from "react";
715
-
716
- interface User {
717
- id: string;
718
- name: string;
719
- }
762
+ import { Suspense } from "react";
720
763
 
721
- export const userStore = store({
722
- name: "users",
764
+ function UserProfile() {
765
+ const { user } = useStore(({ get }) => {
766
+ const [state, actions] = get(userStore);
767
+
768
+ // Trigger fetch on mount
769
+ trigger(actions.fetchUser, [], "123");
770
+
771
+ return {
772
+ // async.wait() throws if pending (triggers Suspense)
773
+ user: async.wait(state.currentUser),
774
+ };
775
+ });
776
+
777
+ // Only renders when data is ready
778
+ return <div>{user.name}</div>;
779
+ }
780
+
781
+ function App() {
782
+ return (
783
+ <Suspense fallback={<Spinner />}>
784
+ <UserProfile />
785
+ </Suspense>
786
+ );
787
+ }
788
+ ```
789
+
790
+ **What Storion does:**
791
+
792
+ 1. `async.wait()` checks the async state's status
793
+ 2. If `"pending"`, throws a promise that React Suspense catches
794
+ 3. If `"error"`, throws the error for ErrorBoundary to catch
795
+ 4. If `"success"`, returns the data
796
+ 5. When the data arrives, Suspense re-renders the component
797
+
798
+ ### Derived Async State
799
+
800
+ ```ts
801
+ const dashboardStore = store({
802
+ name: "dashboard",
723
803
  state: {
724
- currentUser: async.fresh<User>(),
725
- searchResults: async.stale<User[]>([]),
804
+ user: async.fresh<User>(),
805
+ posts: async.fresh<Post[]>(),
806
+ summary: async.fresh<{ name: string; postCount: number }>(),
726
807
  },
727
- setup({ focus, effect }) {
728
- // ═══════════════════════════════════════════════════════════════════
729
- // Pattern 1: Fetch at SETUP TIME
730
- // Data is fetched immediately when store is created
731
- // Good for: App config, auth state, critical data
732
- // ═══════════════════════════════════════════════════════════════════
733
- const currentUserAsync = async(focus("currentUser"), async (ctx) => {
734
- const res = await fetch("/api/me", { signal: ctx.signal });
735
- return res.json();
808
+ setup({ state, focus }) {
809
+ // ... async actions for user and posts ...
810
+
811
+ // Option 1: Using async.all() - simpler for multiple sources
812
+ async.derive(focus("summary"), () => {
813
+ const [user, posts] = async.all(state.user, state.posts);
814
+ return { name: user.name, postCount: posts.length };
736
815
  });
737
816
 
738
- // Fetch immediately during setup
739
- currentUserAsync.dispatch();
740
-
741
- // ═══════════════════════════════════════════════════════════════════
742
- // Pattern 2: Expose DISPATCH for UI control
743
- // Store provides action, UI decides when to call
744
- // Good for: Search, pagination, user-triggered refresh
745
- // ═══════════════════════════════════════════════════════════════════
746
- const searchAsync = async(
747
- focus("searchResults"),
748
- async (ctx, query: string) => {
749
- const res = await fetch(`/api/users/search?q=${query}`, {
750
- signal: ctx.signal,
751
- });
752
- return res.json();
753
- }
754
- );
817
+ // Option 2: Using async.wait() - more control for conditional logic
818
+ async.derive(focus("summary"), () => {
819
+ const user = async.wait(state.user);
820
+ const posts = async.wait(state.posts);
821
+ return { name: user.name, postCount: posts.length };
822
+ });
755
823
 
756
824
  return {
757
- currentUser: currentUserAsync,
758
- search: searchAsync.dispatch,
759
- cancelSearch: searchAsync.cancel,
825
+ /* actions */
760
826
  };
761
827
  },
762
828
  });
829
+ ```
763
830
 
764
- // ═══════════════════════════════════════════════════════════════════════════
765
- // Pattern 3: TRIGGER with dependencies
766
- // Uses useStore's `trigger` for declarative data fetching
767
- // ═══════════════════════════════════════════════════════════════════════════
768
-
769
- // 3a. No deps - fetch ONCE when component mounts
770
- function UserProfile() {
771
- const { user } = useStore(({ get, trigger }) => {
772
- const [state, actions] = get(userStore);
773
-
774
- // No deps array = fetch once, never refetch
775
- trigger(actions.currentUser.dispatch, []);
831
+ **Use case:** Computing a value from multiple async sources.
776
832
 
777
- return { user: state.currentUser };
778
- });
833
+ **What Storion does:**
779
834
 
780
- if (user.status === "pending") return <Spinner />;
781
- return <div>{user.data?.name}</div>;
782
- }
835
+ 1. Runs the derive function and tracks dependencies
836
+ 2. If any source is pending/error, the derived state mirrors that status
837
+ 3. If all sources are ready, computes and stores the result
838
+ 4. Re-runs automatically when source states change
783
839
 
784
- // 3b. With context.id - refetch EVERY TIME component visits
785
- function Dashboard() {
786
- const { user } = useStore(({ get, trigger, id }) => {
787
- const [state, actions] = get(userStore);
840
+ **When to use each approach:**
788
841
 
789
- // `id` changes each time component mounts = refetch on every visit
790
- trigger(actions.currentUser.dispatch, [id]);
842
+ | Approach | Best for |
843
+ | -------------- | ----------------------------------------------------- |
844
+ | `async.all()` | Waiting for multiple sources at once (cleaner syntax) |
845
+ | `async.wait()` | Conditional logic where you may not need all sources |
791
846
 
792
- return { user: state.currentUser };
793
- });
794
-
795
- return <div>Welcome back, {user.data?.name}</div>;
796
- }
847
+ ---
797
848
 
798
- // 3c. With custom deps - refetch when deps change
799
- function UserById({ userId }: { userId: string }) {
800
- const { user } = useStore(({ get, trigger }) => {
801
- const [state, actions] = get(userStore);
849
+ ## Using Stores in React
802
850
 
803
- // Refetch when userId prop changes
804
- trigger(actions.currentUser.dispatch, [userId]);
851
+ ### useStore Hook
805
852
 
806
- return { user: state.currentUser };
807
- });
808
-
809
- return <div>{user.data?.name}</div>;
810
- }
853
+ ```tsx
854
+ import { useStore } from "storion/react";
855
+ import { trigger } from "storion";
811
856
 
812
- // ═══════════════════════════════════════════════════════════════════════════
813
- // Pattern 4: useEffect - standard React pattern
814
- // For compatibility or when you need more control
815
- // ═══════════════════════════════════════════════════════════════════════════
816
- function UserListWithEffect() {
817
- const { search } = useStore(({ get }) => {
818
- const [, actions] = get(userStore);
819
- return { search: actions.search };
820
- });
857
+ function Component() {
858
+ const { count, inc, user } = useStore(({ get, id }) => {
859
+ const [counterState, counterActions] = get(counterStore);
860
+ const [userState, userActions] = get(userStore);
821
861
 
822
- useEffect(() => {
823
- search("initial");
824
- }, []);
862
+ // Trigger immediately (empty deps = once)
863
+ trigger(userActions.fetchProfile, []); // OR trigger(userActions.fetchProfile);
825
864
 
826
- return <div>...</div>;
827
- }
865
+ // Trigger on each component mount (id is unique per mount)
866
+ trigger(userActions.refresh, [id]);
828
867
 
829
- // ═══════════════════════════════════════════════════════════════════════════
830
- // Pattern 5: USER INTERACTION - on-demand fetching
831
- // ═══════════════════════════════════════════════════════════════════════════
832
- function SearchBox() {
833
- const [query, setQuery] = useState("");
834
- const { results, search, cancel } = useStore(({ get }) => {
835
- const [state, actions] = get(userStore);
836
868
  return {
837
- results: state.searchResults,
838
- search: actions.search,
839
- cancel: actions.cancelSearch,
869
+ count: counterState.count,
870
+ inc: counterActions.inc,
871
+ user: userState.profile,
840
872
  };
841
873
  });
842
874
 
843
- const handleSearch = () => {
844
- cancel(); // Cancel previous search
845
- search(query);
846
- };
847
-
848
- return (
849
- <div>
850
- <input value={query} onChange={(e) => setQuery(e.target.value)} />
851
- <button onClick={handleSearch}>Search</button>
852
- <button onClick={cancel}>Cancel</button>
853
-
854
- {results.status === "pending" && <Spinner />}
855
- {results.data?.map((user) => (
856
- <div key={user.id}>{user.name}</div>
857
- ))}
858
- </div>
859
- );
875
+ return <div>...</div>;
860
876
  }
861
877
  ```
862
878
 
863
- **Summary: Choosing the right pattern**
879
+ **Selector context provides:**
864
880
 
865
- ```
866
- ┌─────────────────────────────────────────────────────────────────────────┐
867
- │ When should data be fetched? │
868
- ├─────────────────────────────────────────────────────────────────────────┤
869
- │ │
870
- │ App starts? ──────────► Setup time (dispatch in setup) │
871
- │ │
872
- │ Component mounts? │
873
- │ │ │
874
- │ ├── Once ever? ────► trigger(fn, []) │
875
- │ │ │
876
- │ ├── Every visit? ──► trigger(fn, [context.id]) │
877
- │ │ │
878
- │ └── When deps change? ► trigger(fn, [dep1, dep2]) │
879
- │ │
880
- │ User clicks? ──────────► onClick handler calls action │
881
- │ │
882
- └─────────────────────────────────────────────────────────────────────────┘
883
- ```
881
+ | Property | Description |
882
+ | -------------------------- | ---------------------------------------------- |
883
+ | `get(store)` | Get store instance, returns `[state, actions]` |
884
+ | `get(service)` | Get service instance (cached) |
885
+ | `create(service, ...args)` | Create fresh service instance with args |
886
+ | `id` | Unique ID per component mount |
887
+ | `once(fn)` | Run function once on mount |
884
888
 
885
- ### Suspense Pattern with `async.wait()`
889
+ **Global function `trigger()`** Call a function when dependencies change (import from `"storion"`).
886
890
 
887
- **The problem:** You want to use React Suspense for loading states, but managing the "throw promise" pattern manually is complex and error-prone.
891
+ ### Stable Function Wrapping
888
892
 
889
- **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.
893
+ Functions returned from `useStore` are automatically wrapped with stable references. This means:
894
+
895
+ - The function reference never changes between renders
896
+ - The function always accesses the latest props and state
897
+ - Safe to pass to child components without causing re-renders
890
898
 
891
899
  ```tsx
892
- import { Suspense } from "react";
893
- import { async } from "storion/async";
894
900
  import { useStore } from "storion/react";
895
901
 
896
- // Component that uses Suspense - no loading/error handling needed!
897
- function UserProfile() {
898
- const { user } = useStore(({ get, trigger }) => {
899
- const [state, actions] = get(userStore);
902
+ function SearchForm({ userId }: { userId: string }) {
903
+ const [query, setQuery] = useState("");
900
904
 
901
- // Trigger fetch on mount
902
- trigger(actions.fetchUser, []);
905
+ const { search, results } = useStore(({ get }) => {
906
+ const [state, actions] = get(searchStore);
903
907
 
904
908
  return {
905
- // async.wait() throws if pending/error, returns data if success
906
- user: async.wait(state.currentUser),
909
+ results: state.results,
910
+ // This function is auto-wrapped with stable reference
911
+ search: () => {
912
+ // Always has access to current query and userId
913
+ actions.performSearch(query, userId);
914
+ },
907
915
  };
908
916
  });
909
917
 
910
- // This only renders when data is ready
911
918
  return (
912
919
  <div>
913
- <h1>{user.name}</h1>
914
- <p>{user.email}</p>
920
+ <input value={query} onChange={(e) => setQuery(e.target.value)} />
921
+ {/* search reference is stable - won't cause Button to re-render */}
922
+ <Button onClick={search}>Search</Button>
923
+ <Results items={results} />
915
924
  </div>
916
925
  );
917
926
  }
918
927
 
919
- // Parent wraps with Suspense and ErrorBoundary
920
- function App() {
921
- return (
922
- <ErrorBoundary fallback={<div>Something went wrong</div>}>
923
- <Suspense fallback={<Spinner />}>
924
- <UserProfile />
925
- </Suspense>
926
- </ErrorBoundary>
927
- );
928
- }
928
+ // Button only re-renders when its own props change
929
+ const Button = memo(({ onClick, children }) => (
930
+ <button onClick={onClick}>{children}</button>
931
+ ));
929
932
  ```
930
933
 
931
- **Multiple async states with `async.all()`:**
934
+ **Use case:** Creating callbacks that depend on component state/props but need stable references for `memo`, `useCallback` dependencies, or child component optimization.
935
+
936
+ **What Storion does:**
937
+
938
+ 1. Detects functions in the selector's return value
939
+ 2. Wraps each function with a stable reference (created once)
940
+ 3. When the wrapped function is called, it executes the latest version from the selector
941
+ 4. Component state (`query`) and props (`userId`) are always current when the function runs
942
+
943
+ **Why this matters:**
932
944
 
933
945
  ```tsx
934
- function Dashboard() {
935
- const { user, posts, comments } = useStore(({ get, trigger }) => {
936
- const [userState, userActions] = get(userStore);
937
- const [postState, postActions] = get(postStore);
938
- const [commentState, commentActions] = get(commentStore);
939
-
940
- trigger(userActions.fetch);
941
- trigger(postActions.fetch);
942
- trigger(commentActions.fetch);
943
-
944
- // Wait for ALL async states - suspends until all are ready
945
- const [user, posts, comments] = async.all(
946
- userState.current,
947
- postState.list,
948
- commentState.recent
949
- );
946
+ // Without stable wrapping - new reference every render
947
+ const search = () => actions.search(query); // Changes every render!
950
948
 
951
- return { user, posts, comments };
952
- });
949
+ // Manual useCallback - verbose and easy to forget deps
950
+ const search = useCallback(() => actions.search(query), [query, actions]);
953
951
 
954
- return (
955
- <div>
956
- <h1>Welcome, {user.name}</h1>
957
- <PostList posts={posts} />
958
- <CommentList comments={comments} />
959
- </div>
960
- );
961
- }
952
+ // ✅ With useStore - stable reference, always current values
953
+ const { search } = useStore(({ get }) => ({
954
+ search: () => actions.search(query), // Stable reference!
955
+ }));
962
956
  ```
963
957
 
964
- **Race pattern with `async.race()`:**
958
+ ### Trigger Patterns
965
959
 
966
960
  ```tsx
967
- function FastestResult() {
968
- const { result } = useStore(({ get, trigger }) => {
969
- const [state, actions] = get(searchStore);
961
+ import { trigger } from "storion";
962
+ import { useStore } from "storion/react";
963
+
964
+ function Dashboard({ categoryId }: { categoryId: string }) {
965
+ const { data } = useStore(({ get, id }) => {
966
+ const [state, actions] = get(dataStore);
967
+
968
+ // Pattern 1: Fetch once ever (empty deps)
969
+ trigger(actions.fetchOnce, []);
970
+
971
+ // Pattern 2: Fetch every mount (id changes each mount)
972
+ trigger(actions.fetchEveryVisit, [id]);
973
+
974
+ // Pattern 3: Fetch when prop changes
975
+ trigger(actions.fetchByCategory, [categoryId], categoryId);
970
976
 
971
- trigger(actions.searchAPI1, [], query);
972
- trigger(actions.searchAPI2, [], query);
977
+ return { data: state.data };
978
+ });
979
+ }
980
+ ```
981
+
982
+ **What Storion does:**
973
983
 
974
- // Returns whichever finishes first
984
+ 1. `trigger()` compares current deps with previous deps
985
+ 2. If deps changed (or first render), calls the function with provided args
986
+ 3. Empty deps `[]` means "call once and never again"
987
+ 4. `[id]` means "call every time component mounts" (id is unique per mount)
988
+
989
+ ### Fine-Grained Updates with pick()
990
+
991
+ ```tsx
992
+ import { pick } from "storion";
993
+
994
+ function UserName() {
995
+ const { name, fullName } = useStore(({ get }) => {
996
+ const [state] = get(userStore);
975
997
  return {
976
- result: async.race(state.api1Results, state.api2Results),
998
+ // Re-renders ONLY when this specific value changes
999
+ name: pick(() => state.profile.name),
1000
+
1001
+ // Computed values are tracked the same way
1002
+ fullName: pick(() => `${state.profile.first} ${state.profile.last}`),
977
1003
  };
978
1004
  });
979
1005
 
980
- return <ResultList items={result} />;
1006
+ return <span>{fullName}</span>;
981
1007
  }
982
1008
  ```
983
1009
 
984
- **Async helpers summary:**
1010
+ **Use case:** When you need even more precise control over re-renders.
985
1011
 
986
- | Helper | Behavior | Use case |
987
- | ------------------------ | ------------------------------------- | ------------------------- |
988
- | `async.wait(state)` | Throws if pending/error, returns data | Single Suspense resource |
989
- | `async.all(...states)` | Waits for all, returns tuple | Multiple parallel fetches |
990
- | `async.any(...states)` | Returns first successful | Fallback sources |
991
- | `async.race(states)` | Returns fastest | Competitive fetching |
992
- | `async.hasData(state)` | `boolean` | Check without suspending |
993
- | `async.isLoading(state)` | `boolean` | Loading indicator |
994
- | `async.isError(state)` | `boolean` | Error check |
1012
+ **Without pick():** Component re-renders when `state.profile` reference changes (even if `name` didn't change).
995
1013
 
996
- ### Derived Async State with `async.derive()`
1014
+ **With pick():** Component only re-renders when the picked value actually changes.
997
1015
 
998
- **The problem:** You need to compute a value from multiple async states. If any source is loading, the derived value should be loading. If any errors, it should error. Writing this logic manually is verbose and error-prone.
1016
+ **pick() equality options:**
999
1017
 
1000
- **With Storion:** Use `async.derive()` to create computed async state. It uses `async.wait()` internally, so it automatically handles pending/error states and re-computes when sources change.
1018
+ ```tsx
1019
+ const result = useStore(({ get }) => {
1020
+ const [state] = get(mapStore);
1021
+ return {
1022
+ // Default: strict equality (===)
1023
+ x: pick(() => state.coords.x),
1001
1024
 
1002
- ```ts
1003
- import { store } from "storion";
1004
- import { async, type AsyncState } from "storion/async";
1025
+ // Shallow: compare object properties one level deep
1026
+ coords: pick(() => state.coords, "shallow"),
1005
1027
 
1006
- interface User {
1007
- id: string;
1008
- name: string;
1009
- }
1028
+ // Deep: recursive comparison
1029
+ settings: pick(() => state.settings, "deep"),
1010
1030
 
1011
- interface Post {
1012
- id: string;
1013
- title: string;
1014
- authorId: string;
1015
- }
1031
+ // Custom: provide your own function
1032
+ ids: pick(
1033
+ () => state.items.map((i) => i.id),
1034
+ (a, b) => a.length === b.length && a.every((v, i) => v === b[i])
1035
+ ),
1036
+ };
1037
+ });
1038
+ ```
1016
1039
 
1017
- export const dashboardStore = store({
1018
- name: "dashboard",
1019
- state: {
1020
- user: async.fresh<User>(),
1021
- posts: async.fresh<Post[]>(),
1022
- // Derived async state - computed from user + posts
1023
- summary: async.fresh<{ userName: string; postCount: number }>(),
1024
- },
1025
- setup({ state, focus }) {
1026
- // Fetch actions
1027
- const userActions = async(focus("user"), async (ctx, userId: string) => {
1028
- const res = await fetch(`/api/users/${userId}`, { signal: ctx.signal });
1029
- return res.json();
1030
- });
1040
+ ---
1031
1041
 
1032
- const postsActions = async(focus("posts"), async (ctx, userId: string) => {
1033
- const res = await fetch(`/api/users/${userId}/posts`, {
1034
- signal: ctx.signal,
1035
- });
1036
- return res.json();
1037
- });
1042
+ ## API Reference
1038
1043
 
1039
- // Derive summary from user + posts
1040
- // - If user OR posts is pending → summary is pending
1041
- // - If user OR posts has error → summary has error
1042
- // - If both succeed → summary is success with computed value
1043
- async.derive(focus("summary"), () => {
1044
- const user = async.wait(state.user);
1045
- const posts = async.wait(state.posts);
1046
- return {
1047
- userName: user.name,
1048
- postCount: posts.length,
1049
- };
1050
- });
1044
+ ### store(options)
1051
1045
 
1046
+ Creates a store specification.
1047
+
1048
+ ```ts
1049
+ import { store } from "storion";
1050
+
1051
+ const myStore = store({
1052
+ name: "myStore",
1053
+ state: { count: 0 },
1054
+ setup({ state, update, focus, get, create, onDispose }) {
1052
1055
  return {
1053
- fetchUser: userActions.dispatch,
1054
- fetchPosts: postsActions.dispatch,
1056
+ inc: () => state.count++,
1055
1057
  };
1056
1058
  },
1057
1059
  });
1058
1060
  ```
1059
1061
 
1060
- **Conditional dependencies:**
1062
+ **Options:**
1061
1063
 
1062
- ```ts
1063
- // Derive with conditional async sources
1064
- async.derive(focus("result"), () => {
1065
- const type = async.wait(state.type);
1066
-
1067
- // Dynamically choose which async state to wait for
1068
- if (type === "user") {
1069
- return async.wait(state.userData);
1070
- } else {
1071
- return async.wait(state.guestData);
1072
- }
1073
- });
1074
- ```
1064
+ | Option | Type | Description |
1065
+ | ------------ | ------------------------------ | ------------------------------------------- |
1066
+ | `name` | `string` | Display name for debugging |
1067
+ | `state` | `TState` | Initial state object |
1068
+ | `setup` | `(ctx) => TActions` | Setup function, returns actions |
1069
+ | `lifetime` | `"singleton" \| "autoDispose"` | Instance lifecycle (default: `"singleton"`) |
1070
+ | `equality` | `Equality \| EqualityMap` | Custom equality for state comparisons |
1071
+ | `onDispatch` | `(event) => void` | Called when any action is dispatched |
1072
+ | `onError` | `(error) => void` | Called when an error occurs |
1075
1073
 
1076
- **Parallel waiting with `async.all()`:**
1074
+ **Setup context:**
1077
1075
 
1078
- ```ts
1079
- // Wait for multiple states in parallel (more efficient)
1080
- async.derive(focus("combined"), () => {
1081
- const [user, posts, comments] = async.all(
1082
- state.user,
1083
- state.posts,
1084
- state.comments
1085
- );
1086
- return { user, posts, comments };
1087
- });
1088
- ```
1076
+ | Property | Description |
1077
+ | -------------------------- | --------------------------------------- |
1078
+ | `state` | Reactive state (first-level props only) |
1079
+ | `update(fn)` | Immer-style update for nested state |
1080
+ | `focus(path)` | Create getter/setter for a path |
1081
+ | `get(spec)` | Get dependency (store or service) |
1082
+ | `create(factory, ...args)` | Create fresh instance |
1083
+ | `dirty(prop?)` | Check if state has changed |
1084
+ | `reset()` | Reset to initial state |
1085
+ | `onDispose(fn)` | Register cleanup function |
1089
1086
 
1090
- **Stale mode - preserve data during recomputation:**
1087
+ ### container(options?)
1088
+
1089
+ Creates a container for managing store and service instances.
1091
1090
 
1092
1091
  ```ts
1093
- state: {
1094
- // Stale mode: keeps previous computed value while recomputing
1095
- summary: async.stale({ userName: "Loading...", postCount: 0 }),
1096
- }
1092
+ import { container } from "storion";
1097
1093
 
1098
- // The derive will preserve stale data during pending/error
1099
- async.derive(focus("summary"), () => {
1100
- const user = async.wait(state.user);
1101
- const posts = async.wait(state.posts);
1102
- return { userName: user.name, postCount: posts.length };
1094
+ const app = container({
1095
+ middleware: myMiddleware,
1103
1096
  });
1097
+
1098
+ // Get store instance
1099
+ const { state, actions } = app.get(userStore);
1100
+
1101
+ // Get service instance
1102
+ const api = app.get(apiService);
1103
+
1104
+ // Create with parameters
1105
+ const logger = app.create(loggerService, "myNamespace");
1106
+
1107
+ // Lifecycle
1108
+ app.delete(userStore); // Remove specific instance
1109
+ app.clear(); // Clear all instances
1110
+ app.dispose(); // Dispose container and cleanup
1104
1111
  ```
1105
1112
 
1106
- **Key behaviors:**
1113
+ **Methods:**
1107
1114
 
1108
- | Source State | Derived State |
1109
- | ------------------ | ----------------------------------- |
1110
- | Any source pending | `pending` (stale: preserves data) |
1111
- | Any source error | `error` (stale: preserves data) |
1112
- | All sources ready | `success` with computed value |
1113
- | Sources change | Auto-recomputes via effect tracking |
1115
+ | Method | Description |
1116
+ | -------------------------- | ------------------------------------- |
1117
+ | `get(spec)` | Get or create cached instance |
1118
+ | `create(factory, ...args)` | Create fresh instance (not cached) |
1119
+ | `set(spec, factory)` | Override factory (useful for testing) |
1120
+ | `delete(spec)` | Remove cached instance |
1121
+ | `clear()` | Clear all cached instances |
1122
+ | `dispose()` | Dispose container and all instances |
1114
1123
 
1115
- **`async.derive()` vs manual effects:**
1124
+ ### effect(fn, options?)
1125
+
1126
+ Creates a reactive effect.
1116
1127
 
1117
1128
  ```ts
1118
- // Manual - verbose, error-prone
1119
- effect(() => {
1120
- if (state.user.status === "pending" || state.posts.status === "pending") {
1121
- state.summary = asyncState("fresh", "pending");
1122
- return;
1123
- }
1124
- if (state.user.status === "error") {
1125
- state.summary = asyncState("fresh", "error", state.user.error);
1126
- return;
1127
- }
1128
- if (state.posts.status === "error") {
1129
- state.summary = asyncState("fresh", "error", state.posts.error);
1130
- return;
1131
- }
1132
- state.summary = asyncState("fresh", "success", {
1133
- userName: state.user.data.name,
1134
- postCount: state.posts.data.length,
1129
+ import { effect } from "storion";
1130
+
1131
+ const cleanup = effect((ctx) => {
1132
+ console.log("Count:", state.count);
1133
+
1134
+ ctx.onCleanup(() => {
1135
+ console.log("Cleaning up...");
1135
1136
  });
1136
1137
  });
1137
1138
 
1138
- // With async.derive - clean and automatic
1139
- async.derive(focus("summary"), () => {
1140
- const user = async.wait(state.user);
1141
- const posts = async.wait(state.posts);
1142
- return { userName: user.name, postCount: posts.length };
1143
- });
1139
+ // Later: stop the effect
1140
+ cleanup();
1144
1141
  ```
1145
1142
 
1146
- ### Dependency Injection
1143
+ **Context properties:**
1147
1144
 
1148
- **The problem:** Your stores need shared services (API clients, loggers, config) but importing singletons directly causes issues:
1145
+ | Property | Description |
1146
+ | --------------- | ------------------------------------ |
1147
+ | `onCleanup(fn)` | Register cleanup function |
1148
+ | `safe(promise)` | Wrap promise to ignore stale results |
1149
+ | `signal` | AbortSignal for fetch cancellation |
1150
+ | `refresh()` | Manually trigger re-run (async only) |
1149
1151
 
1150
- - **No lifecycle management** — ES imports are forever; you can't dispose or recreate instances
1151
- - **Testing is painful** — Mocking ES modules requires awkward workarounds
1152
- - **No cleanup** — Resources like connections, intervals, or subscriptions leak between tests
1152
+ **Options:**
1153
1153
 
1154
- **With Storion:** The container is a full DI system that manages the complete lifecycle:
1154
+ | Option | Type | Description |
1155
+ | ------------ | -------- | ------------------- |
1156
+ | `debugLabel` | `string` | Label for debugging |
1155
1157
 
1156
- - **Automatic caching** — Services are singletons by default, created on first use
1157
- - **Dispose & cleanup** — Call `dispose()` to clean up resources, `delete()` to remove and recreate
1158
- - **Override for testing** — Swap implementations with `set()` without touching module imports
1159
- - **Hierarchical containers** — Create child containers for scoped dependencies
1158
+ ### async(focus, handler, options?)
1159
+
1160
+ Creates async state management.
1160
1161
 
1161
1162
  ```ts
1162
- import { container, type Resolver } from "storion";
1163
+ import { async } from "storion/async";
1163
1164
 
1164
- // Define service factory
1165
- interface ApiService {
1166
- get<T>(url: string): Promise<T>;
1167
- post<T>(url: string, data: unknown): Promise<T>;
1168
- }
1165
+ const userAsync = async(
1166
+ focus("user"),
1167
+ async (ctx, userId: string) => {
1168
+ const res = await fetch(`/api/users/${userId}`, { signal: ctx.signal });
1169
+ return res.json();
1170
+ },
1171
+ {
1172
+ retry: { count: 3, delay: 1000 },
1173
+ onSuccess: (data) => console.log("Loaded:", data),
1174
+ onError: (error) => console.error("Failed:", error),
1175
+ }
1176
+ );
1169
1177
 
1170
- function createApiService(resolver: Resolver): ApiService {
1171
- const baseUrl = resolver.get(configFactory).apiUrl;
1178
+ // Actions
1179
+ userAsync.dispatch("123"); // Start async operation
1180
+ userAsync.cancel(); // Cancel current operation
1181
+ userAsync.refresh(); // Refetch with same args
1182
+ userAsync.reset(); // Reset to initial state
1183
+ ```
1172
1184
 
1173
- return {
1174
- async get(url) {
1175
- const res = await fetch(`${baseUrl}${url}`);
1176
- return res.json();
1177
- },
1178
- async post(url, data) {
1179
- const res = await fetch(`${baseUrl}${url}`, {
1180
- method: "POST",
1181
- body: JSON.stringify(data),
1182
- });
1183
- return res.json();
1184
- },
1185
- };
1186
- }
1185
+ **Options:**
1187
1186
 
1188
- function configFactory(): { apiUrl: string } {
1189
- return { apiUrl: process.env.API_URL ?? "http://localhost:3000" };
1190
- }
1187
+ | Option | Type | Description |
1188
+ | ------------- | ------------------------------- | ------------------------ |
1189
+ | `retry.count` | `number` | Number of retry attempts |
1190
+ | `retry.delay` | `number \| (attempt) => number` | Delay between retries |
1191
+ | `onSuccess` | `(data) => void` | Called on success |
1192
+ | `onError` | `(error) => void` | Called on error |
1191
1193
 
1192
- // Use in store
1193
- const userStore = store({
1194
- name: "user",
1195
- state: { user: null },
1196
- setup({ get }) {
1197
- const api = get(createApiService); // Singleton, cached
1194
+ **Async helpers:**
1198
1195
 
1199
- return {
1200
- fetchUser: async (id: string) => {
1201
- return api.get(`/users/${id}`);
1202
- },
1203
- };
1204
- },
1196
+ ```ts
1197
+ // Initial state creators
1198
+ async.fresh<T>(); // Fresh mode: data undefined during loading
1199
+ async.stale<T>(initial); // Stale mode: preserves data during loading
1200
+
1201
+ // State extractors (Suspense-compatible)
1202
+ async.wait(state); // Get data or throw
1203
+ async.all(...states); // Wait for all, return tuple
1204
+ async.any(...states); // Get first successful
1205
+ async.race(states); // Get fastest
1206
+
1207
+ // State checks (non-throwing)
1208
+ async.hasData(state); // boolean
1209
+ async.isLoading(state); // boolean
1210
+ async.isError(state); // boolean
1211
+
1212
+ // Derived state
1213
+ async.derive(focus, () => {
1214
+ const a = async.wait(state.a);
1215
+ const b = async.wait(state.b);
1216
+ return computeResult(a, b);
1205
1217
  });
1218
+ ```
1206
1219
 
1207
- // Testing - easy to mock without module mocking
1208
- const mockApi: ApiService = {
1209
- get: async () => ({ id: "1", name: "Test User" }),
1210
- post: async () => ({}),
1211
- };
1220
+ ### pick(fn, equality?)
1212
1221
 
1213
- const testApp = container();
1214
- testApp.set(createApiService, () => mockApi); // Override with mock
1222
+ Fine-grained value tracking.
1215
1223
 
1216
- // Now userStore will use mockApi instead of real API
1217
- const { actions } = testApp.get(userStore);
1218
- await actions.fetchUser("1"); // Uses mockApi.get()
1224
+ ```ts
1225
+ import { pick } from "storion";
1219
1226
 
1220
- // Lifecycle management
1221
- testApp.delete(createApiService); // Remove cached instance
1222
- testApp.clear(); // Clear all cached instances
1223
- testApp.dispose(); // Dispose container and all instances
1227
+ // In selector
1228
+ const name = pick(() => state.profile.name);
1229
+ const coords = pick(() => state.coords, "shallow");
1230
+ const config = pick(() => state.config, "deep");
1231
+ const custom = pick(
1232
+ () => state.ids,
1233
+ (a, b) => arraysEqual(a, b)
1234
+ );
1224
1235
  ```
1225
1236
 
1226
- ### Parameterized Factories with `create()`
1237
+ **Equality options:**
1238
+
1239
+ | Value | Description |
1240
+ | ------------------- | --------------------------------- |
1241
+ | (none) | Strict equality (`===`) |
1242
+ | `"shallow"` | Compare properties one level deep |
1243
+ | `"deep"` | Recursive comparison |
1244
+ | `(a, b) => boolean` | Custom comparison function |
1227
1245
 
1228
- **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.
1246
+ ### batch(fn)
1229
1247
 
1230
- **With Storion:** Use `create()` for parameterized factories. Unlike `get()`, `create()` always returns fresh instances and supports additional arguments.
1248
+ Batch multiple mutations into one notification.
1231
1249
 
1232
1250
  ```ts
1233
- import { store, container, type Resolver } from "storion";
1251
+ import { batch } from "storion";
1234
1252
 
1235
- // Parameterized factory - receives resolver + custom args
1236
- function createLogger(resolver: Resolver, namespace: string) {
1237
- return {
1238
- info: (msg: string) => console.log(`[${namespace}] INFO: ${msg}`),
1239
- error: (msg: string) => console.error(`[${namespace}] ERROR: ${msg}`),
1240
- };
1241
- }
1253
+ batch(() => {
1254
+ state.x = 1;
1255
+ state.y = 2;
1256
+ state.z = 3;
1257
+ });
1258
+ // Subscribers notified once, not three times
1259
+ ```
1242
1260
 
1243
- function createDatabase(
1244
- resolver: Resolver,
1245
- config: { host: string; port: number }
1246
- ) {
1247
- return {
1248
- query: (sql: string) =>
1249
- fetch(`http://${config.host}:${config.port}/query`, {
1250
- method: "POST",
1251
- body: sql,
1252
- }),
1253
- close: () => {
1254
- /* cleanup */
1255
- },
1256
- };
1257
- }
1261
+ ### untrack(fn)
1258
1262
 
1259
- // Use in store setup
1260
- const userStore = store({
1261
- name: "user",
1262
- state: { users: [] as User[] },
1263
- setup({ create }) {
1264
- // Each call creates a fresh instance with specific config
1265
- const logger = create(createLogger, "user-store");
1266
- const db = create(createDatabase, { host: "localhost", port: 5432 });
1263
+ Read state without tracking dependencies.
1267
1264
 
1268
- return {
1269
- fetchUsers: async () => {
1270
- logger.info("Fetching users...");
1271
- await db.query("SELECT * FROM users");
1272
- },
1273
- };
1274
- },
1275
- });
1265
+ ```ts
1266
+ import { untrack } from "storion";
1276
1267
 
1277
- // Also works with container directly
1278
- const app = container();
1279
- const authLogger = app.create(createLogger, "auth");
1280
- const adminDb = app.create(createDatabase, { host: "admin.db", port: 5433 });
1268
+ effect(() => {
1269
+ const count = state.count; // Tracked
1270
+
1271
+ const name = untrack(() => state.name); // Not tracked
1272
+
1273
+ console.log(count, name);
1274
+ });
1275
+ // Effect only re-runs when count changes, not when name changes
1281
1276
  ```
1282
1277
 
1283
- **Key differences between `get()` and `create()`:**
1278
+ ---
1284
1279
 
1285
- | Feature | `get()` | `create()` |
1286
- | ---------- | --------------------------- | --------------------------------------------- |
1287
- | Caching | Yes (singleton per factory) | No (always fresh) |
1288
- | Arguments | None (parameterless only) | Supports additional arguments |
1289
- | Use case | Shared services | Configured instances, child stores |
1290
- | Middleware | Applied | Applied (without args) / Bypassed (with args) |
1280
+ ## Advanced Patterns
1291
1281
 
1292
1282
  ### Middleware
1293
1283
 
1294
- **The problem:** You need cross-cutting behavior (logging, persistence, devtools) applied to some or all stores, without modifying each store individually.
1295
-
1296
- **With Storion:** Compose middleware and apply it conditionally using patterns like `"user*"` (startsWith), `"*Store"` (endsWith), or custom predicates.
1284
+ Middleware intercepts store creation for cross-cutting concerns.
1297
1285
 
1298
1286
  ```ts
1299
1287
  import { container, compose, applyFor, applyExcept } from "storion";
1300
1288
  import type { StoreMiddleware } from "storion";
1301
1289
 
1302
- // Logging middleware - ctx.spec is always available
1290
+ // Simple middleware
1303
1291
  const loggingMiddleware: StoreMiddleware = (ctx) => {
1304
- console.log(`Creating store: ${ctx.displayName}`);
1292
+ console.log(`Creating: ${ctx.displayName}`);
1305
1293
  const instance = ctx.next();
1306
1294
  console.log(`Created: ${instance.id}`);
1307
1295
  return instance;
1308
1296
  };
1309
1297
 
1310
- // Persistence middleware
1298
+ // Middleware with store-specific logic
1311
1299
  const persistMiddleware: StoreMiddleware = (ctx) => {
1312
1300
  const instance = ctx.next();
1313
- // Access store-specific options directly
1314
- const isPersistent = ctx.spec.options.meta?.persist === true;
1315
- if (isPersistent) {
1316
- // Add persistence logic...
1301
+
1302
+ if (ctx.spec.options.meta?.persist) {
1303
+ // Add persistence logic
1317
1304
  }
1305
+
1318
1306
  return instance;
1319
1307
  };
1320
1308
 
1309
+ // Apply conditionally
1321
1310
  const app = container({
1322
1311
  middleware: compose(
1323
- // Apply logging to all stores starting with "user"
1312
+ // Apply to stores starting with "user"
1324
1313
  applyFor("user*", loggingMiddleware),
1325
1314
 
1326
- // Apply persistence except for cache stores
1315
+ // Apply except to cache stores
1327
1316
  applyExcept("*Cache", persistMiddleware),
1328
1317
 
1329
1318
  // Apply to specific stores
1330
1319
  applyFor(["authStore", "settingsStore"], loggingMiddleware),
1331
1320
 
1332
- // Apply based on custom condition
1333
- applyFor(
1334
- (ctx) => ctx.spec.options.meta?.persist === true,
1335
- persistMiddleware
1336
- )
1321
+ // Apply based on condition
1322
+ applyFor((ctx) => ctx.spec.options.meta?.debug === true, loggingMiddleware)
1337
1323
  ),
1338
1324
  });
1339
1325
  ```
1340
1326
 
1341
- ---
1327
+ **Pattern matching:**
1342
1328
 
1343
- ## API Reference
1329
+ | Pattern | Matches |
1330
+ | ------------------ | ---------------------- |
1331
+ | `"user*"` | Starts with "user" |
1332
+ | `"*Store"` | Ends with "Store" |
1333
+ | `["a", "b"]` | Exact match "a" or "b" |
1334
+ | `(ctx) => boolean` | Custom predicate |
1344
1335
 
1345
- ### Core (`storion`)
1336
+ ### Parameterized Services
1346
1337
 
1347
- | Export | Description |
1348
- | ---------------------- | ---------------------------------------------- |
1349
- | `store(options)` | Create a store specification |
1350
- | `container(options?)` | Create a container for store instances and DI |
1351
- | `effect(fn, options?)` | Create reactive side effects with cleanup |
1352
- | `pick(fn, equality?)` | Fine-grained derived value tracking |
1353
- | `batch(fn)` | Batch multiple mutations into one notification |
1354
- | `untrack(fn)` | Read state without tracking dependencies |
1355
-
1356
- #### Store Options
1338
+ For services that need configuration:
1357
1339
 
1358
1340
  ```ts
1359
- interface StoreOptions<TState, TActions> {
1360
- name?: string; // Store display name for debugging (becomes spec.displayName)
1361
- state: TState; // Initial state
1362
- setup: (ctx: StoreContext) => TActions; // Setup function
1363
- lifetime?: "singleton" | "autoDispose"; // Instance lifetime
1364
- equality?: Equality | EqualityMap; // Custom equality for state
1365
- onDispatch?: (event: DispatchEvent) => void; // Action dispatch callback
1366
- onError?: (error: unknown) => void; // Error callback
1341
+ // Parameterized service factory
1342
+ function dbService(resolver, config: { host: string; port: number }) {
1343
+ return {
1344
+ query: (sql: string) =>
1345
+ fetch(`http://${config.host}:${config.port}/query`, {
1346
+ method: "POST",
1347
+ body: sql,
1348
+ }),
1349
+ };
1367
1350
  }
1351
+
1352
+ // Use with create() instead of get()
1353
+ const myStore = store({
1354
+ name: "data",
1355
+ state: { items: [] },
1356
+ setup({ create }) {
1357
+ // create() always makes a fresh instance and accepts args
1358
+ const db = create(dbService, { host: "localhost", port: 5432 });
1359
+
1360
+ return {
1361
+ fetchItems: async () => {
1362
+ return db.query("SELECT * FROM items");
1363
+ },
1364
+ };
1365
+ },
1366
+ });
1368
1367
  ```
1369
1368
 
1370
- **Per-property equality** — Configure different equality checks for each state property:
1369
+ **get() vs create():**
1370
+
1371
+ | Aspect | `get()` | `create()` |
1372
+ | --------- | --------------- | -------------------- |
1373
+ | Caching | Yes (singleton) | No (always fresh) |
1374
+ | Arguments | None | Supports extra args |
1375
+ | Use case | Shared services | Configured instances |
1376
+
1377
+ ### Store-Level Equality
1378
+
1379
+ Configure how state changes are detected:
1371
1380
 
1372
1381
  ```ts
1373
- const myStore = store({
1374
- name: "settings",
1382
+ const mapStore = store({
1383
+ name: "map",
1375
1384
  state: {
1376
- theme: "light",
1377
1385
  coords: { x: 0, y: 0 },
1378
- items: [] as string[],
1379
- config: { nested: { deep: true } },
1386
+ markers: [] as Marker[],
1387
+ settings: { zoom: 1, rotation: 0 },
1380
1388
  },
1381
- // Per-property equality configuration
1382
1389
  equality: {
1383
- theme: "strict", // Default (===)
1384
- coords: "shallow", // Compare { x, y } properties
1385
- items: "shallow", // Compare array elements
1386
- config: "deep", // Deep recursive comparison
1390
+ // Shallow: only notify if x or y actually changed
1391
+ coords: "shallow",
1392
+ // Deep: recursive comparison for complex objects
1393
+ settings: "deep",
1394
+ // Custom function
1395
+ markers: (a, b) => a.length === b.length,
1387
1396
  },
1388
1397
  setup({ state }) {
1389
1398
  return {
1390
1399
  setCoords: (x: number, y: number) => {
1391
- // Only triggers subscribers if x or y actually changed (shallow compare)
1400
+ // This creates a new object, but shallow equality
1401
+ // prevents notification if x and y are the same
1392
1402
  state.coords = { x, y };
1393
1403
  },
1394
1404
  };
@@ -1396,179 +1406,75 @@ const myStore = store({
1396
1406
  });
1397
1407
  ```
1398
1408
 
1399
- | Equality | Description |
1400
- | ------------------- | ----------------------------------------------- |
1401
- | `"strict"` | Default `===` comparison |
1402
- | `"shallow"` | Compares object/array properties one level deep |
1403
- | `"deep"` | Recursively compares nested structures |
1404
- | `(a, b) => boolean` | Custom comparison function |
1405
-
1406
- #### StoreContext (in setup)
1409
+ ### Testing with Mocks
1407
1410
 
1408
1411
  ```ts
1409
- interface StoreContext<TState, TActions> {
1410
- state: TState; // First-level props only (state.x = y)
1411
- get<T>(spec: StoreSpec<T>): StoreTuple; // Get dependency store (cached)
1412
- get<T>(factory: Factory<T>): T; // Get DI service (cached)
1413
- create<T>(spec: StoreSpec<T>): StoreInstance<T>; // Create child store (fresh)
1414
- create<T>(factory: Factory<T>): T; // Create service (fresh)
1415
- create<R, A>(factory: (r, ...a: A) => R, ...a: A): R; // Parameterized factory
1416
- focus<P extends Path>(path: P): Focus; // Lens-like accessor
1417
- update(fn: (draft: TState) => void): void; // For nested/array mutations
1418
- dirty(prop?: keyof TState): boolean; // Check if state changed
1419
- reset(): void; // Reset to initial state
1420
- onDispose(fn: VoidFunction): void; // Register cleanup
1421
- }
1422
- ```
1423
-
1424
- > **Note:** `state` allows direct assignment only for first-level properties. Use `update()` for nested objects, arrays, or batch updates.
1412
+ import { container } from "storion";
1425
1413
 
1426
- **`get()` vs `create()` — When to use each:**
1427
-
1428
- | Method | Caching | Use case |
1429
- | ---------- | -------- | ------------------------------------------------------ |
1430
- | `get()` | Cached | Shared dependencies, singleton services |
1431
- | `create()` | No cache | Child stores, parameterized factories, fresh instances |
1432
-
1433
- ```ts
1434
- setup({ get, create }) {
1435
- // get() - cached, same instance every time
1436
- const api = get(apiService); // Singleton
1414
+ // Production code
1415
+ const app = container();
1437
1416
 
1438
- // create() - fresh instance each call
1439
- const childStore = create(childSpec); // New store instance
1417
+ // Test setup
1418
+ const testApp = container();
1440
1419
 
1441
- // create() with arguments - parameterized factory
1442
- const db = create(createDatabase, { host: 'localhost', port: 5432 });
1443
- const logger = create(createLogger, 'auth-store');
1420
+ // Override services with mocks
1421
+ testApp.set(apiService, () => ({
1422
+ get: async () => ({ id: "1", name: "Test User" }),
1423
+ post: async () => ({}),
1424
+ }));
1444
1425
 
1445
- return { /* ... */ };
1446
- }
1426
+ // Now stores will use the mock
1427
+ const { actions } = testApp.get(userStore);
1428
+ await actions.fetchUser("1"); // Uses mock apiService
1447
1429
  ```
1448
1430
 
1449
- ### React (`storion/react`)
1450
-
1451
- | Export | Description |
1452
- | -------------------------- | ----------------------------------------- |
1453
- | `StoreProvider` | Provides container to React tree |
1454
- | `useStore(selector)` | Hook to consume stores with selector |
1455
- | `useStore(spec)` | Hook for component-local store |
1456
- | `useContainer()` | Access container from context |
1457
- | `create(options)` | Create store + hook for single-store apps |
1458
- | `withStore(hook, render?)` | HOC pattern for store consumption |
1431
+ ### Child Containers
1459
1432
 
1460
- #### useStore Selector
1433
+ For scoped dependencies (e.g., per-request in SSR):
1461
1434
 
1462
1435
  ```ts
1463
- // Selector receives context with get(), create(), mixin(), once()
1464
- const result = useStore(({ get, create, mixin, once }) => {
1465
- const [state, actions] = get(myStore);
1466
- const service = get(myFactory); // Cached
1436
+ const rootApp = container();
1467
1437
 
1468
- // create() for parameterized factories (fresh instance each render)
1469
- const logger = create(createLogger, "my-component");
1470
-
1471
- // Run once on mount
1472
- once(() => actions.init());
1473
-
1474
- return { value: state.value, action: actions.doSomething };
1438
+ // Create child container with overrides
1439
+ const requestApp = container({
1440
+ parent: rootApp,
1475
1441
  });
1476
- ```
1477
1442
 
1478
- ### Async (`storion/async`)
1443
+ // Child inherits from parent but can have its own instances
1444
+ requestApp.set(sessionService, () => createSessionForRequest());
1479
1445
 
1480
- | Export | Description |
1481
- | --------------------------------- | ------------------------------------------- |
1482
- | `async(focus, handler, options?)` | Create async action |
1483
- | `async.fresh<T>()` | Create fresh mode initial state |
1484
- | `async.stale<T>(initial)` | Create stale mode initial state |
1485
- | `async.wait(state)` | Extract data or throw (Suspense-compatible) |
1486
- | `async.all(...states)` | Wait for all states to be ready |
1487
- | `async.any(...states)` | Get first ready state |
1488
- | `async.race(states)` | Race between states |
1489
- | `async.derive(focus, computeFn)` | Derive async state from other async states |
1490
- | `async.hasData(state)` | Check if state has data |
1491
- | `async.isLoading(state)` | Check if state is loading |
1492
- | `async.isError(state)` | Check if state has error |
1493
-
1494
- #### AsyncState Types
1495
-
1496
- ```ts
1497
- interface AsyncState<T, M extends "fresh" | "stale"> {
1498
- status: "idle" | "pending" | "success" | "error";
1499
- mode: M;
1500
- data: M extends "stale" ? T : T | undefined;
1501
- error: Error | undefined;
1502
- timestamp: number | undefined;
1503
- }
1504
- ```
1505
-
1506
- ### Middleware
1507
-
1508
- | Export | Description |
1509
- | ------------- | -------------------------------------------------- |
1510
- | `compose` | Compose multiple StoreMiddleware into one |
1511
- | `applyFor` | Apply middleware conditionally (pattern/predicate) |
1512
- | `applyExcept` | Apply middleware except for matching patterns |
1513
-
1514
- #### Middleware Context (Discriminated Union)
1515
-
1516
- Middleware context uses a discriminated union with `type` field:
1517
-
1518
- ```ts
1519
- // For stores (container middleware)
1520
- interface StoreMiddlewareContext {
1521
- type: "store"; // Discriminant
1522
- spec: StoreSpec; // Always present for stores
1523
- factory: Factory;
1524
- resolver: Resolver;
1525
- next: () => unknown;
1526
- displayName: string; // Always present for stores
1527
- }
1528
-
1529
- // For plain factories (resolver middleware)
1530
- interface FactoryMiddlewareContext {
1531
- type: "factory"; // Discriminant
1532
- factory: Factory;
1533
- resolver: Resolver;
1534
- next: () => unknown;
1535
- displayName: string | undefined;
1536
- }
1537
-
1538
- type MiddlewareContext = FactoryMiddlewareContext | StoreMiddlewareContext;
1446
+ // Cleanup after request
1447
+ requestApp.dispose();
1539
1448
  ```
1540
1449
 
1541
- **Store-specific middleware** (for containers):
1450
+ ### Store Lifecycle
1542
1451
 
1543
1452
  ```ts
1544
- // No generics needed - simple and clean
1545
- type StoreMiddleware = (ctx: StoreMiddlewareContext) => StoreInstance;
1453
+ const myStore = store({
1454
+ name: "myStore",
1455
+ lifetime: "autoDispose", // Dispose when no subscribers
1456
+ state: { ... },
1457
+ setup({ onDispose }) {
1458
+ const interval = setInterval(() => {}, 1000);
1459
+
1460
+ // Cleanup when store is disposed
1461
+ onDispose(() => {
1462
+ clearInterval(interval);
1463
+ });
1546
1464
 
1547
- const loggingMiddleware: StoreMiddleware = (ctx) => {
1548
- console.log(`Creating: ${ctx.displayName}`);
1549
- const instance = ctx.next();
1550
- console.log(`Created: ${instance.id}`);
1551
- return instance as StoreInstance;
1552
- };
1465
+ return { ... };
1466
+ },
1467
+ });
1553
1468
  ```
1554
1469
 
1555
- **Generic middleware** (for resolver, works with both stores and factories):
1470
+ **Lifetime options:**
1556
1471
 
1557
- ```ts
1558
- type Middleware = (ctx: MiddlewareContext) => unknown;
1559
-
1560
- const loggingMiddleware: Middleware = (ctx) => {
1561
- // Use type narrowing
1562
- if (ctx.type === "store") {
1563
- console.log(`Store: ${ctx.spec.displayName}`);
1564
- } else {
1565
- console.log(`Factory: ${ctx.displayName ?? "anonymous"}`);
1566
- }
1567
- return ctx.next();
1568
- };
1569
- ```
1472
+ | Value | Behavior |
1473
+ | --------------- | ------------------------------------------- |
1474
+ | `"singleton"` | Lives until container is disposed (default) |
1475
+ | `"autoDispose"` | Disposed when last subscriber unsubscribes |
1570
1476
 
1571
- ### Devtools (`storion/devtools`)
1477
+ ### DevTools Integration
1572
1478
 
1573
1479
  ```ts
1574
1480
  import { devtools } from "storion/devtools";
@@ -1576,18 +1482,14 @@ import { devtools } from "storion/devtools";
1576
1482
  const app = container({
1577
1483
  middleware: devtools({
1578
1484
  name: "My App",
1579
- // Enable in development only
1580
1485
  enabled: process.env.NODE_ENV === "development",
1581
1486
  }),
1582
1487
  });
1583
1488
  ```
1584
1489
 
1585
- ### Devtools Panel (`storion/devtools-panel`)
1586
-
1587
1490
  ```tsx
1588
1491
  import { DevtoolsPanel } from "storion/devtools-panel";
1589
1492
 
1590
- // Mount anywhere in your app (dev only)
1591
1493
  function App() {
1592
1494
  return (
1593
1495
  <>
@@ -1600,52 +1502,104 @@ function App() {
1600
1502
 
1601
1503
  ---
1602
1504
 
1603
- ## Edge Cases & Best Practices
1505
+ ## Error Handling
1604
1506
 
1605
- ### Don't directly mutate nested state or arrays
1507
+ ### Effect Errors
1606
1508
 
1607
- Direct mutation only works for first-level properties. Use `update()` for nested objects and arrays:
1509
+ Errors in effects are caught and can be handled:
1608
1510
 
1609
1511
  ```ts
1610
- // Wrong - nested mutation won't trigger reactivity
1611
- setup({ state }) {
1612
- return {
1613
- setName: (name: string) => {
1614
- state.profile.name = name; // Won't work!
1512
+ const myStore = store({
1513
+ name: "myStore",
1514
+ state: { ... },
1515
+ onError: (error) => {
1516
+ console.error("Store error:", error);
1517
+ // Send to error tracking service
1518
+ },
1519
+ setup({ state }) {
1520
+ effect(() => {
1521
+ if (state.invalid) {
1522
+ throw new Error("Invalid state!");
1523
+ }
1524
+ });
1525
+
1526
+ return { ... };
1527
+ },
1528
+ });
1529
+ ```
1530
+
1531
+ ### Async Errors
1532
+
1533
+ ```ts
1534
+ const userAsync = async(
1535
+ focus("user"),
1536
+ async (ctx) => {
1537
+ const res = await fetch("/api/user", { signal: ctx.signal });
1538
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
1539
+ return res.json();
1540
+ },
1541
+ {
1542
+ onError: (error) => {
1543
+ // Handle or log the error
1615
1544
  },
1616
- addItem: (item: string) => {
1617
- state.items.push(item); // Won't work!
1545
+ retry: {
1546
+ count: 3,
1547
+ delay: (attempt) => Math.min(1000 * 2 ** attempt, 10000),
1618
1548
  },
1619
- };
1549
+ }
1550
+ );
1551
+ ```
1552
+
1553
+ ### React Error Boundaries
1554
+
1555
+ ```tsx
1556
+ function App() {
1557
+ return (
1558
+ <ErrorBoundary fallback={<ErrorPage />}>
1559
+ <Suspense fallback={<Spinner />}>
1560
+ <UserProfile />
1561
+ </Suspense>
1562
+ </ErrorBoundary>
1563
+ );
1620
1564
  }
1621
1565
 
1622
- // ✅ Correct - use update() for nested/array mutations
1623
- setup({ state, update }) {
1624
- return {
1625
- setName: (name: string) => {
1626
- update((draft) => {
1627
- draft.profile.name = name;
1628
- });
1629
- },
1630
- addItem: (item: string) => {
1631
- update((draft) => {
1632
- draft.items.push(item);
1633
- });
1634
- },
1635
- // First-level props can be assigned directly
1636
- setCount: (n: number) => {
1637
- state.count = n; // This works!
1638
- },
1639
- };
1566
+ function UserProfile() {
1567
+ const { user } = useStore(({ get }) => {
1568
+ const [state] = get(userStore);
1569
+ // async.wait() throws on error, caught by ErrorBoundary
1570
+ return { user: async.wait(state.currentUser) };
1571
+ });
1572
+
1573
+ return <div>{user.name}</div>;
1640
1574
  }
1641
1575
  ```
1642
1576
 
1643
- ### ❌ Don't call `get()` inside actions
1577
+ ---
1578
+
1579
+ ## Limitations & Anti-patterns
1644
1580
 
1645
- `get()` is for declaring dependencies during setup, not runtime:
1581
+ ### Don't Mutate Nested State Directly
1582
+
1583
+ Direct mutation only works for first-level properties:
1646
1584
 
1647
1585
  ```ts
1648
- // ❌ Wrong - calling get() inside action
1586
+ // ❌ Wrong - won't trigger reactivity
1587
+ state.profile.name = "John";
1588
+ state.items.push("new item");
1589
+
1590
+ // ✅ Correct - use update()
1591
+ update((draft) => {
1592
+ draft.profile.name = "John";
1593
+ draft.items.push("new item");
1594
+ });
1595
+ ```
1596
+
1597
+ ### ❌ Don't Call get() Inside Actions
1598
+
1599
+ `get()` is for setup-time dependencies, not runtime:
1600
+
1601
+ ```ts
1602
+ // ❌ Wrong
1649
1603
  setup({ get }) {
1650
1604
  return {
1651
1605
  doSomething: () => {
@@ -1654,31 +1608,30 @@ setup({ get }) {
1654
1608
  };
1655
1609
  }
1656
1610
 
1657
- // ✅ Correct - declare dependency at setup time
1611
+ // ✅ Correct - capture at setup time
1658
1612
  setup({ get }) {
1659
1613
  const [otherState, otherActions] = get(otherStore);
1660
1614
 
1661
1615
  return {
1662
1616
  doSomething: () => {
1663
- if (otherState.ready) {
1664
- // Use the reactive state captured during setup
1665
- }
1617
+ // Use the captured state/actions
1618
+ if (otherState.ready) { ... }
1666
1619
  },
1667
1620
  };
1668
1621
  }
1669
1622
  ```
1670
1623
 
1671
- ### ❌ Don't return Promises from effects
1624
+ ### ❌ Don't Use Async Effects
1672
1625
 
1673
- Effects must be synchronous. Use `ctx.safe()` for async:
1626
+ Effects must be synchronous:
1674
1627
 
1675
1628
  ```ts
1676
- // ❌ Wrong - async effect
1629
+ // ❌ Wrong
1677
1630
  effect(async (ctx) => {
1678
- const data = await fetchData(); // Don't do this!
1631
+ const data = await fetchData();
1679
1632
  });
1680
1633
 
1681
- // ✅ Correct - use ctx.safe()
1634
+ // ✅ Correct
1682
1635
  effect((ctx) => {
1683
1636
  ctx.safe(fetchData()).then((data) => {
1684
1637
  state.data = data;
@@ -1686,80 +1639,121 @@ effect((ctx) => {
1686
1639
  });
1687
1640
  ```
1688
1641
 
1689
- ### Use `pick()` for computed values from nested state
1642
+ ### Don't Pass Anonymous Functions to trigger()
1690
1643
 
1691
- When reading nested state in selectors, use `pick()` for fine-grained reactivity:
1644
+ Anonymous functions create new references on every render:
1692
1645
 
1693
1646
  ```ts
1694
- // Re-renders when profile object changes (coarse tracking)
1695
- const name = state.profile.name;
1647
+ // ❌ Wrong - anonymous function called every render
1648
+ trigger(() => {
1649
+ actions.search(query);
1650
+ }, [query]);
1696
1651
 
1697
- // Re-renders only when the actual name value changes (fine tracking)
1698
- const name = pick(() => state.profile.name);
1699
- const fullName = pick(() => `${state.profile.first} ${state.profile.last}`);
1652
+ // Correct - stable function reference
1653
+ trigger(actions.search, [query], query);
1700
1654
  ```
1701
1655
 
1702
- ### Use stale mode for SWR patterns
1656
+ ### Don't Call refresh() Synchronously
1657
+
1658
+ Calling `ctx.refresh()` during effect execution throws an error:
1659
+
1660
+ ```ts
1661
+ // ❌ Wrong - throws error
1662
+ effect((ctx) => {
1663
+ ctx.refresh(); // Error!
1664
+ });
1665
+
1666
+ // ✅ Correct - async or return pattern
1667
+ effect((ctx) => {
1668
+ setTimeout(() => ctx.refresh(), 1000);
1669
+ // or
1670
+ return ctx.refresh;
1671
+ });
1672
+ ```
1673
+
1674
+ ### ❌ Don't Create Stores Inside Components
1675
+
1676
+ Store specs should be defined at module level:
1703
1677
 
1704
1678
  ```ts
1705
- // Fresh mode: data is undefined during loading
1706
- state: {
1707
- data: async.fresh<Data>(),
1679
+ // Wrong - creates new spec on every render
1680
+ function Component() {
1681
+ const myStore = store({ ... }); // Don't do this!
1708
1682
  }
1709
1683
 
1710
- // Stale mode: preserves previous data during loading (SWR pattern)
1711
- state: {
1712
- data: async.stale<Data>(initialData),
1684
+ // Correct - define at module level
1685
+ const myStore = store({ ... });
1686
+
1687
+ function Component() {
1688
+ const { state } = useStore(({ get }) => get(myStore));
1713
1689
  }
1714
1690
  ```
1715
1691
 
1716
- ---
1692
+ ### ❌ Don't Forget to Handle All Async States
1717
1693
 
1718
- ## TypeScript
1694
+ ```tsx
1695
+ // ❌ Incomplete - misses error and idle states
1696
+ function User() {
1697
+ const { user } = useStore(({ get }) => {
1698
+ const [state] = get(userStore);
1699
+ return { user: state.currentUser };
1700
+ });
1719
1701
 
1720
- Storion is written in TypeScript and provides excellent type inference:
1702
+ if (user.status === "pending") return <Spinner />;
1703
+ return <div>{user.data.name}</div>; // Crashes if error or idle!
1704
+ }
1721
1705
 
1722
- ```ts
1723
- // State and action types are inferred
1724
- const myStore = store({
1725
- name: "my-store",
1726
- state: { count: 0, name: "" },
1727
- setup({ state }) {
1728
- return {
1729
- inc: () => state.count++, // () => void
1730
- setName: (n: string) => (state.name = n), // (n: string) => string
1731
- };
1732
- },
1733
- });
1706
+ // ✅ Complete handling
1707
+ function User() {
1708
+ const { user } = useStore(...);
1734
1709
 
1735
- // Using with explicit types when needed (unions, nullable)
1736
- interface MyState {
1737
- userId: string | null;
1738
- status: "idle" | "loading" | "ready";
1710
+ if (user.status === "idle") return <button>Load User</button>;
1711
+ if (user.status === "pending") return <Spinner />;
1712
+ if (user.status === "error") return <Error error={user.error} />;
1713
+ return <div>{user.data.name}</div>;
1739
1714
  }
1715
+ ```
1740
1716
 
1741
- const typedStore = store({
1742
- name: "typed",
1743
- state: {
1744
- userId: null as string | null,
1745
- status: "idle" as "idle" | "loading" | "ready",
1746
- } satisfies MyState,
1717
+ ### Limitation: No Deep Property Tracking
1718
+
1719
+ Storion tracks first-level property access, not deep paths:
1720
+
1721
+ ```ts
1722
+ // Both track "profile" property, not "profile.name"
1723
+ const name1 = state.profile.name;
1724
+ const name2 = state.profile.email;
1725
+
1726
+ // To get finer tracking, use pick()
1727
+ const name = pick(() => state.profile.name);
1728
+ ```
1729
+
1730
+ ### Limitation: Equality Check Timing
1731
+
1732
+ Store-level equality runs on write, component-level equality runs on read:
1733
+
1734
+ ```ts
1735
+ // Store level - prevents notification
1736
+ store({
1737
+ equality: { coords: "shallow" },
1747
1738
  setup({ state }) {
1748
1739
  return {
1749
- setUser: (id: string | null) => {
1750
- state.userId = id;
1740
+ setCoords: (x, y) => {
1741
+ // If same x,y, no subscribers are notified
1742
+ state.coords = { x, y };
1751
1743
  },
1752
1744
  };
1753
1745
  },
1754
1746
  });
1747
+
1748
+ // Component level - prevents re-render
1749
+ const x = pick(() => state.coords.x);
1750
+ // Component only re-renders if x specifically changed
1755
1751
  ```
1756
1752
 
1757
1753
  ---
1758
1754
 
1759
1755
  ## Contributing
1760
1756
 
1761
- We welcome contributions! Here's how to get started:
1762
-
1763
1757
  ### Prerequisites
1764
1758
 
1765
1759
  - Node.js 18+
@@ -1768,40 +1762,20 @@ We welcome contributions! Here's how to get started:
1768
1762
  ### Setup
1769
1763
 
1770
1764
  ```bash
1771
- # Clone the repo
1772
1765
  git clone https://github.com/linq2js/storion.git
1773
1766
  cd storion
1774
-
1775
- # Install dependencies
1776
1767
  pnpm install
1777
-
1778
- # Build the library
1779
1768
  pnpm --filter storion build
1780
1769
  ```
1781
1770
 
1782
1771
  ### Development
1783
1772
 
1784
1773
  ```bash
1785
- # Watch mode
1786
- pnpm --filter storion dev
1787
-
1788
- # Run tests
1789
- pnpm --filter storion test
1790
-
1791
- # Run tests with UI
1792
- pnpm --filter storion test:ui
1793
-
1794
- # Type check
1795
- pnpm --filter storion build:check
1774
+ pnpm --filter storion dev # Watch mode
1775
+ pnpm --filter storion test # Run tests
1776
+ pnpm --filter storion test:ui # Tests with UI
1796
1777
  ```
1797
1778
 
1798
- ### Code Style
1799
-
1800
- - Prefer **type inference** over explicit interfaces (add types only for unions, nullable, discriminated unions)
1801
- - Keep examples **copy/paste runnable**
1802
- - Write tests for new features
1803
- - Follow existing patterns in the codebase
1804
-
1805
1779
  ### Commit Messages
1806
1780
 
1807
1781
  Use [Conventional Commits](https://www.conventionalcommits.org/):
@@ -1810,17 +1784,8 @@ Use [Conventional Commits](https://www.conventionalcommits.org/):
1810
1784
  feat(core): add new feature
1811
1785
  fix(react): resolve hook issue
1812
1786
  docs: update README
1813
- chore: bump dependencies
1814
1787
  ```
1815
1788
 
1816
- ### Pull Requests
1817
-
1818
- 1. Fork the repo and create your branch from `main`
1819
- 2. Add tests for new functionality
1820
- 3. Ensure all tests pass
1821
- 4. Update documentation as needed
1822
- 5. Submit a PR with a clear description
1823
-
1824
1789
  ---
1825
1790
 
1826
1791
  ## License