storion 0.8.3 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +127 -21
  2. package/README.md +42 -2021
  3. package/dist/async/abortable.d.ts +295 -0
  4. package/dist/async/abortable.d.ts.map +1 -0
  5. package/dist/async/async.d.ts +86 -5
  6. package/dist/async/async.d.ts.map +1 -1
  7. package/dist/async/context.d.ts +15 -0
  8. package/dist/async/context.d.ts.map +1 -0
  9. package/dist/async/index.d.ts +16 -3
  10. package/dist/async/index.d.ts.map +1 -1
  11. package/dist/async/index.js +407 -137
  12. package/dist/async/safe.d.ts +221 -0
  13. package/dist/async/safe.d.ts.map +1 -0
  14. package/dist/async/types.d.ts +77 -29
  15. package/dist/async/types.d.ts.map +1 -1
  16. package/dist/async/wrappers.d.ts +217 -0
  17. package/dist/async/wrappers.d.ts.map +1 -0
  18. package/dist/core/effect.d.ts +34 -26
  19. package/dist/core/effect.d.ts.map +1 -1
  20. package/dist/core/equality.d.ts +25 -0
  21. package/dist/core/equality.d.ts.map +1 -1
  22. package/dist/core/focus.d.ts +20 -0
  23. package/dist/core/focus.d.ts.map +1 -0
  24. package/dist/core/focusHelpers.d.ts +258 -0
  25. package/dist/core/focusHelpers.d.ts.map +1 -0
  26. package/dist/core/middleware.d.ts +4 -4
  27. package/dist/core/store.d.ts.map +1 -1
  28. package/dist/core/storeContext.d.ts +2 -9
  29. package/dist/core/storeContext.d.ts.map +1 -1
  30. package/dist/dev.d.ts +0 -10
  31. package/dist/dev.d.ts.map +1 -1
  32. package/dist/{index-C8B6Mo8r.js → effect-BDQU8Voz.js} +1241 -583
  33. package/dist/errors.d.ts +6 -0
  34. package/dist/errors.d.ts.map +1 -1
  35. package/dist/index.d.ts +5 -4
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/network/index.d.ts +69 -0
  38. package/dist/network/index.d.ts.map +1 -0
  39. package/dist/network/retry.d.ts +53 -0
  40. package/dist/network/retry.d.ts.map +1 -0
  41. package/dist/network/services.d.ts +58 -0
  42. package/dist/network/services.d.ts.map +1 -0
  43. package/dist/network/store.d.ts +36 -0
  44. package/dist/network/store.d.ts.map +1 -0
  45. package/dist/network/utils.d.ts +9 -0
  46. package/dist/network/utils.d.ts.map +1 -0
  47. package/dist/persist/index.d.ts +1 -1
  48. package/dist/persist/index.d.ts.map +1 -1
  49. package/dist/persist/index.js +55 -31
  50. package/dist/persist/persist.d.ts +119 -62
  51. package/dist/persist/persist.d.ts.map +1 -1
  52. package/dist/pool.d.ts +77 -0
  53. package/dist/pool.d.ts.map +1 -0
  54. package/dist/react/index.d.ts +2 -2
  55. package/dist/react/index.d.ts.map +1 -1
  56. package/dist/react/index.js +245 -244
  57. package/dist/react/stable.d.ts +27 -0
  58. package/dist/react/stable.d.ts.map +1 -0
  59. package/dist/react/useStore.d.ts +38 -13
  60. package/dist/react/useStore.d.ts.map +1 -1
  61. package/dist/react/withStore.d.ts.map +1 -1
  62. package/dist/storion.js +911 -37
  63. package/dist/trigger.d.ts +12 -7
  64. package/dist/trigger.d.ts.map +1 -1
  65. package/dist/types.d.ts +133 -22
  66. package/dist/types.d.ts.map +1 -1
  67. package/dist/utils/storeTuple.d.ts +7 -0
  68. package/dist/utils/storeTuple.d.ts.map +1 -0
  69. package/package.json +5 -1
  70. package/dist/collection.d.ts +0 -34
  71. package/dist/collection.d.ts.map +0 -1
  72. package/dist/core/proxy.d.ts +0 -47
  73. package/dist/core/proxy.d.ts.map +0 -1
  74. package/dist/effect-C6h0PDDI.js +0 -446
  75. package/dist/isPromiseLike-bFkfHAbm.js +0 -6
  76. package/dist/react/useLocalStore.d.ts +0 -48
  77. package/dist/react/useLocalStore.d.ts.map +0 -1
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  <h1 align="center">Storion</h1>
6
6
 
7
7
  <p align="center">
8
- <strong>Reactive stores for modern apps. Type-safe. Auto-tracked. Effortlessly composable.</strong>
8
+ <strong>Reactive state management with automatic dependency tracking</strong>
9
9
  </p>
10
10
 
11
11
  <p align="center">
@@ -13,122 +13,50 @@
13
13
  <a href="https://bundlephobia.com/package/storion"><img src="https://img.shields.io/bundlephobia/minzip/storion?style=flat-square&color=green" alt="bundle size"></a>
14
14
  <a href="https://github.com/linq2js/storion/actions"><img src="https://img.shields.io/github/actions/workflow/status/linq2js/storion/ci.yml?style=flat-square&label=tests" alt="tests"></a>
15
15
  <a href="https://github.com/linq2js/storion/blob/main/LICENSE"><img src="https://img.shields.io/npm/l/storion?style=flat-square" alt="license"></a>
16
- <a href="https://github.com/linq2js/storion"><img src="https://img.shields.io/github/stars/linq2js/storion?style=flat-square" alt="stars"></a>
17
16
  </p>
18
17
 
19
18
  <p align="center">
20
- <a href="https://linq2js.github.io/storion/">📚 Documentation</a>
21
- <a href="#features">Features</a>
22
- <a href="#installation">Installation</a>
23
- <a href="#quick-start">Quick Start</a>
24
- <a href="#core-concepts">Core Concepts</a> •
25
- <a href="#api-reference">API Reference</a> •
26
- <a href="#advanced-patterns">Advanced Patterns</a> •
27
- <a href="#limitations--anti-patterns">Limitations</a>
19
+ <a href="https://linq2js.github.io/storion/"><strong>📚 Documentation</strong></a> ·
20
+ <a href="https://linq2js.github.io/storion/demos.html">Demos</a> ·
21
+ <a href="https://linq2js.github.io/storion/api/store.html">API</a> ·
22
+ <a href="https://linq2js.github.io/storion/guide/getting-started.html">Getting Started</a>
28
23
  </p>
29
24
 
30
25
  ---
31
26
 
32
- ## Table of Contents
27
+ ## Why Storion?
33
28
 
34
- - [What is Storion?](#what-is-storion)
35
- - [Features](#features)
36
- - [Installation](#installation)
37
- - [Quick Start](#quick-start)
38
- - [Core Concepts](#core-concepts)
39
- - [Stores](#stores)
40
- - [Services](#services)
41
- - [Container](#container)
42
- - [Reactivity](#reactivity)
43
- - [Working with State](#working-with-state)
44
- - [Direct Mutation](#direct-mutation)
45
- - [Nested State with update()](#nested-state-with-update)
46
- - [Focus (Lens-like Access)](#focus-lens-like-access)
47
- - [Reactive Effects](#reactive-effects)
48
- - [Async State Management](#async-state-management)
49
- - [Using Stores in React](#using-stores-in-react)
50
- - [API Reference](#api-reference)
51
- - [Advanced Patterns](#advanced-patterns)
52
- - [Limitations & Anti-patterns](#limitations--anti-patterns)
53
- - [Contributing](#contributing)
29
+ **Simple at first. Powerful as you grow.**
54
30
 
55
- ---
56
-
57
- ## What is Storion?
58
-
59
- Storion is a lightweight state management library that automatically tracks which parts of your state you use and only updates when those parts change.
60
-
61
- **The core idea is simple:**
62
-
63
- 1. You read state → Storion remembers what you read
64
- 2. That state changes → Storion updates only the components that need it
65
-
66
- No manual selectors. No accidental over-rendering. Just write natural code.
67
-
68
- ```tsx
69
- function Counter() {
70
- const { count, inc } = useStore(({ get }) => {
71
- const [state, actions] = get(counterStore);
72
- return { count: state.count, inc: actions.inc };
73
- });
74
-
75
- return <button onClick={inc}>{count}</button>;
76
- }
77
- ```
78
-
79
- **What Storion does:**
80
-
81
- - When you access `state.count`, Storion notes that this component depends on `count`
82
- - When `count` changes, Storion re-renders only this component
83
- - If other state properties change, this component stays untouched
84
-
85
- ---
31
+ Start with basic stores and direct mutations. As your app grows, layer in async state, effects, dependency injection, and middleware — all without rewriting existing code.
86
32
 
87
33
  ## Features
88
34
 
89
- | Feature | Description |
90
- | --------------------------- | ----------------------------------------------------------- |
91
- | 🎯 **Auto-tracking** | Dependencies tracked automatically when you read state |
92
- | 🔒 **Type-safe** | Full TypeScript support with excellent inference |
93
- | **Fine-grained updates** | Only re-render what actually changed |
94
- | 🧩 **Composable** | Mix stores, use dependency injection, create derived values |
95
- | 🔄 **Reactive effects** | Side effects that automatically respond to state changes |
96
- | 📦 **Tiny footprint** | ~4KB minified + gzipped |
97
- | 🛠️ **DevTools** | Built-in devtools panel for debugging |
98
- | 🔌 **Middleware** | Extensible with conditional middleware patterns |
99
- | ⏳ **Async helpers** | First-class async state management with cancellation |
100
-
101
- ---
35
+ | | |
36
+ | -------------------- | ---------------------------------------- |
37
+ | 🎯 **Auto-tracking** | Dependencies tracked when you read state |
38
+ | **Fine-grained** | Only re-render what changed |
39
+ | 🔒 **Type-safe** | Full TypeScript with excellent inference |
40
+ | 📦 **Tiny** | ~4KB minified + gzipped |
41
+ | **Async** | First-class loading states with Suspense |
42
+ | 🛠️ **DevTools** | Built-in debugging panel |
102
43
 
103
44
  ## Installation
104
45
 
105
46
  ```bash
106
47
  npm install storion
107
- # or
108
- pnpm add storion
109
- # or
110
- yarn add storion
111
- ```
112
-
113
- For React integration:
114
-
115
- ```bash
116
- npm install storion react
117
48
  ```
118
49
 
119
- ---
120
-
121
50
  ## Quick Start
122
51
 
123
- ### Single Store (Simplest Approach)
124
-
125
- Best for small apps or isolated features.
52
+ **Read state Storion tracks it. State changes → Only affected components re-render.**
126
53
 
127
54
  ```tsx
128
- import { create } from "storion/react";
55
+ import { store } from "storion";
56
+ import { useStore } from "storion/react";
129
57
 
130
- // Define store + hook in one call
131
- const [counterStore, useCounter] = create({
58
+ // Define a store
59
+ const counterStore = store({
132
60
  name: "counter",
133
61
  state: { count: 0 },
134
62
  setup({ state }) {
@@ -141,1956 +69,49 @@ const [counterStore, useCounter] = create({
141
69
 
142
70
  // Use in React
143
71
  function Counter() {
144
- const { count, inc } = useCounter((state, actions) => ({
145
- count: state.count,
146
- inc: actions.inc,
147
- }));
148
-
149
- return <button onClick={inc}>{count}</button>;
150
- }
151
-
152
- // Use outside React
153
- counterStore.actions.inc();
154
- console.log(counterStore.state.count);
155
- ```
156
-
157
- **What Storion does:**
158
-
159
- 1. Creates a reactive state container with `{ count: 0 }`
160
- 2. Wraps the state so any read is tracked
161
- 3. When `inc()` changes `count`, Storion notifies only subscribers using `count`
162
- 4. The React hook connects the component to the store and handles cleanup automatically
163
-
164
- ### Multi-Store with Container (Scalable Approach)
165
-
166
- Best for larger apps with multiple stores.
167
-
168
- ```tsx
169
- import { store, container } from "storion";
170
- import { StoreProvider, useStore } from "storion/react";
171
-
172
- // Define stores separately
173
- const authStore = store({
174
- name: "auth",
175
- state: { userId: null as string | null },
176
- setup({ state }) {
177
- return {
178
- login: (id: string) => {
179
- state.userId = id;
180
- },
181
- logout: () => {
182
- state.userId = null;
183
- },
184
- };
185
- },
186
- });
187
-
188
- const todosStore = store({
189
- name: "todos",
190
- state: { items: [] as string[] },
191
- setup({ state, update }) {
192
- return {
193
- add: (text: string) => {
194
- update((draft) => {
195
- draft.items.push(text);
196
- });
197
- },
198
- };
199
- },
200
- });
201
-
202
- // Create container (manages all store instances)
203
- const app = container();
204
-
205
- // Provide to React tree
206
- function App() {
207
- return (
208
- <StoreProvider container={app}>
209
- <Screen />
210
- </StoreProvider>
211
- );
212
- }
213
-
214
- // Consume multiple stores
215
- function Screen() {
216
- const { userId, items, add, login } = useStore(({ get }) => {
217
- const [auth, authActions] = get(authStore);
218
- const [todos, todosActions] = get(todosStore);
219
- return {
220
- userId: auth.userId,
221
- items: todos.items,
222
- add: todosActions.add,
223
- login: authActions.login,
224
- };
72
+ const { count, inc, dec } = useStore(({ get }) => {
73
+ const [state, actions] = get(counterStore);
74
+ return { count: state.count, ...actions };
225
75
  });
226
76
 
227
77
  return (
228
78
  <div>
229
- <p>User: {userId ?? "Not logged in"}</p>
230
- <button onClick={() => login("user-1")}>Login</button>
231
- <ul>
232
- {items.map((item, i) => (
233
- <li key={i}>{item}</li>
234
- ))}
235
- </ul>
236
- <button onClick={() => add("New todo")}>Add Todo</button>
79
+ <button onClick={dec}>-</button>
80
+ <span>{count}</span>
81
+ <button onClick={inc}>+</button>
237
82
  </div>
238
83
  );
239
84
  }
240
85
  ```
241
86
 
242
- **What Storion does:**
243
-
244
- 1. Each `store()` call creates a store specification (a blueprint)
245
- 2. The `container()` manages store instances and their lifecycles
246
- 3. When you call `get(authStore)`, the container either returns an existing instance or creates one
247
- 4. All stores share the same container, enabling cross-store communication
248
- 5. The container handles cleanup when the app unmounts
249
-
250
- ---
251
-
252
- ## Core Concepts
253
-
254
- ### Stores
255
-
256
- 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.
257
-
258
- ```ts
259
- import { store } from "storion";
260
-
261
- const userStore = store({
262
- name: "user", // Identifier for debugging
263
- state: {
264
- // Initial state
265
- name: "",
266
- email: "",
267
- },
268
- setup({ state }) {
269
- // Setup function returns actions
270
- return {
271
- setName: (name: string) => {
272
- state.name = name;
273
- },
274
- setEmail: (email: string) => {
275
- state.email = email;
276
- },
277
- };
278
- },
279
- });
280
- ```
281
-
282
- **Naming convention:** Use `xxxStore` for store specifications (e.g., `userStore`, `authStore`, `cartStore`).
283
-
284
- ### Services
285
-
286
- A **service** is a factory function that creates dependencies like API clients, loggers, or utilities. Services are cached by the container.
287
-
288
- ```ts
289
- // Service factory (use xxxService naming)
290
- function apiService(resolver) {
291
- return {
292
- get: (url: string) => fetch(url).then((r) => r.json()),
293
- post: (url: string, data: unknown) =>
294
- fetch(url, { method: "POST", body: JSON.stringify(data) }).then((r) =>
295
- r.json()
296
- ),
297
- };
298
- }
299
-
300
- function loggerService(resolver) {
301
- return {
302
- info: (msg: string) => console.log(`[INFO] ${msg}`),
303
- error: (msg: string) => console.error(`[ERROR] ${msg}`),
304
- };
305
- }
306
- ```
307
-
308
- **Naming convention:** Use `xxxService` for service factories (e.g., `apiService`, `loggerService`, `authService`).
309
-
310
- ### Using Services in Stores
311
-
312
- ```ts
313
- const userStore = store({
314
- name: "user",
315
- state: { user: null },
316
- setup({ get }) {
317
- // Get services (cached automatically)
318
- const api = get(apiService);
319
- const logger = get(loggerService);
320
-
321
- return {
322
- fetchUser: async (id: string) => {
323
- logger.info(`Fetching user ${id}`);
324
- return api.get(`/users/${id}`);
325
- },
326
- };
327
- },
328
- });
329
- ```
330
-
331
- **What Storion does:**
332
-
333
- 1. When `get(apiService)` is called, the container checks if an instance exists
334
- 2. If not, it calls `apiService()` to create one and caches it
335
- 3. Future calls to `get(apiService)` return the same instance
336
- 4. This gives you dependency injection without complex configuration
337
-
338
- ### Container
339
-
340
- The **container** is the central hub that:
341
-
342
- - Creates and caches store instances
343
- - Creates and caches service instances
344
- - Provides dependency injection
345
- - Manages cleanup and disposal
346
-
347
- ```ts
348
- import { container } from "storion";
349
-
350
- const app = container();
351
-
352
- // Get store instance
353
- const { state, actions } = app.get(userStore);
354
-
355
- // Get service instance
356
- const api = app.get(apiService);
357
-
358
- // Clear all instances (useful for testing)
359
- app.clear();
360
-
361
- // Dispose container (cleanup all resources)
362
- app.dispose();
363
- ```
364
-
365
- ### Reactivity
366
-
367
- Storion's reactivity is built on a simple principle: **track reads, notify on writes**.
368
-
369
- ```ts
370
- // When you read state.count, Storion tracks this access
371
- const value = state.count;
372
-
373
- // When you write state.count, Storion notifies all trackers
374
- state.count = value + 1;
375
- ```
376
-
377
- **What Storion does behind the scenes:**
378
-
379
- 1. State is wrapped in a tracking layer
380
- 2. Each read is recorded: "Component A depends on `count`"
381
- 3. Each write triggers a check: "Who depends on `count`? Notify them."
382
- 4. Only affected subscribers are notified, keeping updates minimal
383
-
384
- ---
385
-
386
- ## Working with State
387
-
388
- ### Direct Mutation
389
-
390
- For **first-level properties**, you can assign directly:
391
-
392
- ```ts
393
- const userStore = store({
394
- name: "user",
395
- state: {
396
- name: "",
397
- age: 0,
398
- isActive: false,
399
- },
400
- setup({ state }) {
401
- return {
402
- setName: (name: string) => {
403
- state.name = name;
404
- },
405
- setAge: (age: number) => {
406
- state.age = age;
407
- },
408
- activate: () => {
409
- state.isActive = true;
410
- },
411
- };
412
- },
413
- });
414
- ```
415
-
416
- **Use case:** Simple state updates where you're changing a top-level property.
417
-
418
- **What Storion does:**
419
-
420
- 1. Intercepts the assignment `state.name = name`
421
- 2. Compares old and new values
422
- 3. If different, notifies all subscribers watching `name`
423
-
424
- ### Nested State with update()
425
-
426
- For **nested objects or arrays**, use `update()` with an immer-style draft:
427
-
428
- ```ts
429
- const userStore = store({
430
- name: "user",
431
- state: {
432
- profile: { name: "", email: "" },
433
- tags: [] as string[],
434
- },
435
- setup({ state, update }) {
436
- return {
437
- // Update nested object
438
- setProfileName: (name: string) => {
439
- update((draft) => {
440
- draft.profile.name = name;
441
- });
442
- },
443
-
444
- // Update array
445
- addTag: (tag: string) => {
446
- update((draft) => {
447
- draft.tags.push(tag);
448
- });
449
- },
450
-
451
- // Batch multiple changes
452
- updateProfile: (name: string, email: string) => {
453
- update((draft) => {
454
- draft.profile.name = name;
455
- draft.profile.email = email;
456
- });
457
- },
458
- };
459
- },
460
- });
461
- ```
462
-
463
- **Use case:** Any mutation to nested objects, arrays, or when you need to update multiple properties atomically.
464
-
465
- **What Storion does:**
466
-
467
- 1. Creates a draft copy of the state
468
- 2. Lets you mutate the draft freely
469
- 3. Compares the draft to the original state
470
- 4. Applies only the changes and notifies affected subscribers
471
- 5. All changes within one `update()` call are batched into a single notification
472
-
473
- ### Focus (Lens-like Access)
474
-
475
- `focus()` creates a getter/setter pair for any state path:
476
-
477
- ```ts
478
- const settingsStore = store({
479
- name: "settings",
480
- state: {
481
- user: { name: "", email: "" },
482
- preferences: {
483
- theme: "light" as "light" | "dark",
484
- notifications: true,
485
- },
486
- },
487
- setup({ focus }) {
488
- // Create focused accessors
489
- const [getTheme, setTheme] = focus("preferences.theme");
490
- const [getUser, setUser] = focus("user");
491
-
492
- return {
493
- // Direct value
494
- setTheme,
495
-
496
- // Computed from previous value
497
- toggleTheme: () => {
498
- setTheme((prev) => (prev === "light" ? "dark" : "light"));
499
- },
500
-
501
- // Immer-style mutation on focused path
502
- updateUserName: (name: string) => {
503
- setUser((draft) => {
504
- draft.name = name;
505
- });
506
- },
507
-
508
- // Getter for use in effects
509
- getTheme,
510
- };
511
- },
512
- });
513
- ```
514
-
515
- **Use case:** When you frequently access a deep path and want cleaner code.
516
-
517
- **What Storion does:**
518
-
519
- 1. Parses the path `"preferences.theme"` once at setup time
520
- 2. The getter reads directly from that path
521
- 3. The setter determines the update type automatically:
522
- - Direct value: `setTheme("dark")`
523
- - Reducer (returns new value): `setTheme(prev => newValue)`
524
- - Producer (mutates draft): `setTheme(draft => { draft.x = y })`
525
-
526
- **Focus setter patterns:**
527
-
528
- | Pattern | Example | When to use |
529
- | ------------ | ------------------------------- | ----------------------------- |
530
- | Direct value | `set("dark")` | Replacing the entire value |
531
- | Reducer | `set(prev => prev + 1)` | Computing from previous value |
532
- | Producer | `set(draft => { draft.x = 1 })` | Partial updates to objects |
533
-
534
- ---
535
-
536
- ## Reactive Effects
537
-
538
- Effects are functions that run automatically when their dependencies change.
539
-
540
- ### Basic Effect
541
-
542
- ```ts
543
- import { store, effect } from "storion";
544
-
545
- const userStore = store({
546
- name: "user",
547
- state: {
548
- firstName: "",
549
- lastName: "",
550
- fullName: "",
551
- },
552
- setup({ state }) {
553
- // Effect runs when firstName or lastName changes
554
- effect(() => {
555
- state.fullName = `${state.firstName} ${state.lastName}`.trim();
556
- });
557
-
558
- return {
559
- setFirstName: (name: string) => {
560
- state.firstName = name;
561
- },
562
- setLastName: (name: string) => {
563
- state.lastName = name;
564
- },
565
- };
566
- },
567
- });
568
- ```
569
-
570
- **Use case:** Computed/derived state that should stay in sync with source data.
571
-
572
- **What Storion does:**
573
-
574
- 1. Runs the effect function immediately
575
- 2. Tracks every state read during execution (`firstName`, `lastName`)
576
- 3. When any tracked value changes, re-runs the effect
577
- 4. The effect updates `fullName`, which notifies its own subscribers
578
-
579
- ### Effect with Cleanup
580
-
581
- ```ts
582
- const syncStore = store({
583
- name: "sync",
584
- state: {
585
- userId: null as string | null,
586
- status: "idle" as "idle" | "connected" | "error",
587
- },
588
- setup({ state }) {
589
- effect((ctx) => {
590
- if (!state.userId) return;
591
-
592
- const ws = new WebSocket(`/ws?user=${state.userId}`);
593
- state.status = "connected";
594
-
595
- // Cleanup runs before next effect or on dispose
596
- ctx.onCleanup(() => {
597
- ws.close();
598
- state.status = "idle";
599
- });
600
- });
601
-
602
- return {
603
- login: (id: string) => {
604
- state.userId = id;
605
- },
606
- logout: () => {
607
- state.userId = null;
608
- },
609
- };
610
- },
611
- });
612
- ```
613
-
614
- **Use case:** Managing resources like WebSocket connections, event listeners, or timers.
615
-
616
- **What Storion does:**
617
-
618
- 1. Runs effect when `userId` changes
619
- 2. Before re-running, calls the cleanup function from the previous run
620
- 3. When the store is disposed, calls cleanup one final time
621
- 4. This prevents resource leaks
622
-
623
- ### Effect with Async Operations
624
-
625
- Effects must be synchronous, but you can handle async operations safely:
626
-
627
- ```ts
628
- effect((ctx) => {
629
- const userId = state.userId;
630
- if (!userId) return;
631
-
632
- // ctx.safe() wraps a promise to ignore stale results
633
- ctx.safe(fetchUserData(userId)).then((data) => {
634
- // Only runs if this effect is still current
635
- state.userData = data;
636
- });
637
-
638
- // Or use abort signal for fetch
639
- fetch(`/api/user/${userId}`, { signal: ctx.signal })
640
- .then((res) => res.json())
641
- .then((data) => {
642
- state.userData = data;
643
- });
644
- });
645
- ```
646
-
647
- **Use case:** Data fetching that should be cancelled when dependencies change.
648
-
649
- **What Storion does:**
650
-
651
- 1. `ctx.safe()` wraps the promise in a guard
652
- 2. If the effect re-runs before the promise resolves, the guard prevents the callback from executing
653
- 3. `ctx.signal` is an AbortSignal that aborts when the effect re-runs
654
- 4. This prevents race conditions and stale data updates
655
-
656
- ### Manual Effect Refresh
657
-
658
- ```ts
659
- effect((ctx) => {
660
- // From async code
661
- setTimeout(() => {
662
- ctx.refresh(); // Triggers a re-run
663
- }, 1000);
664
-
665
- // Or by returning ctx.refresh
666
- if (needsAnotherRun) {
667
- return ctx.refresh;
668
- }
669
- });
670
- ```
671
-
672
- **Important:** You cannot call `ctx.refresh()` synchronously during effect execution. This throws an error to prevent infinite loops.
673
-
674
- ---
675
-
676
- ## Async State Management
677
-
678
- Storion provides helpers for managing async operations with loading, error, and success states.
87
+ ## Async State
679
88
 
680
- ### Defining Async State
681
-
682
- ```ts
683
- import { store } from "storion";
89
+ ```tsx
684
90
  import { async } from "storion/async";
685
91
 
686
- interface User {
687
- id: string;
688
- name: string;
689
- }
690
-
691
92
  const userStore = store({
692
93
  name: "user",
693
- state: {
694
- // Fresh mode: data is undefined during loading
695
- currentUser: async.fresh<User>(),
696
-
697
- // Stale mode: preserves previous data during loading (SWR pattern)
698
- userList: async.stale<User[]>([]),
699
- },
94
+ state: { user: async.fresh<User>() },
700
95
  setup({ focus }) {
701
- const currentUserAsync = async(
702
- focus("currentUser"),
703
- async (ctx, userId: string) => {
704
- const res = await fetch(`/api/users/${userId}`, { signal: ctx.signal });
705
- return res.json();
706
- },
707
- {
708
- retry: { count: 3, delay: (attempt) => attempt * 1000 },
709
- onError: (err) => console.error("Failed:", err),
710
- }
711
- );
96
+ const userQuery = async.action(focus("user"), async (ctx, id: string) => {
97
+ const res = await fetch(`/api/users/${id}`, { signal: ctx.signal });
98
+ return res.json();
99
+ });
712
100
 
713
- return {
714
- fetchUser: currentUserAsync.dispatch,
715
- cancelFetch: currentUserAsync.cancel,
716
- refreshUser: currentUserAsync.refresh,
717
- };
101
+ return { fetchUser: userQuery.dispatch };
718
102
  },
719
103
  });
720
104
  ```
721
105
 
722
- **Use case:** API calls, data fetching, any async operation that needs loading/error states.
723
-
724
- **What Storion does:**
725
-
726
- 1. `async.fresh<User>()` creates initial state: `{ status: "idle", data: undefined, error: undefined }`
727
- 2. When `dispatch()` is called:
728
- - Sets status to `"pending"`
729
- - In fresh mode, clears data; in stale mode, keeps previous data
730
- 3. When the promise resolves:
731
- - Sets status to `"success"` and stores the data
732
- 4. When the promise rejects:
733
- - Sets status to `"error"` and stores the error
734
- 5. If `cancel()` is called, aborts the request via `ctx.signal`
735
-
736
- ### Consuming Async State
737
-
738
- ```tsx
739
- function UserProfile() {
740
- const { user, fetchUser } = useStore(({ get }) => {
741
- const [state, actions] = get(userStore);
742
- return { user: state.currentUser, fetchUser: actions.fetchUser };
743
- });
744
-
745
- useEffect(() => {
746
- fetchUser("123");
747
- }, []);
748
-
749
- if (user.status === "pending") return <Spinner />;
750
- if (user.status === "error") return <Error message={user.error.message} />;
751
- if (user.status === "idle") return null;
752
-
753
- return <div>{user.data.name}</div>;
754
- }
755
- ```
756
-
757
- ### Async State with Suspense
106
+ ## Learn More
758
107
 
759
- ```tsx
760
- import { trigger } from "storion";
761
- import { async } from "storion/async";
762
- import { useStore } from "storion/react";
763
- import { Suspense } from "react";
764
-
765
- function UserProfile() {
766
- const { user } = useStore(({ get }) => {
767
- const [state, actions] = get(userStore);
108
+ 📚 **[Full Documentation](https://linq2js.github.io/storion/)** — Guides, examples, and API reference
768
109
 
769
- // Trigger fetch on mount
770
- trigger(actions.fetchUser, [], "123");
771
-
772
- return {
773
- // async.wait() throws if pending (triggers Suspense)
774
- user: async.wait(state.currentUser),
775
- };
776
- });
777
-
778
- // Only renders when data is ready
779
- return <div>{user.name}</div>;
780
- }
781
-
782
- function App() {
783
- return (
784
- <Suspense fallback={<Spinner />}>
785
- <UserProfile />
786
- </Suspense>
787
- );
788
- }
789
- ```
790
-
791
- **What Storion does:**
792
-
793
- 1. `async.wait()` checks the async state's status
794
- 2. If `"pending"`, throws a promise that React Suspense catches
795
- 3. If `"error"`, throws the error for ErrorBoundary to catch
796
- 4. If `"success"`, returns the data
797
- 5. When the data arrives, Suspense re-renders the component
798
-
799
- ### Derived Async State
800
-
801
- ```ts
802
- const dashboardStore = store({
803
- name: "dashboard",
804
- state: {
805
- user: async.fresh<User>(),
806
- posts: async.fresh<Post[]>(),
807
- summary: async.fresh<{ name: string; postCount: number }>(),
808
- },
809
- setup({ state, focus }) {
810
- // ... async actions for user and posts ...
811
-
812
- // Option 1: Using async.all() - simpler for multiple sources
813
- async.derive(focus("summary"), () => {
814
- const [user, posts] = async.all(state.user, state.posts);
815
- return { name: user.name, postCount: posts.length };
816
- });
817
-
818
- // Option 2: Using async.wait() - more control for conditional logic
819
- async.derive(focus("summary"), () => {
820
- const user = async.wait(state.user);
821
- const posts = async.wait(state.posts);
822
- return { name: user.name, postCount: posts.length };
823
- });
824
-
825
- return {
826
- /* actions */
827
- };
828
- },
829
- });
830
- ```
831
-
832
- **Use case:** Computing a value from multiple async sources.
833
-
834
- **What Storion does:**
835
-
836
- 1. Runs the derive function and tracks dependencies
837
- 2. If any source is pending/error, the derived state mirrors that status
838
- 3. If all sources are ready, computes and stores the result
839
- 4. Re-runs automatically when source states change
840
-
841
- **When to use each approach:**
842
-
843
- | Approach | Best for |
844
- | -------------- | ----------------------------------------------------- |
845
- | `async.all()` | Waiting for multiple sources at once (cleaner syntax) |
846
- | `async.wait()` | Conditional logic where you may not need all sources |
847
-
848
- ---
849
-
850
- ## Using Stores in React
851
-
852
- ### useStore Hook
853
-
854
- ```tsx
855
- import { useStore } from "storion/react";
856
- import { trigger } from "storion";
857
-
858
- function Component() {
859
- const { count, inc, user } = useStore(({ get, id }) => {
860
- const [counterState, counterActions] = get(counterStore);
861
- const [userState, userActions] = get(userStore);
862
-
863
- // Trigger immediately (empty deps = once)
864
- trigger(userActions.fetchProfile, []); // OR trigger(userActions.fetchProfile);
865
-
866
- // Trigger on each component mount (id is unique per mount)
867
- trigger(userActions.refresh, [id]);
868
-
869
- return {
870
- count: counterState.count,
871
- inc: counterActions.inc,
872
- user: userState.profile,
873
- };
874
- });
875
-
876
- return <div>...</div>;
877
- }
878
- ```
879
-
880
- **Selector context provides:**
881
-
882
- | Property | Description |
883
- | -------------------------- | ---------------------------------------------- |
884
- | `get(store)` | Get store instance, returns `[state, actions]` |
885
- | `get(service)` | Get service instance (cached) |
886
- | `create(service, ...args)` | Create fresh service instance with args |
887
- | `id` | Unique ID per component mount |
888
- | `once(fn)` | Run function once on mount |
889
-
890
- **Global function `trigger()`** — Call a function when dependencies change (import from `"storion"`).
891
-
892
- ### Stable Function Wrapping
893
-
894
- Functions returned from `useStore` are automatically wrapped with stable references. This means:
895
-
896
- - The function reference never changes between renders
897
- - The function always accesses the latest props and state
898
- - Safe to pass to child components without causing re-renders
899
-
900
- ```tsx
901
- import { useStore } from "storion/react";
902
-
903
- function SearchForm({ userId }: { userId: string }) {
904
- const [query, setQuery] = useState("");
905
-
906
- const { search, results } = useStore(({ get }) => {
907
- const [state, actions] = get(searchStore);
908
-
909
- return {
910
- results: state.results,
911
- // This function is auto-wrapped with stable reference
912
- search: () => {
913
- // Always has access to current query and userId
914
- actions.performSearch(query, userId);
915
- },
916
- };
917
- });
918
-
919
- return (
920
- <div>
921
- <input value={query} onChange={(e) => setQuery(e.target.value)} />
922
- {/* search reference is stable - won't cause Button to re-render */}
923
- <Button onClick={search}>Search</Button>
924
- <Results items={results} />
925
- </div>
926
- );
927
- }
928
-
929
- // Button only re-renders when its own props change
930
- const Button = memo(({ onClick, children }) => (
931
- <button onClick={onClick}>{children}</button>
932
- ));
933
- ```
934
-
935
- **Use case:** Creating callbacks that depend on component state/props but need stable references for `memo`, `useCallback` dependencies, or child component optimization.
936
-
937
- **What Storion does:**
938
-
939
- 1. Detects functions in the selector's return value
940
- 2. Wraps each function with a stable reference (created once)
941
- 3. When the wrapped function is called, it executes the latest version from the selector
942
- 4. Component state (`query`) and props (`userId`) are always current when the function runs
943
-
944
- **Why this matters:**
945
-
946
- ```tsx
947
- // ❌ Without stable wrapping - new reference every render
948
- const search = () => actions.search(query); // Changes every render!
949
-
950
- // ❌ Manual useCallback - verbose and easy to forget deps
951
- const search = useCallback(() => actions.search(query), [query, actions]);
952
-
953
- // ✅ With useStore - stable reference, always current values
954
- const { search } = useStore(({ get }) => ({
955
- search: () => actions.search(query), // Stable reference!
956
- }));
957
- ```
958
-
959
- ### Trigger Patterns
960
-
961
- ```tsx
962
- import { trigger } from "storion";
963
- import { useStore } from "storion/react";
964
-
965
- function Dashboard({ categoryId }: { categoryId: string }) {
966
- const { data } = useStore(({ get, id }) => {
967
- const [state, actions] = get(dataStore);
968
-
969
- // Pattern 1: Fetch once ever (empty deps)
970
- trigger(actions.fetchOnce, []);
971
-
972
- // Pattern 2: Fetch every mount (id changes each mount)
973
- trigger(actions.fetchEveryVisit, [id]);
974
-
975
- // Pattern 3: Fetch when prop changes
976
- trigger(actions.fetchByCategory, [categoryId], categoryId);
977
-
978
- return { data: state.data };
979
- });
980
- }
981
- ```
982
-
983
- **What Storion does:**
984
-
985
- 1. `trigger()` compares current deps with previous deps
986
- 2. If deps changed (or first render), calls the function with provided args
987
- 3. Empty deps `[]` means "call once and never again"
988
- 4. `[id]` means "call every time component mounts" (id is unique per mount)
989
-
990
- **Comparison with React Query / Apollo:**
991
-
992
- | Storion | React Query | Apollo | Behavior |
993
- | ------------------------------------------ | ----------------------------------------- | ------------------------------------------------ | ----------------------------- |
994
- | `trigger(fetch, [])` | `useQuery()` | `useQuery()` | Auto-fetch on mount |
995
- | `trigger(fetch, [id])` | `useQuery({ refetchOnMount: 'always' })` | `useQuery({ fetchPolicy: 'network-only' })` | Fetch every mount |
996
- | `trigger(fetch, [categoryId], categoryId)` | `useQuery({ variables: { categoryId } })` | `useQuery(QUERY, { variables: { categoryId } })` | Refetch when variable changes |
997
- | Manual `dispatch()` | `useLazyQuery()` | `useLazyQuery()` | Fetch on user action |
998
-
999
- ```tsx
1000
- // Auto-fetch (like useQuery in React Query / Apollo)
1001
- function UserProfile() {
1002
- const { user } = useStore(({ get }) => {
1003
- const [state, actions] = get(userStore);
1004
- trigger(actions.fetchUser, []); // Fetches automatically on mount
1005
- return { user: state.user };
1006
- });
1007
- }
1008
-
1009
- // Lazy fetch (like useLazyQuery in React Query / Apollo)
1010
- function SearchResults() {
1011
- const { results, search } = useStore(({ get }) => {
1012
- const [state, actions] = get(searchStore);
1013
- // No trigger - user controls when to fetch
1014
- return { results: state.results, search: actions.search };
1015
- });
1016
-
1017
- return (
1018
- <div>
1019
- <button onClick={() => search("query")}>Search</button>
1020
- {/* results shown after user clicks */}
1021
- </div>
1022
- );
1023
- }
1024
- ```
1025
-
1026
- ### Fine-Grained Updates with pick()
1027
-
1028
- ```tsx
1029
- import { pick } from "storion";
1030
-
1031
- function UserName() {
1032
- const { name, fullName } = useStore(({ get }) => {
1033
- const [state] = get(userStore);
1034
- return {
1035
- // Re-renders ONLY when this specific value changes
1036
- name: pick(() => state.profile.name),
1037
-
1038
- // Computed values are tracked the same way
1039
- fullName: pick(() => `${state.profile.first} ${state.profile.last}`),
1040
- };
1041
- });
1042
-
1043
- return <span>{fullName}</span>;
1044
- }
1045
- ```
1046
-
1047
- **Use case:** When you need even more precise control over re-renders.
1048
-
1049
- **Without pick():** Component re-renders when `state.profile` reference changes (even if `name` didn't change).
1050
-
1051
- **With pick():** Component only re-renders when the picked value actually changes.
1052
-
1053
- **pick() equality options:**
1054
-
1055
- ```tsx
1056
- const result = useStore(({ get }) => {
1057
- const [state] = get(mapStore);
1058
- return {
1059
- // Default: strict equality (===)
1060
- x: pick(() => state.coords.x),
1061
-
1062
- // Shallow: compare object properties one level deep
1063
- coords: pick(() => state.coords, "shallow"),
1064
-
1065
- // Deep: recursive comparison
1066
- settings: pick(() => state.settings, "deep"),
1067
-
1068
- // Custom: provide your own function
1069
- ids: pick(
1070
- () => state.items.map((i) => i.id),
1071
- (a, b) => a.length === b.length && a.every((v, i) => v === b[i])
1072
- ),
1073
- };
1074
- });
1075
- ```
1076
-
1077
- ---
1078
-
1079
- ## API Reference
1080
-
1081
- ### store(options)
1082
-
1083
- Creates a store specification.
1084
-
1085
- ```ts
1086
- import { store } from "storion";
1087
-
1088
- const myStore = store({
1089
- name: "myStore",
1090
- state: { count: 0 },
1091
- setup({ state, update, focus, get, create, onDispose }) {
1092
- return {
1093
- inc: () => state.count++,
1094
- };
1095
- },
1096
- });
1097
- ```
1098
-
1099
- **Options:**
1100
-
1101
- | Option | Type | Description |
1102
- | ------------ | ------------------------------ | ------------------------------------------- |
1103
- | `name` | `string` | Display name for debugging |
1104
- | `state` | `TState` | Initial state object |
1105
- | `setup` | `(ctx) => TActions` | Setup function, returns actions |
1106
- | `lifetime` | `"singleton" \| "autoDispose"` | Instance lifecycle (default: `"singleton"`) |
1107
- | `equality` | `Equality \| EqualityMap` | Custom equality for state comparisons |
1108
- | `onDispatch` | `(event) => void` | Called when any action is dispatched |
1109
- | `onError` | `(error) => void` | Called when an error occurs |
1110
-
1111
- **Setup context:**
1112
-
1113
- | Property | Description |
1114
- | -------------------------- | --------------------------------------- |
1115
- | `state` | Reactive state (first-level props only) |
1116
- | `update(fn)` | Immer-style update for nested state |
1117
- | `focus(path)` | Create getter/setter for a path |
1118
- | `get(spec)` | Get dependency (store or service) |
1119
- | `create(factory, ...args)` | Create fresh instance |
1120
- | `dirty(prop?)` | Check if state has changed |
1121
- | `reset()` | Reset to initial state |
1122
- | `onDispose(fn)` | Register cleanup function |
1123
-
1124
- ### container(options?)
1125
-
1126
- Creates a container for managing store and service instances.
1127
-
1128
- ```ts
1129
- import { container } from "storion";
1130
-
1131
- const app = container({
1132
- middleware: myMiddleware,
1133
- });
1134
-
1135
- // Get store instance
1136
- const { state, actions } = app.get(userStore);
1137
-
1138
- // Get service instance
1139
- const api = app.get(apiService);
1140
-
1141
- // Create with parameters
1142
- const logger = app.create(loggerService, "myNamespace");
1143
-
1144
- // Lifecycle
1145
- app.delete(userStore); // Remove specific instance
1146
- app.clear(); // Clear all instances
1147
- app.dispose(); // Dispose container and cleanup
1148
- ```
1149
-
1150
- **Methods:**
1151
-
1152
- | Method | Description |
1153
- | -------------------------- | ------------------------------------- |
1154
- | `get(spec)` | Get or create cached instance |
1155
- | `create(factory, ...args)` | Create fresh instance (not cached) |
1156
- | `set(spec, factory)` | Override factory (useful for testing) |
1157
- | `delete(spec)` | Remove cached instance |
1158
- | `clear()` | Clear all cached instances |
1159
- | `dispose()` | Dispose container and all instances |
1160
-
1161
- ### effect(fn, options?)
1162
-
1163
- Creates a reactive effect.
1164
-
1165
- ```ts
1166
- import { effect } from "storion";
1167
-
1168
- const cleanup = effect((ctx) => {
1169
- console.log("Count:", state.count);
1170
-
1171
- ctx.onCleanup(() => {
1172
- console.log("Cleaning up...");
1173
- });
1174
- });
1175
-
1176
- // Later: stop the effect
1177
- cleanup();
1178
- ```
1179
-
1180
- **Context properties:**
1181
-
1182
- | Property | Description |
1183
- | --------------- | ------------------------------------ |
1184
- | `onCleanup(fn)` | Register cleanup function |
1185
- | `safe(promise)` | Wrap promise to ignore stale results |
1186
- | `signal` | AbortSignal for fetch cancellation |
1187
- | `refresh()` | Manually trigger re-run (async only) |
1188
-
1189
- **Options:**
1190
-
1191
- | Option | Type | Description |
1192
- | ------------ | -------- | ------------------- |
1193
- | `debugLabel` | `string` | Label for debugging |
1194
-
1195
- ### async(focus, handler, options?)
1196
-
1197
- Creates async state management.
1198
-
1199
- ```ts
1200
- import { async } from "storion/async";
1201
-
1202
- const userAsync = async(
1203
- focus("user"),
1204
- async (ctx, userId: string) => {
1205
- const res = await fetch(`/api/users/${userId}`, { signal: ctx.signal });
1206
- return res.json();
1207
- },
1208
- {
1209
- retry: { count: 3, delay: 1000 },
1210
- onError: (error) => console.error("Failed:", error),
1211
- autoCancel: true, // Cancel previous request on new dispatch (default)
1212
- }
1213
- );
1214
-
1215
- // Actions
1216
- userAsync.dispatch("123"); // Start async operation
1217
- userAsync.cancel(); // Cancel current operation
1218
- userAsync.refresh(); // Refetch with same args
1219
- userAsync.reset(); // Reset to initial state
1220
- ```
1221
-
1222
- **Options:**
1223
-
1224
- | Option | Type | Description |
1225
- | ------------- | ------------------------------- | --------------------------------------------------------- |
1226
- | `retry` | `number \| AsyncRetryOptions` | Retry configuration |
1227
- | `retry.count` | `number` | Number of retry attempts |
1228
- | `retry.delay` | `number \| (attempt) => number` | Delay between retries (ms) |
1229
- | `onError` | `(error) => void` | Called on error |
1230
- | `autoCancel` | `boolean` | Cancel previous request on new dispatch (default: `true`) |
1231
-
1232
- **Async helpers:**
1233
-
1234
- ```ts
1235
- // Initial state creators
1236
- async.fresh<T>(); // Fresh mode: data undefined during loading
1237
- async.stale<T>(initial); // Stale mode: preserves data during loading
1238
-
1239
- // State extractors (Suspense-compatible)
1240
- async.wait(state); // Get data or throw
1241
- async.all(...states); // Wait for all, return tuple
1242
- async.any(...states); // Get first successful
1243
- async.race(states); // Get fastest
1244
-
1245
- // State checks (non-throwing)
1246
- async.hasData(state); // boolean
1247
- async.isLoading(state); // boolean
1248
- async.isError(state); // boolean
1249
-
1250
- // Derived state
1251
- async.derive(focus, () => {
1252
- const a = async.wait(state.a);
1253
- const b = async.wait(state.b);
1254
- return computeResult(a, b);
1255
- });
1256
- ```
1257
-
1258
- **How `async.wait()` handles each state:**
1259
-
1260
- | Status | Fresh Mode | Stale Mode |
1261
- | --------- | ------------------------------ | --------------------- |
1262
- | `idle` | ❌ Throws `AsyncNotReadyError` | ✅ Returns stale data |
1263
- | `pending` | ❌ Throws promise (Suspense) | ✅ Returns stale data |
1264
- | `success` | ✅ Returns data | ✅ Returns data |
1265
- | `error` | ❌ Throws error | ✅ Returns stale data |
1266
-
1267
- **Key insight:** In **stale mode**, `async.wait()` always returns the stale data (even during idle/pending/error), so your UI can show previous data while loading. In **fresh mode**, it throws until data is ready.
1268
-
1269
- ```tsx
1270
- // Fresh mode - throws on idle, must trigger fetch first
1271
- const freshState = async.fresh<User>();
1272
- async.wait(freshState); // ❌ Throws "Cannot wait: state is idle"
1273
-
1274
- // Stale mode - returns initial data immediately
1275
- const staleState = async.stale<User[]>([]);
1276
- async.wait(staleState); // ✅ Returns [] (the initial data)
1277
- ```
1278
-
1279
- **`async.all()` follows the same rules** — it calls `async.wait()` on each state:
1280
-
1281
- ```tsx
1282
- // All stale mode - returns immediately with stale data
1283
- const [users, posts] = async.all(
1284
- async.stale<User[]>([]), // Returns []
1285
- async.stale<Post[]>([]) // Returns []
1286
- );
1287
-
1288
- // Mixed mode - throws if any fresh state is not ready
1289
- const [user, posts] = async.all(
1290
- async.fresh<User>(), // ❌ Throws - idle fresh state
1291
- async.stale<Post[]>([])
1292
- );
1293
- ```
1294
-
1295
- ### pick(fn, equality?)
1296
-
1297
- Fine-grained value tracking.
1298
-
1299
- ```ts
1300
- import { pick } from "storion";
1301
-
1302
- // In selector
1303
- const name = pick(() => state.profile.name);
1304
- const coords = pick(() => state.coords, "shallow");
1305
- const config = pick(() => state.config, "deep");
1306
- const custom = pick(
1307
- () => state.ids,
1308
- (a, b) => arraysEqual(a, b)
1309
- );
1310
- ```
1311
-
1312
- **Equality options:**
1313
-
1314
- | Value | Description |
1315
- | ------------------- | --------------------------------- |
1316
- | (none) | Strict equality (`===`) |
1317
- | `"shallow"` | Compare properties one level deep |
1318
- | `"deep"` | Recursive comparison |
1319
- | `(a, b) => boolean` | Custom comparison function |
1320
-
1321
- ### batch(fn)
1322
-
1323
- Batch multiple mutations into one notification.
1324
-
1325
- ```ts
1326
- import { batch } from "storion";
1327
-
1328
- batch(() => {
1329
- state.x = 1;
1330
- state.y = 2;
1331
- state.z = 3;
1332
- });
1333
- // Subscribers notified once, not three times
1334
- ```
1335
-
1336
- ### untrack(fn)
1337
-
1338
- Read state without tracking dependencies.
1339
-
1340
- ```ts
1341
- import { untrack } from "storion";
1342
-
1343
- effect(() => {
1344
- const count = state.count; // Tracked
1345
-
1346
- const name = untrack(() => state.name); // Not tracked
1347
-
1348
- console.log(count, name);
1349
- });
1350
- // Effect only re-runs when count changes, not when name changes
1351
- ```
1352
-
1353
- ---
1354
-
1355
- ## Advanced Patterns
1356
-
1357
- ### Middleware
1358
-
1359
- Middleware intercepts store creation for cross-cutting concerns.
1360
-
1361
- ```ts
1362
- import { container, compose, applyFor, applyExcept } from "storion";
1363
- import type { StoreMiddleware } from "storion";
1364
-
1365
- // Simple middleware
1366
- const loggingMiddleware: StoreMiddleware = (ctx) => {
1367
- console.log(`Creating: ${ctx.displayName}`);
1368
- const instance = ctx.next();
1369
- console.log(`Created: ${instance.id}`);
1370
- return instance;
1371
- };
1372
-
1373
- // Middleware with store-specific logic
1374
- const persistMiddleware: StoreMiddleware = (ctx) => {
1375
- const instance = ctx.next();
1376
-
1377
- if (ctx.spec.options.meta?.persist) {
1378
- // Add persistence logic
1379
- }
1380
-
1381
- return instance;
1382
- };
1383
-
1384
- // Apply single middleware
1385
- const app = container({
1386
- middleware: loggingMiddleware,
1387
- });
1388
-
1389
- // Apply multiple middlewares (array)
1390
- const app = container({
1391
- middleware: [
1392
- // Apply to stores starting with "user"
1393
- applyFor("user*", loggingMiddleware),
1394
-
1395
- // Apply except to cache stores
1396
- applyExcept("*Cache", persistMiddleware),
1397
-
1398
- // Apply to specific stores
1399
- applyFor(["authStore", "settingsStore"], loggingMiddleware),
1400
-
1401
- // Apply based on condition
1402
- applyFor((ctx) => ctx.spec.options.meta?.debug === true, loggingMiddleware),
1403
- ],
1404
- });
1405
- ```
1406
-
1407
- **Pattern matching:**
1408
-
1409
- | Pattern | Matches |
1410
- | ------------------ | ---------------------- |
1411
- | `"user*"` | Starts with "user" |
1412
- | `"*Store"` | Ends with "Store" |
1413
- | `["a", "b"]` | Exact match "a" or "b" |
1414
- | `(ctx) => boolean` | Custom predicate |
1415
-
1416
- ### Parameterized Services
1417
-
1418
- For services that need configuration:
1419
-
1420
- ```ts
1421
- // Parameterized service factory
1422
- function dbService(resolver, config: { host: string; port: number }) {
1423
- return {
1424
- query: (sql: string) =>
1425
- fetch(`http://${config.host}:${config.port}/query`, {
1426
- method: "POST",
1427
- body: sql,
1428
- }),
1429
- };
1430
- }
1431
-
1432
- // Use with create() instead of get()
1433
- const myStore = store({
1434
- name: "data",
1435
- state: { items: [] },
1436
- setup({ create }) {
1437
- // create() always makes a fresh instance and accepts args
1438
- const db = create(dbService, { host: "localhost", port: 5432 });
1439
-
1440
- return {
1441
- fetchItems: async () => {
1442
- return db.query("SELECT * FROM items");
1443
- },
1444
- };
1445
- },
1446
- });
1447
- ```
1448
-
1449
- **get() vs create():**
1450
-
1451
- | Aspect | `get()` | `create()` |
1452
- | --------- | --------------- | -------------------- |
1453
- | Caching | Yes (singleton) | No (always fresh) |
1454
- | Arguments | None | Supports extra args |
1455
- | Use case | Shared services | Configured instances |
1456
-
1457
- ### Mixins (Reusable Logic)
1458
-
1459
- Mixins let you compose reusable logic across stores and selectors.
1460
-
1461
- **Store Mixin — reusable actions:**
1462
-
1463
- ```ts
1464
- import { store, type StoreContext } from "storion";
1465
-
1466
- // Define a reusable mixin
1467
- const counterMixin = (ctx: StoreContext<{ count: number }>) => ({
1468
- increment: () => ctx.state.count++,
1469
- decrement: () => ctx.state.count--,
1470
- reset: () => ctx.reset(),
1471
- });
1472
-
1473
- // Use in multiple stores
1474
- const store1 = store({
1475
- name: "counter1",
1476
- state: { count: 0, label: "Counter 1" },
1477
- setup: (ctx) => ({
1478
- ...ctx.mixin(counterMixin),
1479
- setLabel: (label: string) => (ctx.state.label = label),
1480
- }),
1481
- });
1482
-
1483
- const store2 = store({
1484
- name: "counter2",
1485
- state: { count: 100 },
1486
- setup: (ctx) => ctx.mixin(counterMixin),
1487
- });
1488
- ```
1489
-
1490
- **Selector Mixin — reusable selector logic:**
1491
-
1492
- ```tsx
1493
- import { useStore, type SelectorContext } from "storion/react";
1494
-
1495
- // Define a reusable selector mixin
1496
- const sumMixin = (
1497
- ctx: SelectorContext,
1498
- stores: StoreSpec<{ value: number }>[]
1499
- ) => {
1500
- return stores.reduce((sum, spec) => {
1501
- const [state] = ctx.get(spec);
1502
- return sum + state.value;
1503
- }, 0);
1504
- };
1505
-
1506
- function Dashboard() {
1507
- const { total } = useStore((ctx) => ({
1508
- total: ctx.mixin(sumMixin, [store1, store2, store3]),
1509
- }));
1510
-
1511
- return <div>Total: {total}</div>;
1512
- }
1513
- ```
1514
-
1515
- **Important: Mixins are NOT singletons**
1516
-
1517
- Each call to `mixin()` creates a fresh instance. If you need singleton behavior **within the same store/selector scope**, wrap with memoize:
1518
-
1519
- ```ts
1520
- import memoize from "lodash/memoize";
1521
- import { store, type StoreContext } from "storion";
1522
-
1523
- // Shared mixin - memoized to be singleton within same store setup
1524
- const sharedLogicMixin = memoize((ctx: StoreContext<any>) => {
1525
- console.log("sharedLogicMixin created"); // Only logs once per store!
1526
- return {
1527
- doSomething: () => console.log("shared logic"),
1528
- };
1529
- });
1530
-
1531
- // Feature A mixin - uses sharedLogicMixin
1532
- const featureAMixin = (ctx: StoreContext<any>) => {
1533
- const shared = ctx.mixin(sharedLogicMixin); // Gets cached instance
1534
- return {
1535
- featureA: () => shared.doSomething(),
1536
- };
1537
- };
1538
-
1539
- // Feature B mixin - also uses sharedLogicMixin
1540
- const featureBMixin = (ctx: StoreContext<any>) => {
1541
- const shared = ctx.mixin(sharedLogicMixin); // Gets SAME cached instance
1542
- return {
1543
- featureB: () => shared.doSomething(),
1544
- };
1545
- };
1546
-
1547
- // Main store - composes both features
1548
- const myStore = store({
1549
- name: "myStore",
1550
- state: { value: 0 },
1551
- setup: (ctx) => {
1552
- const featureA = ctx.mixin(featureAMixin);
1553
- const featureB = ctx.mixin(featureBMixin);
1554
-
1555
- // Both features share the same sharedLogicMixin instance!
1556
- // featureA.featureA and featureB.featureB call the same shared.doSomething
1557
-
1558
- return { ...featureA, ...featureB };
1559
- },
1560
- });
1561
- ```
1562
-
1563
- **What happens:**
1564
-
1565
- 1. `featureAMixin` calls `mixin(sharedLogicMixin)` → creates instance, memoize caches it
1566
- 2. `featureBMixin` calls `mixin(sharedLogicMixin)` → returns cached instance
1567
- 3. Both features share the same `sharedLogicMixin` instance within this store
1568
-
1569
- **When to use mixin vs service:**
1570
-
1571
- | Pattern | Caching | Access to context | Use case |
1572
- | -------------------- | ------------------------- | ---------------------- | ----------------------------------- |
1573
- | `get(service)` | ✅ Global singleton | ❌ No StoreContext | Shared utilities, API clients |
1574
- | `mixin(fn)` | ❌ Fresh each call | ✅ Full context access | Reusable actions, computed values |
1575
- | `mixin(memoize(fn))` | ✅ Singleton (same scope) | ✅ Full context access | Shared logic across multiple mixins |
1576
-
1577
- ### Equality Strategies
1578
-
1579
- Storion supports equality checks at **two levels**, giving you fine-grained control over when updates happen.
1580
-
1581
- **Comparison with other libraries:**
1582
-
1583
- | Library | Store-level equality | Selector-level equality |
1584
- | ----------- | -------------------- | -------------------------------------- |
1585
- | **Redux** | ❌ No | ✅ `useSelector(selector, equalityFn)` |
1586
- | **Zustand** | ❌ No | ✅ `useStore(selector, shallow)` |
1587
- | **Jotai** | ✅ Per-atom | ❌ No |
1588
- | **MobX** | ✅ Deep by default | ❌ No (computed) |
1589
- | **Storion** | ✅ Per-property | ✅ `pick(fn, equality)` |
1590
-
1591
- **Store-level equality** — Prevents notifications when state "changes" to an equivalent value:
1592
-
1593
- ```ts
1594
- const mapStore = store({
1595
- name: "map",
1596
- state: {
1597
- coords: { x: 0, y: 0 },
1598
- markers: [] as Marker[],
1599
- settings: { zoom: 1, rotation: 0 },
1600
- },
1601
- equality: {
1602
- // Shallow: only notify if x or y actually changed
1603
- coords: "shallow",
1604
- // Deep: recursive comparison for complex objects
1605
- settings: "deep",
1606
- // Custom function
1607
- markers: (a, b) => a.length === b.length,
1608
- },
1609
- setup({ state }) {
1610
- return {
1611
- setCoords: (x: number, y: number) => {
1612
- // This creates a new object, but shallow equality
1613
- // prevents notification if x and y are the same
1614
- state.coords = { x, y };
1615
- },
1616
- };
1617
- },
1618
- });
1619
- ```
1620
-
1621
- **Selector-level equality** — Prevents re-renders when selected value hasn't changed:
1622
-
1623
- ```tsx
1624
- function MapView() {
1625
- const { x, coords, markers } = useStore(({ get }) => {
1626
- const [state] = get(mapStore);
1627
- return {
1628
- // Only re-render if x specifically changed
1629
- x: pick(() => state.coords.x),
1630
-
1631
- // Only re-render if coords object is shallow-different
1632
- coords: pick(() => state.coords, "shallow"),
1633
-
1634
- // Custom comparison at selector level
1635
- markers: pick(
1636
- () => state.markers.map((m) => m.id),
1637
- (a, b) => a.join() === b.join()
1638
- ),
1639
- };
1640
- });
1641
- }
1642
- ```
1643
-
1644
- **When to use each:**
1645
-
1646
- | Level | When it runs | Use case |
1647
- | ------------------ | --------------------- | ---------------------------------------------------- |
1648
- | **Store-level** | On every state write | Prevent unnecessary notifications to ALL subscribers |
1649
- | **Selector-level** | On every selector run | Prevent re-renders for THIS component only |
1650
-
1651
- ```
1652
- ┌─────────────────────────────────────────────────────────────────────┐
1653
- │ state.coords = { x: 1, y: 2 } │
1654
- │ │ │
1655
- │ ▼ │
1656
- │ Store-level equality: coords: "shallow" │
1657
- │ Same x and y values? → Skip notifying ALL subscribers │
1658
- │ │ │
1659
- │ ▼ (if changed) │
1660
- │ ┌─────────────────────┐ ┌─────────────────────┐ │
1661
- │ │ Component A │ │ Component B │ │
1662
- │ │ pick(() => coords.x)│ │ pick(() => coords) │ │
1663
- │ │ │ │ │ │ │ │
1664
- │ │ ▼ │ │ ▼ │ │
1665
- │ │ Re-render if x │ │ Re-render if x OR y │ │
1666
- │ │ changed │ │ changed │ │
1667
- │ └─────────────────────┘ └─────────────────────┘ │
1668
- └─────────────────────────────────────────────────────────────────────┘
1669
- ```
1670
-
1671
- **Why Storion's approach is powerful:**
1672
-
1673
- ```tsx
1674
- // Redux/Zustand - must remember to add equality every time
1675
- const coords = useSelector((state) => state.coords, shallowEqual);
1676
- const coords = useStore((state) => state.coords, shallow);
1677
-
1678
- // Storion - store-level handles common cases, pick() for fine-tuning
1679
- const mapStore = store({
1680
- equality: { coords: "shallow" }, // Set once, applies everywhere
1681
- // ...
1682
- });
1683
-
1684
- // Components can add extra precision with pick()
1685
- const x = pick(() => state.coords.x); // Even finer control
1686
- ```
1687
-
1688
- ### Testing with Mocks
1689
-
1690
- ```ts
1691
- import { container } from "storion";
1692
-
1693
- // Production code
1694
- const app = container();
1695
-
1696
- // Test setup
1697
- const testApp = container();
1698
-
1699
- // Override services with mocks
1700
- testApp.set(apiService, () => ({
1701
- get: async () => ({ id: "1", name: "Test User" }),
1702
- post: async () => ({}),
1703
- }));
1704
-
1705
- // Now stores will use the mock
1706
- const { actions } = testApp.get(userStore);
1707
- await actions.fetchUser("1"); // Uses mock apiService
1708
- ```
1709
-
1710
- ### Child Containers
1711
-
1712
- For scoped dependencies (e.g., per-request in SSR):
1713
-
1714
- ```ts
1715
- const rootApp = container();
1716
-
1717
- // Create child container with overrides
1718
- const requestApp = container({
1719
- parent: rootApp,
1720
- });
1721
-
1722
- // Child inherits from parent but can have its own instances
1723
- requestApp.set(sessionService, () => createSessionForRequest());
1724
-
1725
- // Cleanup after request
1726
- requestApp.dispose();
1727
- ```
1728
-
1729
- ### Store Lifecycle
1730
-
1731
- ```ts
1732
- const myStore = store({
1733
- name: "myStore",
1734
- lifetime: "autoDispose", // Dispose when no subscribers
1735
- state: { ... },
1736
- setup({ onDispose }) {
1737
- const interval = setInterval(() => {}, 1000);
1738
-
1739
- // Cleanup when store is disposed
1740
- onDispose(() => {
1741
- clearInterval(interval);
1742
- });
1743
-
1744
- return { ... };
1745
- },
1746
- });
1747
- ```
1748
-
1749
- **Lifetime options:**
1750
-
1751
- | Value | Behavior |
1752
- | --------------- | ------------------------------------------- |
1753
- | `"singleton"` | Lives until container is disposed (default) |
1754
- | `"autoDispose"` | Disposed when last subscriber unsubscribes |
1755
-
1756
- ### DevTools Integration
1757
-
1758
- ![Storion DevTools](https://raw.githubusercontent.com/linq2js/storion/main/packages/storion/img/image.png)
1759
-
1760
- ```ts
1761
- import { devtools } from "storion/devtools";
1762
-
1763
- const app = container({
1764
- middleware: devtools({
1765
- name: "My App",
1766
- enabled: process.env.NODE_ENV === "development",
1767
- }),
1768
- });
1769
- ```
1770
-
1771
- ```tsx
1772
- import { DevtoolsPanel } from "storion/devtools-panel";
1773
-
1774
- function App() {
1775
- return (
1776
- <>
1777
- <MyApp />
1778
- {process.env.NODE_ENV === "development" && <DevtoolsPanel />}
1779
- </>
1780
- );
1781
- }
1782
- ```
1783
-
1784
- ---
1785
-
1786
- ## Error Handling
1787
-
1788
- ### Effect Errors
1789
-
1790
- Errors in effects are caught and can be handled:
1791
-
1792
- ```ts
1793
- const myStore = store({
1794
- name: "myStore",
1795
- state: { ... },
1796
- onError: (error) => {
1797
- console.error("Store error:", error);
1798
- // Send to error tracking service
1799
- },
1800
- setup({ state }) {
1801
- effect(() => {
1802
- if (state.invalid) {
1803
- throw new Error("Invalid state!");
1804
- }
1805
- });
1806
-
1807
- return { ... };
1808
- },
1809
- });
1810
- ```
1811
-
1812
- **Important:** Even if an effect throws an error, it **still re-runs** when its tracked states change. The effect keeps its dependency tracking from before the error occurred.
1813
-
1814
- ```ts
1815
- effect(() => {
1816
- console.log("Effect running, count:", state.count); // Tracks `count`
1817
-
1818
- if (state.count > 5) {
1819
- throw new Error("Count too high!");
1820
- }
1821
- });
1822
-
1823
- // Later...
1824
- state.count = 10; // Effect re-runs, throws error, calls onError
1825
- state.count = 3; // Effect re-runs again, no error this time
1826
- state.count = 8; // Effect re-runs, throws error again
1827
- ```
1828
-
1829
- This behavior ensures that effects can recover when state returns to a valid condition.
1830
-
1831
- ### Async Errors
1832
-
1833
- ```ts
1834
- const userAsync = async(
1835
- focus("user"),
1836
- async (ctx) => {
1837
- const res = await fetch("/api/user", { signal: ctx.signal });
1838
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
1839
- return res.json();
1840
- },
1841
- {
1842
- onError: (error) => {
1843
- // Handle or log the error
1844
- },
1845
- retry: {
1846
- count: 3,
1847
- delay: (attempt) => Math.min(1000 * 2 ** attempt, 10000),
1848
- },
1849
- }
1850
- );
1851
- ```
1852
-
1853
- ### React Error Boundaries
1854
-
1855
- ```tsx
1856
- function App() {
1857
- return (
1858
- <ErrorBoundary fallback={<ErrorPage />}>
1859
- <Suspense fallback={<Spinner />}>
1860
- <UserProfile />
1861
- </Suspense>
1862
- </ErrorBoundary>
1863
- );
1864
- }
1865
-
1866
- function UserProfile() {
1867
- const { user } = useStore(({ get }) => {
1868
- const [state] = get(userStore);
1869
- // async.wait() throws on error, caught by ErrorBoundary
1870
- return { user: async.wait(state.currentUser) };
1871
- });
1872
-
1873
- return <div>{user.name}</div>;
1874
- }
1875
- ```
1876
-
1877
- ---
1878
-
1879
- ## Limitations & Anti-patterns
1880
-
1881
- ### ❌ Don't Mutate Nested State Directly
1882
-
1883
- Direct mutation only works for first-level properties:
1884
-
1885
- ```ts
1886
- // ❌ Wrong - won't trigger reactivity
1887
- state.profile.name = "John";
1888
- state.items.push("new item");
1889
-
1890
- // ✅ Correct - use update()
1891
- update((draft) => {
1892
- draft.profile.name = "John";
1893
- draft.items.push("new item");
1894
- });
1895
- ```
1896
-
1897
- ### ❌ Don't Call get() Inside Actions
1898
-
1899
- `get()` is for setup-time dependencies, not runtime:
1900
-
1901
- ```ts
1902
- // ❌ Wrong
1903
- setup({ get }) {
1904
- return {
1905
- doSomething: () => {
1906
- const [other] = get(otherStore); // Don't do this!
1907
- },
1908
- };
1909
- }
1910
-
1911
- // ✅ Correct - capture at setup time
1912
- setup({ get }) {
1913
- const [otherState, otherActions] = get(otherStore);
1914
-
1915
- return {
1916
- doSomething: () => {
1917
- // Use the captured state/actions
1918
- if (otherState.ready) { ... }
1919
- },
1920
- };
1921
- }
1922
- ```
1923
-
1924
- ### ❌ Don't Use Async Effects
1925
-
1926
- Effects must be synchronous:
1927
-
1928
- ```ts
1929
- // ❌ Wrong
1930
- effect(async (ctx) => {
1931
- const data = await fetchData();
1932
- });
1933
-
1934
- // ✅ Correct
1935
- effect((ctx) => {
1936
- ctx.safe(fetchData()).then((data) => {
1937
- state.data = data;
1938
- });
1939
- });
1940
- ```
1941
-
1942
- ### ❌ Don't Pass Anonymous Functions to trigger()
1943
-
1944
- Anonymous functions create new references on every render:
1945
-
1946
- ```ts
1947
- // ❌ Wrong - anonymous function called every render
1948
- trigger(() => {
1949
- actions.search(query);
1950
- }, [query]);
1951
-
1952
- // ✅ Correct - stable function reference
1953
- trigger(actions.search, [query], query);
1954
- ```
1955
-
1956
- ### ❌ Don't Call refresh() Synchronously
1957
-
1958
- Calling `ctx.refresh()` during effect execution throws an error:
1959
-
1960
- ```ts
1961
- // ❌ Wrong - throws error
1962
- effect((ctx) => {
1963
- ctx.refresh(); // Error!
1964
- });
1965
-
1966
- // ✅ Correct - async or return pattern
1967
- effect((ctx) => {
1968
- setTimeout(() => ctx.refresh(), 1000);
1969
- // or
1970
- return ctx.refresh;
1971
- });
1972
- ```
1973
-
1974
- ### ❌ Don't Create Stores Inside Components
1975
-
1976
- Store specs should be defined at module level:
1977
-
1978
- ```ts
1979
- // ❌ Wrong - creates new spec on every render
1980
- function Component() {
1981
- const myStore = store({ ... }); // Don't do this!
1982
- }
1983
-
1984
- // ✅ Correct - define at module level
1985
- const myStore = store({ ... });
1986
-
1987
- function Component() {
1988
- const { state } = useStore(({ get }) => get(myStore));
1989
- }
1990
- ```
1991
-
1992
- ### ❌ Don't Forget to Handle All Async States
1993
-
1994
- ```tsx
1995
- // ❌ Incomplete - misses error and idle states
1996
- function User() {
1997
- const { user } = useStore(({ get }) => {
1998
- const [state] = get(userStore);
1999
- return { user: state.currentUser };
2000
- });
2001
-
2002
- if (user.status === "pending") return <Spinner />;
2003
- return <div>{user.data.name}</div>; // Crashes if error or idle!
2004
- }
2005
-
2006
- // ✅ Complete handling
2007
- function User() {
2008
- const { user } = useStore(...);
2009
-
2010
- if (user.status === "idle") return <button>Load User</button>;
2011
- if (user.status === "pending") return <Spinner />;
2012
- if (user.status === "error") return <Error error={user.error} />;
2013
- return <div>{user.data.name}</div>;
2014
- }
2015
- ```
2016
-
2017
- ### Limitation: No Deep Property Tracking
2018
-
2019
- Storion tracks first-level property access, not deep paths:
2020
-
2021
- ```ts
2022
- // Both track "profile" property, not "profile.name"
2023
- const name1 = state.profile.name;
2024
- const name2 = state.profile.email;
2025
-
2026
- // To get finer tracking, use pick()
2027
- const name = pick(() => state.profile.name);
2028
- ```
2029
-
2030
- ### Limitation: Equality Check Timing
2031
-
2032
- Store-level equality runs on write, component-level equality runs on read:
2033
-
2034
- ```ts
2035
- // Store level - prevents notification
2036
- store({
2037
- equality: { coords: "shallow" },
2038
- setup({ state }) {
2039
- return {
2040
- setCoords: (x, y) => {
2041
- // If same x,y, no subscribers are notified
2042
- state.coords = { x, y };
2043
- },
2044
- };
2045
- },
2046
- });
2047
-
2048
- // Component level - prevents re-render
2049
- const x = pick(() => state.coords.x);
2050
- // Component only re-renders if x specifically changed
2051
- ```
2052
-
2053
- ---
2054
-
2055
- ## Contributing
2056
-
2057
- ### Prerequisites
2058
-
2059
- - Node.js 18+
2060
- - pnpm 8+
2061
-
2062
- ### Setup
2063
-
2064
- ```bash
2065
- git clone https://github.com/linq2js/storion.git
2066
- cd storion
2067
- pnpm install
2068
- pnpm --filter storion build
2069
- ```
2070
-
2071
- ### Development
2072
-
2073
- ```bash
2074
- pnpm --filter storion dev # Watch mode
2075
- pnpm --filter storion test # Run tests
2076
- pnpm --filter storion test:ui # Tests with UI
2077
- ```
2078
-
2079
- ### Commit Messages
2080
-
2081
- Use [Conventional Commits](https://www.conventionalcommits.org/):
2082
-
2083
- ```
2084
- feat(core): add new feature
2085
- fix(react): resolve hook issue
2086
- docs: update README
2087
- ```
2088
-
2089
- ### AI Assistance
2090
-
2091
- For AI coding assistants, see [AI_GUIDE.md](./AI_GUIDE.md) for rules and patterns when generating Storion code.
2092
-
2093
- ---
110
+ - [Getting Started](https://linq2js.github.io/storion/guide/getting-started.html)
111
+ - [Core Concepts](https://linq2js.github.io/storion/guide/core-concepts.html)
112
+ - [Async State](https://linq2js.github.io/storion/guide/async.html)
113
+ - [API Reference](https://linq2js.github.io/storion/api/store.html)
114
+ - [Live Demos](https://linq2js.github.io/storion/demos.html)
2094
115
 
2095
116
  ## License
2096
117