storion 0.7.4 → 0.7.5

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.
Files changed (2) hide show
  1. package/README.md +1006 -1128
  2. package/package.json +1 -1
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,675 @@ 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
756
+ ### Async State with Suspense
699
757
 
700
- Storion provides multiple patterns for data fetching. Choose based on your use case:
758
+ ```tsx
759
+ import { async } from "storion/async";
760
+ import { Suspense } from "react";
701
761
 
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 |
762
+ function UserProfile() {
763
+ const { user } = useStore(({ get, trigger }) => {
764
+ const [state, actions] = get(userStore);
709
765
 
710
- ```tsx
711
- import { store } from "storion";
712
- import { async, type AsyncState } from "storion/async";
713
- import { useStore } from "storion/react";
714
- import { useEffect } from "react";
715
-
716
- interface User {
717
- id: string;
718
- name: string;
719
- }
720
-
721
- export const userStore = store({
722
- name: "users",
723
- state: {
724
- currentUser: async.fresh<User>(),
725
- searchResults: async.stale<User[]>([]),
726
- },
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();
736
- });
737
-
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
- );
766
+ // Trigger fetch on mount
767
+ trigger(actions.fetchUser, [], "123");
755
768
 
756
769
  return {
757
- currentUser: currentUserAsync,
758
- search: searchAsync.dispatch,
759
- cancelSearch: searchAsync.cancel,
770
+ // async.wait() throws if pending (triggers Suspense)
771
+ user: async.wait(state.currentUser),
760
772
  };
761
- },
762
- });
763
-
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, []);
776
-
777
- return { user: state.currentUser };
778
773
  });
779
774
 
780
- if (user.status === "pending") return <Spinner />;
781
- return <div>{user.data?.name}</div>;
775
+ // Only renders when data is ready
776
+ return <div>{user.name}</div>;
782
777
  }
783
778
 
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);
788
-
789
- // `id` changes each time component mounts = refetch on every visit
790
- trigger(actions.currentUser.dispatch, [id]);
791
-
792
- return { user: state.currentUser };
793
- });
794
-
795
- return <div>Welcome back, {user.data?.name}</div>;
779
+ function App() {
780
+ return (
781
+ <Suspense fallback={<Spinner />}>
782
+ <UserProfile />
783
+ </Suspense>
784
+ );
796
785
  }
786
+ ```
797
787
 
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);
802
-
803
- // Refetch when userId prop changes
804
- trigger(actions.currentUser.dispatch, [userId]);
788
+ **What Storion does:**
805
789
 
806
- return { user: state.currentUser };
807
- });
790
+ 1. `async.wait()` checks the async state's status
791
+ 2. If `"pending"`, throws a promise that React Suspense catches
792
+ 3. If `"error"`, throws the error for ErrorBoundary to catch
793
+ 4. If `"success"`, returns the data
794
+ 5. When the data arrives, Suspense re-renders the component
808
795
 
809
- return <div>{user.data?.name}</div>;
810
- }
796
+ ### Derived Async State
811
797
 
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
- });
821
-
822
- useEffect(() => {
823
- search("initial");
824
- }, []);
798
+ ```ts
799
+ const dashboardStore = store({
800
+ name: "dashboard",
801
+ state: {
802
+ user: async.fresh<User>(),
803
+ posts: async.fresh<Post[]>(),
804
+ summary: async.fresh<{ name: string; postCount: number }>(),
805
+ },
806
+ setup({ state, focus }) {
807
+ // ... async actions for user and posts ...
825
808
 
826
- return <div>...</div>;
827
- }
809
+ // Derive summary from user + posts
810
+ async.derive(focus("summary"), () => {
811
+ const user = async.wait(state.user);
812
+ const posts = async.wait(state.posts);
813
+ return { name: user.name, postCount: posts.length };
814
+ });
828
815
 
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
816
  return {
837
- results: state.searchResults,
838
- search: actions.search,
839
- cancel: actions.cancelSearch,
817
+ /* actions */
840
818
  };
841
- });
842
-
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
- );
860
- }
819
+ },
820
+ });
861
821
  ```
862
822
 
863
- **Summary: Choosing the right pattern**
823
+ **Use case:** Computing a value from multiple async sources.
864
824
 
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
- ```
825
+ **What Storion does:**
826
+
827
+ 1. Runs the derive function and tracks dependencies
828
+ 2. If any `async.wait()` throws (pending/error), the derived state mirrors that status
829
+ 3. If all sources are ready, computes and stores the result
830
+ 4. Re-runs automatically when source states change
884
831
 
885
- ### Suspense Pattern with `async.wait()`
832
+ ---
886
833
 
887
- **The problem:** You want to use React Suspense for loading states, but managing the "throw promise" pattern manually is complex and error-prone.
834
+ ## Using Stores in React
888
835
 
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.
836
+ ### useStore Hook
890
837
 
891
838
  ```tsx
892
- import { Suspense } from "react";
893
- import { async } from "storion/async";
894
839
  import { useStore } from "storion/react";
895
840
 
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);
841
+ function Component() {
842
+ const { count, inc, user } = useStore(({ get, trigger, id }) => {
843
+ const [counterState, counterActions] = get(counterStore);
844
+ const [userState, userActions] = get(userStore);
900
845
 
901
- // Trigger fetch on mount
902
- trigger(actions.fetchUser, []);
846
+ // Trigger on mount (empty deps = once)
847
+ trigger(userActions.fetchProfile, []);
848
+
849
+ // Trigger when id changes (every mount)
850
+ trigger(userActions.refresh, [id]);
903
851
 
904
852
  return {
905
- // async.wait() throws if pending/error, returns data if success
906
- user: async.wait(state.currentUser),
853
+ count: counterState.count,
854
+ inc: counterActions.inc,
855
+ user: userState.profile,
907
856
  };
908
857
  });
909
858
 
910
- // This only renders when data is ready
911
- return (
912
- <div>
913
- <h1>{user.name}</h1>
914
- <p>{user.email}</p>
915
- </div>
916
- );
917
- }
918
-
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
- );
859
+ return <div>...</div>;
928
860
  }
929
861
  ```
930
862
 
931
- **Multiple async states with `async.all()`:**
863
+ **Selector context provides:**
864
+
865
+ | Property | Description |
866
+ | ---------------------------- | ---------------------------------------------- |
867
+ | `get(store)` | Get store instance, returns `[state, actions]` |
868
+ | `get(service)` | Get service instance (cached) |
869
+ | `create(service, ...args)` | Create fresh service instance with args |
870
+ | `trigger(fn, deps, ...args)` | Call function when deps change |
871
+ | `id` | Unique ID per component mount |
872
+ | `once(fn)` | Run function once on mount |
873
+
874
+ ### Trigger Patterns
932
875
 
933
876
  ```tsx
934
877
  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
- );
878
+ const { data } = useStore(({ get, trigger, id }) => {
879
+ const [state, actions] = get(dataStore);
950
880
 
951
- return { user, posts, comments };
952
- });
881
+ // Pattern 1: Fetch once ever (empty deps)
882
+ trigger(actions.fetchOnce, []);
953
883
 
954
- return (
955
- <div>
956
- <h1>Welcome, {user.name}</h1>
957
- <PostList posts={posts} />
958
- <CommentList comments={comments} />
959
- </div>
960
- );
884
+ // Pattern 2: Fetch every mount (id changes each mount)
885
+ trigger(actions.fetchEveryVisit, [id]);
886
+
887
+ // Pattern 3: Fetch when prop changes
888
+ trigger(actions.fetchByCategory, [categoryId], categoryId);
889
+
890
+ return { data: state.data };
891
+ });
961
892
  }
962
893
  ```
963
894
 
964
- **Race pattern with `async.race()`:**
895
+ **What Storion does:**
965
896
 
966
- ```tsx
967
- function FastestResult() {
968
- const { result } = useStore(({ get, trigger }) => {
969
- const [state, actions] = get(searchStore);
897
+ 1. `trigger()` compares current deps with previous deps
898
+ 2. If deps changed (or first render), calls the function with provided args
899
+ 3. Empty deps `[]` means "call once and never again"
900
+ 4. `[id]` means "call every time component mounts" (id is unique per mount)
901
+
902
+ ### Fine-Grained Updates with pick()
970
903
 
971
- trigger(actions.searchAPI1, [], query);
972
- trigger(actions.searchAPI2, [], query);
904
+ ```tsx
905
+ import { pick } from "storion";
973
906
 
974
- // Returns whichever finishes first
907
+ function UserName() {
908
+ const { name, fullName } = useStore(({ get }) => {
909
+ const [state] = get(userStore);
975
910
  return {
976
- result: async.race(state.api1Results, state.api2Results),
911
+ // Re-renders ONLY when this specific value changes
912
+ name: pick(() => state.profile.name),
913
+
914
+ // Computed values are tracked the same way
915
+ fullName: pick(() => `${state.profile.first} ${state.profile.last}`),
977
916
  };
978
917
  });
979
918
 
980
- return <ResultList items={result} />;
919
+ return <span>{fullName}</span>;
981
920
  }
982
921
  ```
983
922
 
984
- **Async helpers summary:**
923
+ **Use case:** When you need even more precise control over re-renders.
985
924
 
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 |
925
+ **Without pick():** Component re-renders when `state.profile` reference changes (even if `name` didn't change).
995
926
 
996
- ### Derived Async State with `async.derive()`
927
+ **With pick():** Component only re-renders when the picked value actually changes.
997
928
 
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.
929
+ **pick() equality options:**
999
930
 
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.
931
+ ```tsx
932
+ const result = useStore(({ get }) => {
933
+ const [state] = get(mapStore);
934
+ return {
935
+ // Default: strict equality (===)
936
+ x: pick(() => state.coords.x),
1001
937
 
1002
- ```ts
1003
- import { store } from "storion";
1004
- import { async, type AsyncState } from "storion/async";
938
+ // Shallow: compare object properties one level deep
939
+ coords: pick(() => state.coords, "shallow"),
1005
940
 
1006
- interface User {
1007
- id: string;
1008
- name: string;
1009
- }
941
+ // Deep: recursive comparison
942
+ settings: pick(() => state.settings, "deep"),
1010
943
 
1011
- interface Post {
1012
- id: string;
1013
- title: string;
1014
- authorId: string;
1015
- }
944
+ // Custom: provide your own function
945
+ ids: pick(
946
+ () => state.items.map((i) => i.id),
947
+ (a, b) => a.length === b.length && a.every((v, i) => v === b[i])
948
+ ),
949
+ };
950
+ });
951
+ ```
1016
952
 
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
- });
953
+ ---
1031
954
 
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
- });
955
+ ## API Reference
1038
956
 
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
- });
957
+ ### store(options)
1051
958
 
959
+ Creates a store specification.
960
+
961
+ ```ts
962
+ import { store } from "storion";
963
+
964
+ const myStore = store({
965
+ name: "myStore",
966
+ state: { count: 0 },
967
+ setup({ state, update, focus, get, create, onDispose }) {
1052
968
  return {
1053
- fetchUser: userActions.dispatch,
1054
- fetchPosts: postsActions.dispatch,
969
+ inc: () => state.count++,
1055
970
  };
1056
971
  },
1057
972
  });
1058
973
  ```
1059
974
 
1060
- **Conditional dependencies:**
975
+ **Options:**
1061
976
 
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
- ```
977
+ | Option | Type | Description |
978
+ | ------------ | ------------------------------ | ------------------------------------------- |
979
+ | `name` | `string` | Display name for debugging |
980
+ | `state` | `TState` | Initial state object |
981
+ | `setup` | `(ctx) => TActions` | Setup function, returns actions |
982
+ | `lifetime` | `"singleton" \| "autoDispose"` | Instance lifecycle (default: `"singleton"`) |
983
+ | `equality` | `Equality \| EqualityMap` | Custom equality for state comparisons |
984
+ | `onDispatch` | `(event) => void` | Called when any action is dispatched |
985
+ | `onError` | `(error) => void` | Called when an error occurs |
1075
986
 
1076
- **Parallel waiting with `async.all()`:**
987
+ **Setup context:**
1077
988
 
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
- ```
989
+ | Property | Description |
990
+ | -------------------------- | --------------------------------------- |
991
+ | `state` | Reactive state (first-level props only) |
992
+ | `update(fn)` | Immer-style update for nested state |
993
+ | `focus(path)` | Create getter/setter for a path |
994
+ | `get(spec)` | Get dependency (store or service) |
995
+ | `create(factory, ...args)` | Create fresh instance |
996
+ | `dirty(prop?)` | Check if state has changed |
997
+ | `reset()` | Reset to initial state |
998
+ | `onDispose(fn)` | Register cleanup function |
999
+
1000
+ ### container(options?)
1089
1001
 
1090
- **Stale mode - preserve data during recomputation:**
1002
+ Creates a container for managing store and service instances.
1091
1003
 
1092
1004
  ```ts
1093
- state: {
1094
- // Stale mode: keeps previous computed value while recomputing
1095
- summary: async.stale({ userName: "Loading...", postCount: 0 }),
1096
- }
1005
+ import { container } from "storion";
1097
1006
 
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 };
1007
+ const app = container({
1008
+ middleware: myMiddleware,
1103
1009
  });
1010
+
1011
+ // Get store instance
1012
+ const { state, actions } = app.get(userStore);
1013
+
1014
+ // Get service instance
1015
+ const api = app.get(apiService);
1016
+
1017
+ // Create with parameters
1018
+ const logger = app.create(loggerService, "myNamespace");
1019
+
1020
+ // Lifecycle
1021
+ app.delete(userStore); // Remove specific instance
1022
+ app.clear(); // Clear all instances
1023
+ app.dispose(); // Dispose container and cleanup
1104
1024
  ```
1105
1025
 
1106
- **Key behaviors:**
1026
+ **Methods:**
1027
+
1028
+ | Method | Description |
1029
+ | -------------------------- | ------------------------------------- |
1030
+ | `get(spec)` | Get or create cached instance |
1031
+ | `create(factory, ...args)` | Create fresh instance (not cached) |
1032
+ | `set(spec, factory)` | Override factory (useful for testing) |
1033
+ | `delete(spec)` | Remove cached instance |
1034
+ | `clear()` | Clear all cached instances |
1035
+ | `dispose()` | Dispose container and all instances |
1107
1036
 
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 |
1037
+ ### effect(fn, options?)
1114
1038
 
1115
- **`async.derive()` vs manual effects:**
1039
+ Creates a reactive effect.
1116
1040
 
1117
1041
  ```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,
1042
+ import { effect } from "storion";
1043
+
1044
+ const cleanup = effect((ctx) => {
1045
+ console.log("Count:", state.count);
1046
+
1047
+ ctx.onCleanup(() => {
1048
+ console.log("Cleaning up...");
1135
1049
  });
1136
1050
  });
1137
1051
 
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
- });
1052
+ // Later: stop the effect
1053
+ cleanup();
1144
1054
  ```
1145
1055
 
1146
- ### Dependency Injection
1056
+ **Context properties:**
1147
1057
 
1148
- **The problem:** Your stores need shared services (API clients, loggers, config) but importing singletons directly causes issues:
1058
+ | Property | Description |
1059
+ | --------------- | ------------------------------------ |
1060
+ | `onCleanup(fn)` | Register cleanup function |
1061
+ | `safe(promise)` | Wrap promise to ignore stale results |
1062
+ | `signal` | AbortSignal for fetch cancellation |
1063
+ | `refresh()` | Manually trigger re-run (async only) |
1149
1064
 
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
1065
+ **Options:**
1153
1066
 
1154
- **With Storion:** The container is a full DI system that manages the complete lifecycle:
1067
+ | Option | Type | Description |
1068
+ | ------------ | -------- | ------------------- |
1069
+ | `debugLabel` | `string` | Label for debugging |
1155
1070
 
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
1071
+ ### async(focus, handler, options?)
1072
+
1073
+ Creates async state management.
1160
1074
 
1161
1075
  ```ts
1162
- import { container, type Resolver } from "storion";
1076
+ import { async } from "storion/async";
1163
1077
 
1164
- // Define service factory
1165
- interface ApiService {
1166
- get<T>(url: string): Promise<T>;
1167
- post<T>(url: string, data: unknown): Promise<T>;
1168
- }
1078
+ const userAsync = async(
1079
+ focus("user"),
1080
+ async (ctx, userId: string) => {
1081
+ const res = await fetch(`/api/users/${userId}`, { signal: ctx.signal });
1082
+ return res.json();
1083
+ },
1084
+ {
1085
+ retry: { count: 3, delay: 1000 },
1086
+ onSuccess: (data) => console.log("Loaded:", data),
1087
+ onError: (error) => console.error("Failed:", error),
1088
+ }
1089
+ );
1169
1090
 
1170
- function createApiService(resolver: Resolver): ApiService {
1171
- const baseUrl = resolver.get(configFactory).apiUrl;
1091
+ // Actions
1092
+ userAsync.dispatch("123"); // Start async operation
1093
+ userAsync.cancel(); // Cancel current operation
1094
+ userAsync.refresh(); // Refetch with same args
1095
+ userAsync.reset(); // Reset to initial state
1096
+ ```
1172
1097
 
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
- }
1098
+ **Options:**
1187
1099
 
1188
- function configFactory(): { apiUrl: string } {
1189
- return { apiUrl: process.env.API_URL ?? "http://localhost:3000" };
1190
- }
1100
+ | Option | Type | Description |
1101
+ | ------------- | ------------------------------- | ------------------------ |
1102
+ | `retry.count` | `number` | Number of retry attempts |
1103
+ | `retry.delay` | `number \| (attempt) => number` | Delay between retries |
1104
+ | `onSuccess` | `(data) => void` | Called on success |
1105
+ | `onError` | `(error) => void` | Called on error |
1191
1106
 
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
1107
+ **Async helpers:**
1198
1108
 
1199
- return {
1200
- fetchUser: async (id: string) => {
1201
- return api.get(`/users/${id}`);
1202
- },
1203
- };
1204
- },
1109
+ ```ts
1110
+ // Initial state creators
1111
+ async.fresh<T>(); // Fresh mode: data undefined during loading
1112
+ async.stale<T>(initial); // Stale mode: preserves data during loading
1113
+
1114
+ // State extractors (Suspense-compatible)
1115
+ async.wait(state); // Get data or throw
1116
+ async.all(...states); // Wait for all, return tuple
1117
+ async.any(...states); // Get first successful
1118
+ async.race(states); // Get fastest
1119
+
1120
+ // State checks (non-throwing)
1121
+ async.hasData(state); // boolean
1122
+ async.isLoading(state); // boolean
1123
+ async.isError(state); // boolean
1124
+
1125
+ // Derived state
1126
+ async.derive(focus, () => {
1127
+ const a = async.wait(state.a);
1128
+ const b = async.wait(state.b);
1129
+ return computeResult(a, b);
1205
1130
  });
1131
+ ```
1206
1132
 
1207
- // Testing - easy to mock without module mocking
1208
- const mockApi: ApiService = {
1209
- get: async () => ({ id: "1", name: "Test User" }),
1210
- post: async () => ({}),
1211
- };
1133
+ ### pick(fn, equality?)
1212
1134
 
1213
- const testApp = container();
1214
- testApp.set(createApiService, () => mockApi); // Override with mock
1135
+ Fine-grained value tracking.
1215
1136
 
1216
- // Now userStore will use mockApi instead of real API
1217
- const { actions } = testApp.get(userStore);
1218
- await actions.fetchUser("1"); // Uses mockApi.get()
1137
+ ```ts
1138
+ import { pick } from "storion";
1219
1139
 
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
1140
+ // In selector
1141
+ const name = pick(() => state.profile.name);
1142
+ const coords = pick(() => state.coords, "shallow");
1143
+ const config = pick(() => state.config, "deep");
1144
+ const custom = pick(
1145
+ () => state.ids,
1146
+ (a, b) => arraysEqual(a, b)
1147
+ );
1224
1148
  ```
1225
1149
 
1226
- ### Parameterized Factories with `create()`
1150
+ **Equality options:**
1227
1151
 
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.
1152
+ | Value | Description |
1153
+ | ------------------- | --------------------------------- |
1154
+ | (none) | Strict equality (`===`) |
1155
+ | `"shallow"` | Compare properties one level deep |
1156
+ | `"deep"` | Recursive comparison |
1157
+ | `(a, b) => boolean` | Custom comparison function |
1229
1158
 
1230
- **With Storion:** Use `create()` for parameterized factories. Unlike `get()`, `create()` always returns fresh instances and supports additional arguments.
1159
+ ### batch(fn)
1160
+
1161
+ Batch multiple mutations into one notification.
1231
1162
 
1232
1163
  ```ts
1233
- import { store, container, type Resolver } from "storion";
1164
+ import { batch } from "storion";
1234
1165
 
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
- }
1166
+ batch(() => {
1167
+ state.x = 1;
1168
+ state.y = 2;
1169
+ state.z = 3;
1170
+ });
1171
+ // Subscribers notified once, not three times
1172
+ ```
1242
1173
 
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
- }
1174
+ ### untrack(fn)
1258
1175
 
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 });
1176
+ Read state without tracking dependencies.
1267
1177
 
1268
- return {
1269
- fetchUsers: async () => {
1270
- logger.info("Fetching users...");
1271
- await db.query("SELECT * FROM users");
1272
- },
1273
- };
1274
- },
1275
- });
1178
+ ```ts
1179
+ import { untrack } from "storion";
1276
1180
 
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 });
1181
+ effect(() => {
1182
+ const count = state.count; // Tracked
1183
+
1184
+ const name = untrack(() => state.name); // Not tracked
1185
+
1186
+ console.log(count, name);
1187
+ });
1188
+ // Effect only re-runs when count changes, not when name changes
1281
1189
  ```
1282
1190
 
1283
- **Key differences between `get()` and `create()`:**
1191
+ ---
1284
1192
 
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) |
1193
+ ## Advanced Patterns
1291
1194
 
1292
1195
  ### Middleware
1293
1196
 
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.
1197
+ Middleware intercepts store creation for cross-cutting concerns.
1297
1198
 
1298
1199
  ```ts
1299
1200
  import { container, compose, applyFor, applyExcept } from "storion";
1300
1201
  import type { StoreMiddleware } from "storion";
1301
1202
 
1302
- // Logging middleware - ctx.spec is always available
1203
+ // Simple middleware
1303
1204
  const loggingMiddleware: StoreMiddleware = (ctx) => {
1304
- console.log(`Creating store: ${ctx.displayName}`);
1205
+ console.log(`Creating: ${ctx.displayName}`);
1305
1206
  const instance = ctx.next();
1306
1207
  console.log(`Created: ${instance.id}`);
1307
1208
  return instance;
1308
1209
  };
1309
1210
 
1310
- // Persistence middleware
1211
+ // Middleware with store-specific logic
1311
1212
  const persistMiddleware: StoreMiddleware = (ctx) => {
1312
1213
  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...
1214
+
1215
+ if (ctx.spec.options.meta?.persist) {
1216
+ // Add persistence logic
1317
1217
  }
1218
+
1318
1219
  return instance;
1319
1220
  };
1320
1221
 
1222
+ // Apply conditionally
1321
1223
  const app = container({
1322
1224
  middleware: compose(
1323
- // Apply logging to all stores starting with "user"
1225
+ // Apply to stores starting with "user"
1324
1226
  applyFor("user*", loggingMiddleware),
1325
1227
 
1326
- // Apply persistence except for cache stores
1228
+ // Apply except to cache stores
1327
1229
  applyExcept("*Cache", persistMiddleware),
1328
1230
 
1329
1231
  // Apply to specific stores
1330
1232
  applyFor(["authStore", "settingsStore"], loggingMiddleware),
1331
1233
 
1332
- // Apply based on custom condition
1333
- applyFor(
1334
- (ctx) => ctx.spec.options.meta?.persist === true,
1335
- persistMiddleware
1336
- )
1234
+ // Apply based on condition
1235
+ applyFor((ctx) => ctx.spec.options.meta?.debug === true, loggingMiddleware)
1337
1236
  ),
1338
1237
  });
1339
1238
  ```
1340
1239
 
1341
- ---
1342
-
1343
- ## API Reference
1240
+ **Pattern matching:**
1344
1241
 
1345
- ### Core (`storion`)
1242
+ | Pattern | Matches |
1243
+ | ------------------ | ---------------------- |
1244
+ | `"user*"` | Starts with "user" |
1245
+ | `"*Store"` | Ends with "Store" |
1246
+ | `["a", "b"]` | Exact match "a" or "b" |
1247
+ | `(ctx) => boolean` | Custom predicate |
1346
1248
 
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 |
1249
+ ### Parameterized Services
1355
1250
 
1356
- #### Store Options
1251
+ For services that need configuration:
1357
1252
 
1358
1253
  ```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
1254
+ // Parameterized service factory
1255
+ function dbService(resolver, config: { host: string; port: number }) {
1256
+ return {
1257
+ query: (sql: string) =>
1258
+ fetch(`http://${config.host}:${config.port}/query`, {
1259
+ method: "POST",
1260
+ body: sql,
1261
+ }),
1262
+ };
1367
1263
  }
1264
+
1265
+ // Use with create() instead of get()
1266
+ const myStore = store({
1267
+ name: "data",
1268
+ state: { items: [] },
1269
+ setup({ create }) {
1270
+ // create() always makes a fresh instance and accepts args
1271
+ const db = create(dbService, { host: "localhost", port: 5432 });
1272
+
1273
+ return {
1274
+ fetchItems: async () => {
1275
+ return db.query("SELECT * FROM items");
1276
+ },
1277
+ };
1278
+ },
1279
+ });
1368
1280
  ```
1369
1281
 
1370
- **Per-property equality** — Configure different equality checks for each state property:
1282
+ **get() vs create():**
1283
+
1284
+ | Aspect | `get()` | `create()` |
1285
+ | --------- | --------------- | -------------------- |
1286
+ | Caching | Yes (singleton) | No (always fresh) |
1287
+ | Arguments | None | Supports extra args |
1288
+ | Use case | Shared services | Configured instances |
1289
+
1290
+ ### Store-Level Equality
1291
+
1292
+ Configure how state changes are detected:
1371
1293
 
1372
1294
  ```ts
1373
- const myStore = store({
1374
- name: "settings",
1295
+ const mapStore = store({
1296
+ name: "map",
1375
1297
  state: {
1376
- theme: "light",
1377
1298
  coords: { x: 0, y: 0 },
1378
- items: [] as string[],
1379
- config: { nested: { deep: true } },
1299
+ markers: [] as Marker[],
1300
+ settings: { zoom: 1, rotation: 0 },
1380
1301
  },
1381
- // Per-property equality configuration
1382
1302
  equality: {
1383
- theme: "strict", // Default (===)
1384
- coords: "shallow", // Compare { x, y } properties
1385
- items: "shallow", // Compare array elements
1386
- config: "deep", // Deep recursive comparison
1303
+ // Shallow: only notify if x or y actually changed
1304
+ coords: "shallow",
1305
+ // Deep: recursive comparison for complex objects
1306
+ settings: "deep",
1307
+ // Custom function
1308
+ markers: (a, b) => a.length === b.length,
1387
1309
  },
1388
1310
  setup({ state }) {
1389
1311
  return {
1390
1312
  setCoords: (x: number, y: number) => {
1391
- // Only triggers subscribers if x or y actually changed (shallow compare)
1313
+ // This creates a new object, but shallow equality
1314
+ // prevents notification if x and y are the same
1392
1315
  state.coords = { x, y };
1393
1316
  },
1394
1317
  };
@@ -1396,179 +1319,75 @@ const myStore = store({
1396
1319
  });
1397
1320
  ```
1398
1321
 
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)
1322
+ ### Testing with Mocks
1407
1323
 
1408
1324
  ```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.
1425
-
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 |
1325
+ import { container } from "storion";
1432
1326
 
1433
- ```ts
1434
- setup({ get, create }) {
1435
- // get() - cached, same instance every time
1436
- const api = get(apiService); // Singleton
1327
+ // Production code
1328
+ const app = container();
1437
1329
 
1438
- // create() - fresh instance each call
1439
- const childStore = create(childSpec); // New store instance
1330
+ // Test setup
1331
+ const testApp = container();
1440
1332
 
1441
- // create() with arguments - parameterized factory
1442
- const db = create(createDatabase, { host: 'localhost', port: 5432 });
1443
- const logger = create(createLogger, 'auth-store');
1333
+ // Override services with mocks
1334
+ testApp.set(apiService, () => ({
1335
+ get: async () => ({ id: "1", name: "Test User" }),
1336
+ post: async () => ({}),
1337
+ }));
1444
1338
 
1445
- return { /* ... */ };
1446
- }
1339
+ // Now stores will use the mock
1340
+ const { actions } = testApp.get(userStore);
1341
+ await actions.fetchUser("1"); // Uses mock apiService
1447
1342
  ```
1448
1343
 
1449
- ### React (`storion/react`)
1344
+ ### Child Containers
1450
1345
 
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 |
1459
-
1460
- #### useStore Selector
1346
+ For scoped dependencies (e.g., per-request in SSR):
1461
1347
 
1462
1348
  ```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
1467
-
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());
1349
+ const rootApp = container();
1473
1350
 
1474
- return { value: state.value, action: actions.doSomething };
1351
+ // Create child container with overrides
1352
+ const requestApp = container({
1353
+ parent: rootApp,
1475
1354
  });
1476
- ```
1477
-
1478
- ### Async (`storion/async`)
1479
-
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
1355
 
1494
- #### AsyncState Types
1356
+ // Child inherits from parent but can have its own instances
1357
+ requestApp.set(sessionService, () => createSessionForRequest());
1495
1358
 
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
- }
1359
+ // Cleanup after request
1360
+ requestApp.dispose();
1504
1361
  ```
1505
1362
 
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:
1363
+ ### Store Lifecycle
1517
1364
 
1518
1365
  ```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
- }
1366
+ const myStore = store({
1367
+ name: "myStore",
1368
+ lifetime: "autoDispose", // Dispose when no subscribers
1369
+ state: { ... },
1370
+ setup({ onDispose }) {
1371
+ const interval = setInterval(() => {}, 1000);
1372
+
1373
+ // Cleanup when store is disposed
1374
+ onDispose(() => {
1375
+ clearInterval(interval);
1376
+ });
1537
1377
 
1538
- type MiddlewareContext = FactoryMiddlewareContext | StoreMiddlewareContext;
1378
+ return { ... };
1379
+ },
1380
+ });
1539
1381
  ```
1540
1382
 
1541
- **Store-specific middleware** (for containers):
1383
+ **Lifetime options:**
1542
1384
 
1543
- ```ts
1544
- // No generics needed - simple and clean
1545
- type StoreMiddleware = (ctx: StoreMiddlewareContext) => StoreInstance;
1385
+ | Value | Behavior |
1386
+ | --------------- | ------------------------------------------- |
1387
+ | `"singleton"` | Lives until container is disposed (default) |
1388
+ | `"autoDispose"` | Disposed when last subscriber unsubscribes |
1546
1389
 
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
- };
1553
- ```
1554
-
1555
- **Generic middleware** (for resolver, works with both stores and factories):
1556
-
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
- ```
1570
-
1571
- ### Devtools (`storion/devtools`)
1390
+ ### DevTools Integration
1572
1391
 
1573
1392
  ```ts
1574
1393
  import { devtools } from "storion/devtools";
@@ -1576,18 +1395,14 @@ import { devtools } from "storion/devtools";
1576
1395
  const app = container({
1577
1396
  middleware: devtools({
1578
1397
  name: "My App",
1579
- // Enable in development only
1580
1398
  enabled: process.env.NODE_ENV === "development",
1581
1399
  }),
1582
1400
  });
1583
1401
  ```
1584
1402
 
1585
- ### Devtools Panel (`storion/devtools-panel`)
1586
-
1587
1403
  ```tsx
1588
1404
  import { DevtoolsPanel } from "storion/devtools-panel";
1589
1405
 
1590
- // Mount anywhere in your app (dev only)
1591
1406
  function App() {
1592
1407
  return (
1593
1408
  <>
@@ -1600,52 +1415,104 @@ function App() {
1600
1415
 
1601
1416
  ---
1602
1417
 
1603
- ## Edge Cases & Best Practices
1418
+ ## Error Handling
1604
1419
 
1605
- ### Don't directly mutate nested state or arrays
1420
+ ### Effect Errors
1606
1421
 
1607
- Direct mutation only works for first-level properties. Use `update()` for nested objects and arrays:
1422
+ Errors in effects are caught and can be handled:
1608
1423
 
1609
1424
  ```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!
1425
+ const myStore = store({
1426
+ name: "myStore",
1427
+ state: { ... },
1428
+ onError: (error) => {
1429
+ console.error("Store error:", error);
1430
+ // Send to error tracking service
1431
+ },
1432
+ setup({ state }) {
1433
+ effect(() => {
1434
+ if (state.invalid) {
1435
+ throw new Error("Invalid state!");
1436
+ }
1437
+ });
1438
+
1439
+ return { ... };
1440
+ },
1441
+ });
1442
+ ```
1443
+
1444
+ ### Async Errors
1445
+
1446
+ ```ts
1447
+ const userAsync = async(
1448
+ focus("user"),
1449
+ async (ctx) => {
1450
+ const res = await fetch("/api/user", { signal: ctx.signal });
1451
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
1452
+ return res.json();
1453
+ },
1454
+ {
1455
+ onError: (error) => {
1456
+ // Handle or log the error
1615
1457
  },
1616
- addItem: (item: string) => {
1617
- state.items.push(item); // Won't work!
1458
+ retry: {
1459
+ count: 3,
1460
+ delay: (attempt) => Math.min(1000 * 2 ** attempt, 10000),
1618
1461
  },
1619
- };
1462
+ }
1463
+ );
1464
+ ```
1465
+
1466
+ ### React Error Boundaries
1467
+
1468
+ ```tsx
1469
+ function App() {
1470
+ return (
1471
+ <ErrorBoundary fallback={<ErrorPage />}>
1472
+ <Suspense fallback={<Spinner />}>
1473
+ <UserProfile />
1474
+ </Suspense>
1475
+ </ErrorBoundary>
1476
+ );
1620
1477
  }
1621
1478
 
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
- };
1479
+ function UserProfile() {
1480
+ const { user } = useStore(({ get }) => {
1481
+ const [state] = get(userStore);
1482
+ // async.wait() throws on error, caught by ErrorBoundary
1483
+ return { user: async.wait(state.currentUser) };
1484
+ });
1485
+
1486
+ return <div>{user.name}</div>;
1640
1487
  }
1641
1488
  ```
1642
1489
 
1643
- ### ❌ Don't call `get()` inside actions
1490
+ ---
1491
+
1492
+ ## Limitations & Anti-patterns
1493
+
1494
+ ### ❌ Don't Mutate Nested State Directly
1644
1495
 
1645
- `get()` is for declaring dependencies during setup, not runtime:
1496
+ Direct mutation only works for first-level properties:
1646
1497
 
1647
1498
  ```ts
1648
- // ❌ Wrong - calling get() inside action
1499
+ // ❌ Wrong - won't trigger reactivity
1500
+ state.profile.name = "John";
1501
+ state.items.push("new item");
1502
+
1503
+ // ✅ Correct - use update()
1504
+ update((draft) => {
1505
+ draft.profile.name = "John";
1506
+ draft.items.push("new item");
1507
+ });
1508
+ ```
1509
+
1510
+ ### ❌ Don't Call get() Inside Actions
1511
+
1512
+ `get()` is for setup-time dependencies, not runtime:
1513
+
1514
+ ```ts
1515
+ // ❌ Wrong
1649
1516
  setup({ get }) {
1650
1517
  return {
1651
1518
  doSomething: () => {
@@ -1654,31 +1521,30 @@ setup({ get }) {
1654
1521
  };
1655
1522
  }
1656
1523
 
1657
- // ✅ Correct - declare dependency at setup time
1524
+ // ✅ Correct - capture at setup time
1658
1525
  setup({ get }) {
1659
1526
  const [otherState, otherActions] = get(otherStore);
1660
1527
 
1661
1528
  return {
1662
1529
  doSomething: () => {
1663
- if (otherState.ready) {
1664
- // Use the reactive state captured during setup
1665
- }
1530
+ // Use the captured state/actions
1531
+ if (otherState.ready) { ... }
1666
1532
  },
1667
1533
  };
1668
1534
  }
1669
1535
  ```
1670
1536
 
1671
- ### ❌ Don't return Promises from effects
1537
+ ### ❌ Don't Use Async Effects
1672
1538
 
1673
- Effects must be synchronous. Use `ctx.safe()` for async:
1539
+ Effects must be synchronous:
1674
1540
 
1675
1541
  ```ts
1676
- // ❌ Wrong - async effect
1542
+ // ❌ Wrong
1677
1543
  effect(async (ctx) => {
1678
- const data = await fetchData(); // Don't do this!
1544
+ const data = await fetchData();
1679
1545
  });
1680
1546
 
1681
- // ✅ Correct - use ctx.safe()
1547
+ // ✅ Correct
1682
1548
  effect((ctx) => {
1683
1549
  ctx.safe(fetchData()).then((data) => {
1684
1550
  state.data = data;
@@ -1686,80 +1552,121 @@ effect((ctx) => {
1686
1552
  });
1687
1553
  ```
1688
1554
 
1689
- ### Use `pick()` for computed values from nested state
1555
+ ### Don't Pass Anonymous Functions to trigger()
1690
1556
 
1691
- When reading nested state in selectors, use `pick()` for fine-grained reactivity:
1557
+ Anonymous functions create new references on every render:
1692
1558
 
1693
1559
  ```ts
1694
- // Re-renders when profile object changes (coarse tracking)
1695
- const name = state.profile.name;
1560
+ // ❌ Wrong - anonymous function called every render
1561
+ trigger(() => {
1562
+ actions.search(query);
1563
+ }, [query]);
1696
1564
 
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}`);
1565
+ // Correct - stable function reference
1566
+ trigger(actions.search, [query], query);
1700
1567
  ```
1701
1568
 
1702
- ### Use stale mode for SWR patterns
1569
+ ### Don't Call refresh() Synchronously
1570
+
1571
+ Calling `ctx.refresh()` during effect execution throws an error:
1703
1572
 
1704
1573
  ```ts
1705
- // Fresh mode: data is undefined during loading
1706
- state: {
1707
- data: async.fresh<Data>(),
1574
+ // Wrong - throws error
1575
+ effect((ctx) => {
1576
+ ctx.refresh(); // Error!
1577
+ });
1578
+
1579
+ // ✅ Correct - async or return pattern
1580
+ effect((ctx) => {
1581
+ setTimeout(() => ctx.refresh(), 1000);
1582
+ // or
1583
+ return ctx.refresh;
1584
+ });
1585
+ ```
1586
+
1587
+ ### ❌ Don't Create Stores Inside Components
1588
+
1589
+ Store specs should be defined at module level:
1590
+
1591
+ ```ts
1592
+ // ❌ Wrong - creates new spec on every render
1593
+ function Component() {
1594
+ const myStore = store({ ... }); // Don't do this!
1708
1595
  }
1709
1596
 
1710
- // Stale mode: preserves previous data during loading (SWR pattern)
1711
- state: {
1712
- data: async.stale<Data>(initialData),
1597
+ // Correct - define at module level
1598
+ const myStore = store({ ... });
1599
+
1600
+ function Component() {
1601
+ const { state } = useStore(({ get }) => get(myStore));
1713
1602
  }
1714
1603
  ```
1715
1604
 
1716
- ---
1605
+ ### ❌ Don't Forget to Handle All Async States
1717
1606
 
1718
- ## TypeScript
1607
+ ```tsx
1608
+ // ❌ Incomplete - misses error and idle states
1609
+ function User() {
1610
+ const { user } = useStore(({ get }) => {
1611
+ const [state] = get(userStore);
1612
+ return { user: state.currentUser };
1613
+ });
1719
1614
 
1720
- Storion is written in TypeScript and provides excellent type inference:
1615
+ if (user.status === "pending") return <Spinner />;
1616
+ return <div>{user.data.name}</div>; // Crashes if error or idle!
1617
+ }
1721
1618
 
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
- });
1619
+ // ✅ Complete handling
1620
+ function User() {
1621
+ const { user } = useStore(...);
1734
1622
 
1735
- // Using with explicit types when needed (unions, nullable)
1736
- interface MyState {
1737
- userId: string | null;
1738
- status: "idle" | "loading" | "ready";
1623
+ if (user.status === "idle") return <button>Load User</button>;
1624
+ if (user.status === "pending") return <Spinner />;
1625
+ if (user.status === "error") return <Error error={user.error} />;
1626
+ return <div>{user.data.name}</div>;
1739
1627
  }
1628
+ ```
1740
1629
 
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,
1630
+ ### Limitation: No Deep Property Tracking
1631
+
1632
+ Storion tracks first-level property access, not deep paths:
1633
+
1634
+ ```ts
1635
+ // Both track "profile" property, not "profile.name"
1636
+ const name1 = state.profile.name;
1637
+ const name2 = state.profile.email;
1638
+
1639
+ // To get finer tracking, use pick()
1640
+ const name = pick(() => state.profile.name);
1641
+ ```
1642
+
1643
+ ### Limitation: Equality Check Timing
1644
+
1645
+ Store-level equality runs on write, component-level equality runs on read:
1646
+
1647
+ ```ts
1648
+ // Store level - prevents notification
1649
+ store({
1650
+ equality: { coords: "shallow" },
1747
1651
  setup({ state }) {
1748
1652
  return {
1749
- setUser: (id: string | null) => {
1750
- state.userId = id;
1653
+ setCoords: (x, y) => {
1654
+ // If same x,y, no subscribers are notified
1655
+ state.coords = { x, y };
1751
1656
  },
1752
1657
  };
1753
1658
  },
1754
1659
  });
1660
+
1661
+ // Component level - prevents re-render
1662
+ const x = pick(() => state.coords.x);
1663
+ // Component only re-renders if x specifically changed
1755
1664
  ```
1756
1665
 
1757
1666
  ---
1758
1667
 
1759
1668
  ## Contributing
1760
1669
 
1761
- We welcome contributions! Here's how to get started:
1762
-
1763
1670
  ### Prerequisites
1764
1671
 
1765
1672
  - Node.js 18+
@@ -1768,40 +1675,20 @@ We welcome contributions! Here's how to get started:
1768
1675
  ### Setup
1769
1676
 
1770
1677
  ```bash
1771
- # Clone the repo
1772
1678
  git clone https://github.com/linq2js/storion.git
1773
1679
  cd storion
1774
-
1775
- # Install dependencies
1776
1680
  pnpm install
1777
-
1778
- # Build the library
1779
1681
  pnpm --filter storion build
1780
1682
  ```
1781
1683
 
1782
1684
  ### Development
1783
1685
 
1784
1686
  ```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
1687
+ pnpm --filter storion dev # Watch mode
1688
+ pnpm --filter storion test # Run tests
1689
+ pnpm --filter storion test:ui # Tests with UI
1796
1690
  ```
1797
1691
 
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
1692
  ### Commit Messages
1806
1693
 
1807
1694
  Use [Conventional Commits](https://www.conventionalcommits.org/):
@@ -1810,17 +1697,8 @@ Use [Conventional Commits](https://www.conventionalcommits.org/):
1810
1697
  feat(core): add new feature
1811
1698
  fix(react): resolve hook issue
1812
1699
  docs: update README
1813
- chore: bump dependencies
1814
1700
  ```
1815
1701
 
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
1702
  ---
1825
1703
 
1826
1704
  ## License