storion 0.7.4 → 0.7.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1077 -1112
- package/dist/core/storeContext.d.ts +3 -14
- package/dist/core/storeContext.d.ts.map +1 -1
- package/dist/devtools/index.js +1 -1
- package/dist/react/index.js +2 -2
- package/dist/{store-CwgUVEWv.js → store-DGyskBrf.js} +25 -19
- package/dist/storion.js +2 -2
- package/dist/types.d.ts +16 -0
- package/dist/types.d.ts.map +1 -1
- 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="#
|
|
23
|
+
<a href="#core-concepts">Core Concepts</a> •
|
|
24
24
|
<a href="#api-reference">API Reference</a> •
|
|
25
|
-
<a href="#
|
|
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
|
|
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
|
-
|
|
35
|
-
|
|
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
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
122
|
+
### Single Store (Simplest Approach)
|
|
92
123
|
|
|
93
|
-
|
|
124
|
+
Best for small apps or isolated features.
|
|
94
125
|
|
|
95
126
|
```tsx
|
|
96
127
|
import { create } from "storion/react";
|
|
97
128
|
|
|
98
|
-
|
|
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
|
|
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
|
-
|
|
128
|
-
console.log(
|
|
152
|
+
counterStore.actions.inc();
|
|
153
|
+
console.log(counterStore.state.count);
|
|
129
154
|
```
|
|
130
155
|
|
|
131
|
-
|
|
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
|
-
|
|
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
|
-
|
|
249
|
+
---
|
|
217
250
|
|
|
218
|
-
|
|
251
|
+
## Core Concepts
|
|
219
252
|
|
|
220
|
-
|
|
253
|
+
### Stores
|
|
221
254
|
|
|
222
|
-
**
|
|
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
|
-
|
|
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
|
-
|
|
432
|
+
tags: [] as string[],
|
|
232
433
|
},
|
|
233
434
|
setup({ state, update }) {
|
|
234
435
|
return {
|
|
235
|
-
//
|
|
236
|
-
|
|
237
|
-
|
|
436
|
+
// Update nested object
|
|
437
|
+
setProfileName: (name: string) => {
|
|
438
|
+
update((draft) => {
|
|
439
|
+
draft.profile.name = name;
|
|
440
|
+
});
|
|
238
441
|
},
|
|
239
442
|
|
|
240
|
-
//
|
|
241
|
-
|
|
443
|
+
// Update array
|
|
444
|
+
addTag: (tag: string) => {
|
|
242
445
|
update((draft) => {
|
|
243
|
-
draft.
|
|
446
|
+
draft.tags.push(tag);
|
|
244
447
|
});
|
|
245
448
|
},
|
|
246
449
|
|
|
247
|
-
// Batch
|
|
248
|
-
updateProfile: (
|
|
450
|
+
// Batch multiple changes
|
|
451
|
+
updateProfile: (name: string, email: string) => {
|
|
249
452
|
update((draft) => {
|
|
250
|
-
|
|
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
|
-
|
|
462
|
+
**Use case:** Any mutation to nested objects, arrays, or when you need to update multiple properties atomically.
|
|
259
463
|
|
|
260
|
-
|
|
464
|
+
**What Storion does:**
|
|
261
465
|
|
|
262
|
-
|
|
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
|
-
|
|
472
|
+
### Focus (Lens-like Access)
|
|
265
473
|
|
|
266
|
-
|
|
267
|
-
import { store } from "storion";
|
|
474
|
+
`focus()` creates a getter/setter pair for any state path:
|
|
268
475
|
|
|
269
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
495
|
+
// Computed from previous value
|
|
288
496
|
toggleTheme: () => {
|
|
289
497
|
setTheme((prev) => (prev === "light" ? "dark" : "light"));
|
|
290
498
|
},
|
|
291
499
|
|
|
292
|
-
//
|
|
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
|
|
507
|
+
// Getter for use in effects
|
|
300
508
|
getTheme,
|
|
301
509
|
};
|
|
302
510
|
},
|
|
303
511
|
});
|
|
304
512
|
```
|
|
305
513
|
|
|
306
|
-
**
|
|
514
|
+
**Use case:** When you frequently access a deep path and want cleaner code.
|
|
307
515
|
|
|
308
|
-
|
|
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(
|
|
311
|
-
| Reducer | `set(prev =>
|
|
312
|
-
|
|
|
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
|
-
|
|
533
|
+
---
|
|
315
534
|
|
|
316
|
-
|
|
535
|
+
## Reactive Effects
|
|
317
536
|
|
|
318
|
-
|
|
537
|
+
Effects are functions that run automatically when their dependencies change.
|
|
319
538
|
|
|
320
|
-
|
|
539
|
+
### Basic Effect
|
|
321
540
|
|
|
322
541
|
```ts
|
|
323
542
|
import { store, effect } from "storion";
|
|
324
543
|
|
|
325
|
-
|
|
544
|
+
const userStore = store({
|
|
326
545
|
name: "user",
|
|
327
546
|
state: {
|
|
328
547
|
firstName: "",
|
|
329
548
|
lastName: "",
|
|
330
|
-
fullName: "",
|
|
549
|
+
fullName: "",
|
|
331
550
|
},
|
|
332
551
|
setup({ state }) {
|
|
333
|
-
//
|
|
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
|
-
**
|
|
569
|
+
**Use case:** Computed/derived state that should stay in sync with source data.
|
|
351
570
|
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
592
|
+
state.status = "connected";
|
|
367
593
|
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
622
|
+
### Effect with Async Operations
|
|
445
623
|
|
|
446
|
-
|
|
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
|
|
631
|
+
// ctx.safe() wraps a promise to ignore stale results
|
|
454
632
|
ctx.safe(fetchUserData(userId)).then((data) => {
|
|
455
|
-
// Only runs if effect
|
|
633
|
+
// Only runs if this effect is still current
|
|
456
634
|
state.userData = data;
|
|
457
635
|
});
|
|
458
636
|
|
|
@@ -465,930 +643,762 @@ effect((ctx) => {
|
|
|
465
643
|
});
|
|
466
644
|
```
|
|
467
645
|
|
|
468
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
655
|
+
### Manual Effect Refresh
|
|
548
656
|
|
|
549
657
|
```ts
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
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
|
-
//
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
675
|
+
## Async State Management
|
|
613
676
|
|
|
614
|
-
|
|
677
|
+
Storion provides helpers for managing async operations with loading, error, and success states.
|
|
615
678
|
|
|
616
|
-
|
|
679
|
+
### Defining Async State
|
|
617
680
|
|
|
618
681
|
```ts
|
|
619
682
|
import { store } from "storion";
|
|
620
|
-
import { async
|
|
683
|
+
import { async } from "storion/async";
|
|
621
684
|
|
|
622
|
-
interface
|
|
685
|
+
interface User {
|
|
623
686
|
id: string;
|
|
624
687
|
name: string;
|
|
625
|
-
price: number;
|
|
626
688
|
}
|
|
627
689
|
|
|
628
|
-
|
|
629
|
-
name: "
|
|
690
|
+
const userStore = store({
|
|
691
|
+
name: "user",
|
|
630
692
|
state: {
|
|
631
693
|
// Fresh mode: data is undefined during loading
|
|
632
|
-
|
|
694
|
+
currentUser: async.fresh<User>(),
|
|
695
|
+
|
|
633
696
|
// Stale mode: preserves previous data during loading (SWR pattern)
|
|
634
|
-
|
|
697
|
+
userList: async.stale<User[]>([]),
|
|
635
698
|
},
|
|
636
699
|
setup({ focus }) {
|
|
637
|
-
const
|
|
638
|
-
focus("
|
|
639
|
-
async (ctx,
|
|
640
|
-
const res = await fetch(`/api/
|
|
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
|
|
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
|
-
|
|
661
|
-
|
|
662
|
-
|
|
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
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
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
|
-
|
|
745
|
+
fetchUser("123");
|
|
677
746
|
}, []);
|
|
678
747
|
|
|
679
|
-
if (
|
|
680
|
-
|
|
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
|
-
###
|
|
699
|
-
|
|
700
|
-
Storion provides multiple patterns for data fetching. Choose based on your use case:
|
|
701
|
-
|
|
702
|
-
| Pattern | When to use | Example |
|
|
703
|
-
| ----------------------- | ---------------------------------------------- | ---------------------------------- |
|
|
704
|
-
| **Setup time** | Data needed immediately when store initializes | App config, user session |
|
|
705
|
-
| **Trigger (no deps)** | One-time fetch when component mounts | Initial page data |
|
|
706
|
-
| **Trigger (with deps)** | Refetch when component visits or deps change | Dashboard refresh |
|
|
707
|
-
| **useEffect** | Standard React pattern, explicit control | Compatibility with existing code |
|
|
708
|
-
| **User interaction** | On-demand fetching | Search, pagination, refresh button |
|
|
756
|
+
### Async State with Suspense
|
|
709
757
|
|
|
710
758
|
```tsx
|
|
711
|
-
import {
|
|
712
|
-
import { async
|
|
759
|
+
import { trigger } from "storion";
|
|
760
|
+
import { async } from "storion/async";
|
|
713
761
|
import { useStore } from "storion/react";
|
|
714
|
-
import {
|
|
715
|
-
|
|
716
|
-
interface User {
|
|
717
|
-
id: string;
|
|
718
|
-
name: string;
|
|
719
|
-
}
|
|
762
|
+
import { Suspense } from "react";
|
|
720
763
|
|
|
721
|
-
|
|
722
|
-
|
|
764
|
+
function UserProfile() {
|
|
765
|
+
const { user } = useStore(({ get }) => {
|
|
766
|
+
const [state, actions] = get(userStore);
|
|
767
|
+
|
|
768
|
+
// Trigger fetch on mount
|
|
769
|
+
trigger(actions.fetchUser, [], "123");
|
|
770
|
+
|
|
771
|
+
return {
|
|
772
|
+
// async.wait() throws if pending (triggers Suspense)
|
|
773
|
+
user: async.wait(state.currentUser),
|
|
774
|
+
};
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
// Only renders when data is ready
|
|
778
|
+
return <div>{user.name}</div>;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function App() {
|
|
782
|
+
return (
|
|
783
|
+
<Suspense fallback={<Spinner />}>
|
|
784
|
+
<UserProfile />
|
|
785
|
+
</Suspense>
|
|
786
|
+
);
|
|
787
|
+
}
|
|
788
|
+
```
|
|
789
|
+
|
|
790
|
+
**What Storion does:**
|
|
791
|
+
|
|
792
|
+
1. `async.wait()` checks the async state's status
|
|
793
|
+
2. If `"pending"`, throws a promise that React Suspense catches
|
|
794
|
+
3. If `"error"`, throws the error for ErrorBoundary to catch
|
|
795
|
+
4. If `"success"`, returns the data
|
|
796
|
+
5. When the data arrives, Suspense re-renders the component
|
|
797
|
+
|
|
798
|
+
### Derived Async State
|
|
799
|
+
|
|
800
|
+
```ts
|
|
801
|
+
const dashboardStore = store({
|
|
802
|
+
name: "dashboard",
|
|
723
803
|
state: {
|
|
724
|
-
|
|
725
|
-
|
|
804
|
+
user: async.fresh<User>(),
|
|
805
|
+
posts: async.fresh<Post[]>(),
|
|
806
|
+
summary: async.fresh<{ name: string; postCount: number }>(),
|
|
726
807
|
},
|
|
727
|
-
setup({
|
|
728
|
-
//
|
|
729
|
-
|
|
730
|
-
//
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
const res = await fetch("/api/me", { signal: ctx.signal });
|
|
735
|
-
return res.json();
|
|
808
|
+
setup({ state, focus }) {
|
|
809
|
+
// ... async actions for user and posts ...
|
|
810
|
+
|
|
811
|
+
// Option 1: Using async.all() - simpler for multiple sources
|
|
812
|
+
async.derive(focus("summary"), () => {
|
|
813
|
+
const [user, posts] = async.all(state.user, state.posts);
|
|
814
|
+
return { name: user.name, postCount: posts.length };
|
|
736
815
|
});
|
|
737
816
|
|
|
738
|
-
//
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
// Good for: Search, pagination, user-triggered refresh
|
|
745
|
-
// ═══════════════════════════════════════════════════════════════════
|
|
746
|
-
const searchAsync = async(
|
|
747
|
-
focus("searchResults"),
|
|
748
|
-
async (ctx, query: string) => {
|
|
749
|
-
const res = await fetch(`/api/users/search?q=${query}`, {
|
|
750
|
-
signal: ctx.signal,
|
|
751
|
-
});
|
|
752
|
-
return res.json();
|
|
753
|
-
}
|
|
754
|
-
);
|
|
817
|
+
// Option 2: Using async.wait() - more control for conditional logic
|
|
818
|
+
async.derive(focus("summary"), () => {
|
|
819
|
+
const user = async.wait(state.user);
|
|
820
|
+
const posts = async.wait(state.posts);
|
|
821
|
+
return { name: user.name, postCount: posts.length };
|
|
822
|
+
});
|
|
755
823
|
|
|
756
824
|
return {
|
|
757
|
-
|
|
758
|
-
search: searchAsync.dispatch,
|
|
759
|
-
cancelSearch: searchAsync.cancel,
|
|
825
|
+
/* actions */
|
|
760
826
|
};
|
|
761
827
|
},
|
|
762
828
|
});
|
|
829
|
+
```
|
|
763
830
|
|
|
764
|
-
|
|
765
|
-
// Pattern 3: TRIGGER with dependencies
|
|
766
|
-
// Uses useStore's `trigger` for declarative data fetching
|
|
767
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
768
|
-
|
|
769
|
-
// 3a. No deps - fetch ONCE when component mounts
|
|
770
|
-
function UserProfile() {
|
|
771
|
-
const { user } = useStore(({ get, trigger }) => {
|
|
772
|
-
const [state, actions] = get(userStore);
|
|
773
|
-
|
|
774
|
-
// No deps array = fetch once, never refetch
|
|
775
|
-
trigger(actions.currentUser.dispatch, []);
|
|
831
|
+
**Use case:** Computing a value from multiple async sources.
|
|
776
832
|
|
|
777
|
-
|
|
778
|
-
});
|
|
833
|
+
**What Storion does:**
|
|
779
834
|
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
835
|
+
1. Runs the derive function and tracks dependencies
|
|
836
|
+
2. If any source is pending/error, the derived state mirrors that status
|
|
837
|
+
3. If all sources are ready, computes and stores the result
|
|
838
|
+
4. Re-runs automatically when source states change
|
|
783
839
|
|
|
784
|
-
|
|
785
|
-
function Dashboard() {
|
|
786
|
-
const { user } = useStore(({ get, trigger, id }) => {
|
|
787
|
-
const [state, actions] = get(userStore);
|
|
840
|
+
**When to use each approach:**
|
|
788
841
|
|
|
789
|
-
|
|
790
|
-
|
|
842
|
+
| Approach | Best for |
|
|
843
|
+
| -------------- | ----------------------------------------------------- |
|
|
844
|
+
| `async.all()` | Waiting for multiple sources at once (cleaner syntax) |
|
|
845
|
+
| `async.wait()` | Conditional logic where you may not need all sources |
|
|
791
846
|
|
|
792
|
-
|
|
793
|
-
});
|
|
794
|
-
|
|
795
|
-
return <div>Welcome back, {user.data?.name}</div>;
|
|
796
|
-
}
|
|
847
|
+
---
|
|
797
848
|
|
|
798
|
-
|
|
799
|
-
function UserById({ userId }: { userId: string }) {
|
|
800
|
-
const { user } = useStore(({ get, trigger }) => {
|
|
801
|
-
const [state, actions] = get(userStore);
|
|
849
|
+
## Using Stores in React
|
|
802
850
|
|
|
803
|
-
|
|
804
|
-
trigger(actions.currentUser.dispatch, [userId]);
|
|
851
|
+
### useStore Hook
|
|
805
852
|
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
return <div>{user.data?.name}</div>;
|
|
810
|
-
}
|
|
853
|
+
```tsx
|
|
854
|
+
import { useStore } from "storion/react";
|
|
855
|
+
import { trigger } from "storion";
|
|
811
856
|
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
function UserListWithEffect() {
|
|
817
|
-
const { search } = useStore(({ get }) => {
|
|
818
|
-
const [, actions] = get(userStore);
|
|
819
|
-
return { search: actions.search };
|
|
820
|
-
});
|
|
857
|
+
function Component() {
|
|
858
|
+
const { count, inc, user } = useStore(({ get, id }) => {
|
|
859
|
+
const [counterState, counterActions] = get(counterStore);
|
|
860
|
+
const [userState, userActions] = get(userStore);
|
|
821
861
|
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
}, []);
|
|
862
|
+
// Trigger immediately (empty deps = once)
|
|
863
|
+
trigger(userActions.fetchProfile, []); // OR trigger(userActions.fetchProfile);
|
|
825
864
|
|
|
826
|
-
|
|
827
|
-
|
|
865
|
+
// Trigger on each component mount (id is unique per mount)
|
|
866
|
+
trigger(userActions.refresh, [id]);
|
|
828
867
|
|
|
829
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
830
|
-
// Pattern 5: USER INTERACTION - on-demand fetching
|
|
831
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
832
|
-
function SearchBox() {
|
|
833
|
-
const [query, setQuery] = useState("");
|
|
834
|
-
const { results, search, cancel } = useStore(({ get }) => {
|
|
835
|
-
const [state, actions] = get(userStore);
|
|
836
868
|
return {
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
869
|
+
count: counterState.count,
|
|
870
|
+
inc: counterActions.inc,
|
|
871
|
+
user: userState.profile,
|
|
840
872
|
};
|
|
841
873
|
});
|
|
842
874
|
|
|
843
|
-
|
|
844
|
-
cancel(); // Cancel previous search
|
|
845
|
-
search(query);
|
|
846
|
-
};
|
|
847
|
-
|
|
848
|
-
return (
|
|
849
|
-
<div>
|
|
850
|
-
<input value={query} onChange={(e) => setQuery(e.target.value)} />
|
|
851
|
-
<button onClick={handleSearch}>Search</button>
|
|
852
|
-
<button onClick={cancel}>Cancel</button>
|
|
853
|
-
|
|
854
|
-
{results.status === "pending" && <Spinner />}
|
|
855
|
-
{results.data?.map((user) => (
|
|
856
|
-
<div key={user.id}>{user.name}</div>
|
|
857
|
-
))}
|
|
858
|
-
</div>
|
|
859
|
-
);
|
|
875
|
+
return <div>...</div>;
|
|
860
876
|
}
|
|
861
877
|
```
|
|
862
878
|
|
|
863
|
-
**
|
|
879
|
+
**Selector context provides:**
|
|
864
880
|
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
│ Component mounts? │
|
|
873
|
-
│ │ │
|
|
874
|
-
│ ├── Once ever? ────► trigger(fn, []) │
|
|
875
|
-
│ │ │
|
|
876
|
-
│ ├── Every visit? ──► trigger(fn, [context.id]) │
|
|
877
|
-
│ │ │
|
|
878
|
-
│ └── When deps change? ► trigger(fn, [dep1, dep2]) │
|
|
879
|
-
│ │
|
|
880
|
-
│ User clicks? ──────────► onClick handler calls action │
|
|
881
|
-
│ │
|
|
882
|
-
└─────────────────────────────────────────────────────────────────────────┘
|
|
883
|
-
```
|
|
881
|
+
| Property | Description |
|
|
882
|
+
| -------------------------- | ---------------------------------------------- |
|
|
883
|
+
| `get(store)` | Get store instance, returns `[state, actions]` |
|
|
884
|
+
| `get(service)` | Get service instance (cached) |
|
|
885
|
+
| `create(service, ...args)` | Create fresh service instance with args |
|
|
886
|
+
| `id` | Unique ID per component mount |
|
|
887
|
+
| `once(fn)` | Run function once on mount |
|
|
884
888
|
|
|
885
|
-
|
|
889
|
+
**Global function `trigger()`** — Call a function when dependencies change (import from `"storion"`).
|
|
886
890
|
|
|
887
|
-
|
|
891
|
+
### Stable Function Wrapping
|
|
888
892
|
|
|
889
|
-
|
|
893
|
+
Functions returned from `useStore` are automatically wrapped with stable references. This means:
|
|
894
|
+
|
|
895
|
+
- The function reference never changes between renders
|
|
896
|
+
- The function always accesses the latest props and state
|
|
897
|
+
- Safe to pass to child components without causing re-renders
|
|
890
898
|
|
|
891
899
|
```tsx
|
|
892
|
-
import { Suspense } from "react";
|
|
893
|
-
import { async } from "storion/async";
|
|
894
900
|
import { useStore } from "storion/react";
|
|
895
901
|
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
const { user } = useStore(({ get, trigger }) => {
|
|
899
|
-
const [state, actions] = get(userStore);
|
|
902
|
+
function SearchForm({ userId }: { userId: string }) {
|
|
903
|
+
const [query, setQuery] = useState("");
|
|
900
904
|
|
|
901
|
-
|
|
902
|
-
|
|
905
|
+
const { search, results } = useStore(({ get }) => {
|
|
906
|
+
const [state, actions] = get(searchStore);
|
|
903
907
|
|
|
904
908
|
return {
|
|
905
|
-
|
|
906
|
-
|
|
909
|
+
results: state.results,
|
|
910
|
+
// This function is auto-wrapped with stable reference
|
|
911
|
+
search: () => {
|
|
912
|
+
// Always has access to current query and userId
|
|
913
|
+
actions.performSearch(query, userId);
|
|
914
|
+
},
|
|
907
915
|
};
|
|
908
916
|
});
|
|
909
917
|
|
|
910
|
-
// This only renders when data is ready
|
|
911
918
|
return (
|
|
912
919
|
<div>
|
|
913
|
-
<
|
|
914
|
-
|
|
920
|
+
<input value={query} onChange={(e) => setQuery(e.target.value)} />
|
|
921
|
+
{/* search reference is stable - won't cause Button to re-render */}
|
|
922
|
+
<Button onClick={search}>Search</Button>
|
|
923
|
+
<Results items={results} />
|
|
915
924
|
</div>
|
|
916
925
|
);
|
|
917
926
|
}
|
|
918
927
|
|
|
919
|
-
//
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
<Suspense fallback={<Spinner />}>
|
|
924
|
-
<UserProfile />
|
|
925
|
-
</Suspense>
|
|
926
|
-
</ErrorBoundary>
|
|
927
|
-
);
|
|
928
|
-
}
|
|
928
|
+
// Button only re-renders when its own props change
|
|
929
|
+
const Button = memo(({ onClick, children }) => (
|
|
930
|
+
<button onClick={onClick}>{children}</button>
|
|
931
|
+
));
|
|
929
932
|
```
|
|
930
933
|
|
|
931
|
-
**
|
|
934
|
+
**Use case:** Creating callbacks that depend on component state/props but need stable references for `memo`, `useCallback` dependencies, or child component optimization.
|
|
935
|
+
|
|
936
|
+
**What Storion does:**
|
|
937
|
+
|
|
938
|
+
1. Detects functions in the selector's return value
|
|
939
|
+
2. Wraps each function with a stable reference (created once)
|
|
940
|
+
3. When the wrapped function is called, it executes the latest version from the selector
|
|
941
|
+
4. Component state (`query`) and props (`userId`) are always current when the function runs
|
|
942
|
+
|
|
943
|
+
**Why this matters:**
|
|
932
944
|
|
|
933
945
|
```tsx
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
const [userState, userActions] = get(userStore);
|
|
937
|
-
const [postState, postActions] = get(postStore);
|
|
938
|
-
const [commentState, commentActions] = get(commentStore);
|
|
939
|
-
|
|
940
|
-
trigger(userActions.fetch);
|
|
941
|
-
trigger(postActions.fetch);
|
|
942
|
-
trigger(commentActions.fetch);
|
|
943
|
-
|
|
944
|
-
// Wait for ALL async states - suspends until all are ready
|
|
945
|
-
const [user, posts, comments] = async.all(
|
|
946
|
-
userState.current,
|
|
947
|
-
postState.list,
|
|
948
|
-
commentState.recent
|
|
949
|
-
);
|
|
946
|
+
// ❌ Without stable wrapping - new reference every render
|
|
947
|
+
const search = () => actions.search(query); // Changes every render!
|
|
950
948
|
|
|
951
|
-
|
|
952
|
-
|
|
949
|
+
// ❌ Manual useCallback - verbose and easy to forget deps
|
|
950
|
+
const search = useCallback(() => actions.search(query), [query, actions]);
|
|
953
951
|
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
<CommentList comments={comments} />
|
|
959
|
-
</div>
|
|
960
|
-
);
|
|
961
|
-
}
|
|
952
|
+
// ✅ With useStore - stable reference, always current values
|
|
953
|
+
const { search } = useStore(({ get }) => ({
|
|
954
|
+
search: () => actions.search(query), // Stable reference!
|
|
955
|
+
}));
|
|
962
956
|
```
|
|
963
957
|
|
|
964
|
-
|
|
958
|
+
### Trigger Patterns
|
|
965
959
|
|
|
966
960
|
```tsx
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
961
|
+
import { trigger } from "storion";
|
|
962
|
+
import { useStore } from "storion/react";
|
|
963
|
+
|
|
964
|
+
function Dashboard({ categoryId }: { categoryId: string }) {
|
|
965
|
+
const { data } = useStore(({ get, id }) => {
|
|
966
|
+
const [state, actions] = get(dataStore);
|
|
967
|
+
|
|
968
|
+
// Pattern 1: Fetch once ever (empty deps)
|
|
969
|
+
trigger(actions.fetchOnce, []);
|
|
970
|
+
|
|
971
|
+
// Pattern 2: Fetch every mount (id changes each mount)
|
|
972
|
+
trigger(actions.fetchEveryVisit, [id]);
|
|
973
|
+
|
|
974
|
+
// Pattern 3: Fetch when prop changes
|
|
975
|
+
trigger(actions.fetchByCategory, [categoryId], categoryId);
|
|
970
976
|
|
|
971
|
-
|
|
972
|
-
|
|
977
|
+
return { data: state.data };
|
|
978
|
+
});
|
|
979
|
+
}
|
|
980
|
+
```
|
|
981
|
+
|
|
982
|
+
**What Storion does:**
|
|
973
983
|
|
|
974
|
-
|
|
984
|
+
1. `trigger()` compares current deps with previous deps
|
|
985
|
+
2. If deps changed (or first render), calls the function with provided args
|
|
986
|
+
3. Empty deps `[]` means "call once and never again"
|
|
987
|
+
4. `[id]` means "call every time component mounts" (id is unique per mount)
|
|
988
|
+
|
|
989
|
+
### Fine-Grained Updates with pick()
|
|
990
|
+
|
|
991
|
+
```tsx
|
|
992
|
+
import { pick } from "storion";
|
|
993
|
+
|
|
994
|
+
function UserName() {
|
|
995
|
+
const { name, fullName } = useStore(({ get }) => {
|
|
996
|
+
const [state] = get(userStore);
|
|
975
997
|
return {
|
|
976
|
-
|
|
998
|
+
// Re-renders ONLY when this specific value changes
|
|
999
|
+
name: pick(() => state.profile.name),
|
|
1000
|
+
|
|
1001
|
+
// Computed values are tracked the same way
|
|
1002
|
+
fullName: pick(() => `${state.profile.first} ${state.profile.last}`),
|
|
977
1003
|
};
|
|
978
1004
|
});
|
|
979
1005
|
|
|
980
|
-
return <
|
|
1006
|
+
return <span>{fullName}</span>;
|
|
981
1007
|
}
|
|
982
1008
|
```
|
|
983
1009
|
|
|
984
|
-
**
|
|
1010
|
+
**Use case:** When you need even more precise control over re-renders.
|
|
985
1011
|
|
|
986
|
-
|
|
987
|
-
| ------------------------ | ------------------------------------- | ------------------------- |
|
|
988
|
-
| `async.wait(state)` | Throws if pending/error, returns data | Single Suspense resource |
|
|
989
|
-
| `async.all(...states)` | Waits for all, returns tuple | Multiple parallel fetches |
|
|
990
|
-
| `async.any(...states)` | Returns first successful | Fallback sources |
|
|
991
|
-
| `async.race(states)` | Returns fastest | Competitive fetching |
|
|
992
|
-
| `async.hasData(state)` | `boolean` | Check without suspending |
|
|
993
|
-
| `async.isLoading(state)` | `boolean` | Loading indicator |
|
|
994
|
-
| `async.isError(state)` | `boolean` | Error check |
|
|
1012
|
+
**Without pick():** Component re-renders when `state.profile` reference changes (even if `name` didn't change).
|
|
995
1013
|
|
|
996
|
-
|
|
1014
|
+
**With pick():** Component only re-renders when the picked value actually changes.
|
|
997
1015
|
|
|
998
|
-
**
|
|
1016
|
+
**pick() equality options:**
|
|
999
1017
|
|
|
1000
|
-
|
|
1018
|
+
```tsx
|
|
1019
|
+
const result = useStore(({ get }) => {
|
|
1020
|
+
const [state] = get(mapStore);
|
|
1021
|
+
return {
|
|
1022
|
+
// Default: strict equality (===)
|
|
1023
|
+
x: pick(() => state.coords.x),
|
|
1001
1024
|
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
import { async, type AsyncState } from "storion/async";
|
|
1025
|
+
// Shallow: compare object properties one level deep
|
|
1026
|
+
coords: pick(() => state.coords, "shallow"),
|
|
1005
1027
|
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
name: string;
|
|
1009
|
-
}
|
|
1028
|
+
// Deep: recursive comparison
|
|
1029
|
+
settings: pick(() => state.settings, "deep"),
|
|
1010
1030
|
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1031
|
+
// Custom: provide your own function
|
|
1032
|
+
ids: pick(
|
|
1033
|
+
() => state.items.map((i) => i.id),
|
|
1034
|
+
(a, b) => a.length === b.length && a.every((v, i) => v === b[i])
|
|
1035
|
+
),
|
|
1036
|
+
};
|
|
1037
|
+
});
|
|
1038
|
+
```
|
|
1016
1039
|
|
|
1017
|
-
|
|
1018
|
-
name: "dashboard",
|
|
1019
|
-
state: {
|
|
1020
|
-
user: async.fresh<User>(),
|
|
1021
|
-
posts: async.fresh<Post[]>(),
|
|
1022
|
-
// Derived async state - computed from user + posts
|
|
1023
|
-
summary: async.fresh<{ userName: string; postCount: number }>(),
|
|
1024
|
-
},
|
|
1025
|
-
setup({ state, focus }) {
|
|
1026
|
-
// Fetch actions
|
|
1027
|
-
const userActions = async(focus("user"), async (ctx, userId: string) => {
|
|
1028
|
-
const res = await fetch(`/api/users/${userId}`, { signal: ctx.signal });
|
|
1029
|
-
return res.json();
|
|
1030
|
-
});
|
|
1040
|
+
---
|
|
1031
1041
|
|
|
1032
|
-
|
|
1033
|
-
const res = await fetch(`/api/users/${userId}/posts`, {
|
|
1034
|
-
signal: ctx.signal,
|
|
1035
|
-
});
|
|
1036
|
-
return res.json();
|
|
1037
|
-
});
|
|
1042
|
+
## API Reference
|
|
1038
1043
|
|
|
1039
|
-
|
|
1040
|
-
// - If user OR posts is pending → summary is pending
|
|
1041
|
-
// - If user OR posts has error → summary has error
|
|
1042
|
-
// - If both succeed → summary is success with computed value
|
|
1043
|
-
async.derive(focus("summary"), () => {
|
|
1044
|
-
const user = async.wait(state.user);
|
|
1045
|
-
const posts = async.wait(state.posts);
|
|
1046
|
-
return {
|
|
1047
|
-
userName: user.name,
|
|
1048
|
-
postCount: posts.length,
|
|
1049
|
-
};
|
|
1050
|
-
});
|
|
1044
|
+
### store(options)
|
|
1051
1045
|
|
|
1046
|
+
Creates a store specification.
|
|
1047
|
+
|
|
1048
|
+
```ts
|
|
1049
|
+
import { store } from "storion";
|
|
1050
|
+
|
|
1051
|
+
const myStore = store({
|
|
1052
|
+
name: "myStore",
|
|
1053
|
+
state: { count: 0 },
|
|
1054
|
+
setup({ state, update, focus, get, create, onDispose }) {
|
|
1052
1055
|
return {
|
|
1053
|
-
|
|
1054
|
-
fetchPosts: postsActions.dispatch,
|
|
1056
|
+
inc: () => state.count++,
|
|
1055
1057
|
};
|
|
1056
1058
|
},
|
|
1057
1059
|
});
|
|
1058
1060
|
```
|
|
1059
1061
|
|
|
1060
|
-
**
|
|
1062
|
+
**Options:**
|
|
1061
1063
|
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
return async.wait(state.guestData);
|
|
1072
|
-
}
|
|
1073
|
-
});
|
|
1074
|
-
```
|
|
1064
|
+
| Option | Type | Description |
|
|
1065
|
+
| ------------ | ------------------------------ | ------------------------------------------- |
|
|
1066
|
+
| `name` | `string` | Display name for debugging |
|
|
1067
|
+
| `state` | `TState` | Initial state object |
|
|
1068
|
+
| `setup` | `(ctx) => TActions` | Setup function, returns actions |
|
|
1069
|
+
| `lifetime` | `"singleton" \| "autoDispose"` | Instance lifecycle (default: `"singleton"`) |
|
|
1070
|
+
| `equality` | `Equality \| EqualityMap` | Custom equality for state comparisons |
|
|
1071
|
+
| `onDispatch` | `(event) => void` | Called when any action is dispatched |
|
|
1072
|
+
| `onError` | `(error) => void` | Called when an error occurs |
|
|
1075
1073
|
|
|
1076
|
-
**
|
|
1074
|
+
**Setup context:**
|
|
1077
1075
|
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
```
|
|
1076
|
+
| Property | Description |
|
|
1077
|
+
| -------------------------- | --------------------------------------- |
|
|
1078
|
+
| `state` | Reactive state (first-level props only) |
|
|
1079
|
+
| `update(fn)` | Immer-style update for nested state |
|
|
1080
|
+
| `focus(path)` | Create getter/setter for a path |
|
|
1081
|
+
| `get(spec)` | Get dependency (store or service) |
|
|
1082
|
+
| `create(factory, ...args)` | Create fresh instance |
|
|
1083
|
+
| `dirty(prop?)` | Check if state has changed |
|
|
1084
|
+
| `reset()` | Reset to initial state |
|
|
1085
|
+
| `onDispose(fn)` | Register cleanup function |
|
|
1089
1086
|
|
|
1090
|
-
|
|
1087
|
+
### container(options?)
|
|
1088
|
+
|
|
1089
|
+
Creates a container for managing store and service instances.
|
|
1091
1090
|
|
|
1092
1091
|
```ts
|
|
1093
|
-
|
|
1094
|
-
// Stale mode: keeps previous computed value while recomputing
|
|
1095
|
-
summary: async.stale({ userName: "Loading...", postCount: 0 }),
|
|
1096
|
-
}
|
|
1092
|
+
import { container } from "storion";
|
|
1097
1093
|
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
const user = async.wait(state.user);
|
|
1101
|
-
const posts = async.wait(state.posts);
|
|
1102
|
-
return { userName: user.name, postCount: posts.length };
|
|
1094
|
+
const app = container({
|
|
1095
|
+
middleware: myMiddleware,
|
|
1103
1096
|
});
|
|
1097
|
+
|
|
1098
|
+
// Get store instance
|
|
1099
|
+
const { state, actions } = app.get(userStore);
|
|
1100
|
+
|
|
1101
|
+
// Get service instance
|
|
1102
|
+
const api = app.get(apiService);
|
|
1103
|
+
|
|
1104
|
+
// Create with parameters
|
|
1105
|
+
const logger = app.create(loggerService, "myNamespace");
|
|
1106
|
+
|
|
1107
|
+
// Lifecycle
|
|
1108
|
+
app.delete(userStore); // Remove specific instance
|
|
1109
|
+
app.clear(); // Clear all instances
|
|
1110
|
+
app.dispose(); // Dispose container and cleanup
|
|
1104
1111
|
```
|
|
1105
1112
|
|
|
1106
|
-
**
|
|
1113
|
+
**Methods:**
|
|
1107
1114
|
|
|
1108
|
-
|
|
|
1109
|
-
|
|
|
1110
|
-
|
|
|
1111
|
-
|
|
|
1112
|
-
|
|
|
1113
|
-
|
|
|
1115
|
+
| Method | Description |
|
|
1116
|
+
| -------------------------- | ------------------------------------- |
|
|
1117
|
+
| `get(spec)` | Get or create cached instance |
|
|
1118
|
+
| `create(factory, ...args)` | Create fresh instance (not cached) |
|
|
1119
|
+
| `set(spec, factory)` | Override factory (useful for testing) |
|
|
1120
|
+
| `delete(spec)` | Remove cached instance |
|
|
1121
|
+
| `clear()` | Clear all cached instances |
|
|
1122
|
+
| `dispose()` | Dispose container and all instances |
|
|
1114
1123
|
|
|
1115
|
-
|
|
1124
|
+
### effect(fn, options?)
|
|
1125
|
+
|
|
1126
|
+
Creates a reactive effect.
|
|
1116
1127
|
|
|
1117
1128
|
```ts
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
state.summary = asyncState("fresh", "error", state.user.error);
|
|
1126
|
-
return;
|
|
1127
|
-
}
|
|
1128
|
-
if (state.posts.status === "error") {
|
|
1129
|
-
state.summary = asyncState("fresh", "error", state.posts.error);
|
|
1130
|
-
return;
|
|
1131
|
-
}
|
|
1132
|
-
state.summary = asyncState("fresh", "success", {
|
|
1133
|
-
userName: state.user.data.name,
|
|
1134
|
-
postCount: state.posts.data.length,
|
|
1129
|
+
import { effect } from "storion";
|
|
1130
|
+
|
|
1131
|
+
const cleanup = effect((ctx) => {
|
|
1132
|
+
console.log("Count:", state.count);
|
|
1133
|
+
|
|
1134
|
+
ctx.onCleanup(() => {
|
|
1135
|
+
console.log("Cleaning up...");
|
|
1135
1136
|
});
|
|
1136
1137
|
});
|
|
1137
1138
|
|
|
1138
|
-
//
|
|
1139
|
-
|
|
1140
|
-
const user = async.wait(state.user);
|
|
1141
|
-
const posts = async.wait(state.posts);
|
|
1142
|
-
return { userName: user.name, postCount: posts.length };
|
|
1143
|
-
});
|
|
1139
|
+
// Later: stop the effect
|
|
1140
|
+
cleanup();
|
|
1144
1141
|
```
|
|
1145
1142
|
|
|
1146
|
-
|
|
1143
|
+
**Context properties:**
|
|
1147
1144
|
|
|
1148
|
-
|
|
1145
|
+
| Property | Description |
|
|
1146
|
+
| --------------- | ------------------------------------ |
|
|
1147
|
+
| `onCleanup(fn)` | Register cleanup function |
|
|
1148
|
+
| `safe(promise)` | Wrap promise to ignore stale results |
|
|
1149
|
+
| `signal` | AbortSignal for fetch cancellation |
|
|
1150
|
+
| `refresh()` | Manually trigger re-run (async only) |
|
|
1149
1151
|
|
|
1150
|
-
|
|
1151
|
-
- **Testing is painful** — Mocking ES modules requires awkward workarounds
|
|
1152
|
-
- **No cleanup** — Resources like connections, intervals, or subscriptions leak between tests
|
|
1152
|
+
**Options:**
|
|
1153
1153
|
|
|
1154
|
-
|
|
1154
|
+
| Option | Type | Description |
|
|
1155
|
+
| ------------ | -------- | ------------------- |
|
|
1156
|
+
| `debugLabel` | `string` | Label for debugging |
|
|
1155
1157
|
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
- **Hierarchical containers** — Create child containers for scoped dependencies
|
|
1158
|
+
### async(focus, handler, options?)
|
|
1159
|
+
|
|
1160
|
+
Creates async state management.
|
|
1160
1161
|
|
|
1161
1162
|
```ts
|
|
1162
|
-
import {
|
|
1163
|
+
import { async } from "storion/async";
|
|
1163
1164
|
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1165
|
+
const userAsync = async(
|
|
1166
|
+
focus("user"),
|
|
1167
|
+
async (ctx, userId: string) => {
|
|
1168
|
+
const res = await fetch(`/api/users/${userId}`, { signal: ctx.signal });
|
|
1169
|
+
return res.json();
|
|
1170
|
+
},
|
|
1171
|
+
{
|
|
1172
|
+
retry: { count: 3, delay: 1000 },
|
|
1173
|
+
onSuccess: (data) => console.log("Loaded:", data),
|
|
1174
|
+
onError: (error) => console.error("Failed:", error),
|
|
1175
|
+
}
|
|
1176
|
+
);
|
|
1169
1177
|
|
|
1170
|
-
|
|
1171
|
-
|
|
1178
|
+
// Actions
|
|
1179
|
+
userAsync.dispatch("123"); // Start async operation
|
|
1180
|
+
userAsync.cancel(); // Cancel current operation
|
|
1181
|
+
userAsync.refresh(); // Refetch with same args
|
|
1182
|
+
userAsync.reset(); // Reset to initial state
|
|
1183
|
+
```
|
|
1172
1184
|
|
|
1173
|
-
|
|
1174
|
-
async get(url) {
|
|
1175
|
-
const res = await fetch(`${baseUrl}${url}`);
|
|
1176
|
-
return res.json();
|
|
1177
|
-
},
|
|
1178
|
-
async post(url, data) {
|
|
1179
|
-
const res = await fetch(`${baseUrl}${url}`, {
|
|
1180
|
-
method: "POST",
|
|
1181
|
-
body: JSON.stringify(data),
|
|
1182
|
-
});
|
|
1183
|
-
return res.json();
|
|
1184
|
-
},
|
|
1185
|
-
};
|
|
1186
|
-
}
|
|
1185
|
+
**Options:**
|
|
1187
1186
|
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1187
|
+
| Option | Type | Description |
|
|
1188
|
+
| ------------- | ------------------------------- | ------------------------ |
|
|
1189
|
+
| `retry.count` | `number` | Number of retry attempts |
|
|
1190
|
+
| `retry.delay` | `number \| (attempt) => number` | Delay between retries |
|
|
1191
|
+
| `onSuccess` | `(data) => void` | Called on success |
|
|
1192
|
+
| `onError` | `(error) => void` | Called on error |
|
|
1191
1193
|
|
|
1192
|
-
|
|
1193
|
-
const userStore = store({
|
|
1194
|
-
name: "user",
|
|
1195
|
-
state: { user: null },
|
|
1196
|
-
setup({ get }) {
|
|
1197
|
-
const api = get(createApiService); // Singleton, cached
|
|
1194
|
+
**Async helpers:**
|
|
1198
1195
|
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1196
|
+
```ts
|
|
1197
|
+
// Initial state creators
|
|
1198
|
+
async.fresh<T>(); // Fresh mode: data undefined during loading
|
|
1199
|
+
async.stale<T>(initial); // Stale mode: preserves data during loading
|
|
1200
|
+
|
|
1201
|
+
// State extractors (Suspense-compatible)
|
|
1202
|
+
async.wait(state); // Get data or throw
|
|
1203
|
+
async.all(...states); // Wait for all, return tuple
|
|
1204
|
+
async.any(...states); // Get first successful
|
|
1205
|
+
async.race(states); // Get fastest
|
|
1206
|
+
|
|
1207
|
+
// State checks (non-throwing)
|
|
1208
|
+
async.hasData(state); // boolean
|
|
1209
|
+
async.isLoading(state); // boolean
|
|
1210
|
+
async.isError(state); // boolean
|
|
1211
|
+
|
|
1212
|
+
// Derived state
|
|
1213
|
+
async.derive(focus, () => {
|
|
1214
|
+
const a = async.wait(state.a);
|
|
1215
|
+
const b = async.wait(state.b);
|
|
1216
|
+
return computeResult(a, b);
|
|
1205
1217
|
});
|
|
1218
|
+
```
|
|
1206
1219
|
|
|
1207
|
-
|
|
1208
|
-
const mockApi: ApiService = {
|
|
1209
|
-
get: async () => ({ id: "1", name: "Test User" }),
|
|
1210
|
-
post: async () => ({}),
|
|
1211
|
-
};
|
|
1220
|
+
### pick(fn, equality?)
|
|
1212
1221
|
|
|
1213
|
-
|
|
1214
|
-
testApp.set(createApiService, () => mockApi); // Override with mock
|
|
1222
|
+
Fine-grained value tracking.
|
|
1215
1223
|
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
await actions.fetchUser("1"); // Uses mockApi.get()
|
|
1224
|
+
```ts
|
|
1225
|
+
import { pick } from "storion";
|
|
1219
1226
|
|
|
1220
|
-
//
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1227
|
+
// In selector
|
|
1228
|
+
const name = pick(() => state.profile.name);
|
|
1229
|
+
const coords = pick(() => state.coords, "shallow");
|
|
1230
|
+
const config = pick(() => state.config, "deep");
|
|
1231
|
+
const custom = pick(
|
|
1232
|
+
() => state.ids,
|
|
1233
|
+
(a, b) => arraysEqual(a, b)
|
|
1234
|
+
);
|
|
1224
1235
|
```
|
|
1225
1236
|
|
|
1226
|
-
|
|
1237
|
+
**Equality options:**
|
|
1238
|
+
|
|
1239
|
+
| Value | Description |
|
|
1240
|
+
| ------------------- | --------------------------------- |
|
|
1241
|
+
| (none) | Strict equality (`===`) |
|
|
1242
|
+
| `"shallow"` | Compare properties one level deep |
|
|
1243
|
+
| `"deep"` | Recursive comparison |
|
|
1244
|
+
| `(a, b) => boolean` | Custom comparison function |
|
|
1227
1245
|
|
|
1228
|
-
|
|
1246
|
+
### batch(fn)
|
|
1229
1247
|
|
|
1230
|
-
|
|
1248
|
+
Batch multiple mutations into one notification.
|
|
1231
1249
|
|
|
1232
1250
|
```ts
|
|
1233
|
-
import {
|
|
1251
|
+
import { batch } from "storion";
|
|
1234
1252
|
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1253
|
+
batch(() => {
|
|
1254
|
+
state.x = 1;
|
|
1255
|
+
state.y = 2;
|
|
1256
|
+
state.z = 3;
|
|
1257
|
+
});
|
|
1258
|
+
// Subscribers notified once, not three times
|
|
1259
|
+
```
|
|
1242
1260
|
|
|
1243
|
-
|
|
1244
|
-
resolver: Resolver,
|
|
1245
|
-
config: { host: string; port: number }
|
|
1246
|
-
) {
|
|
1247
|
-
return {
|
|
1248
|
-
query: (sql: string) =>
|
|
1249
|
-
fetch(`http://${config.host}:${config.port}/query`, {
|
|
1250
|
-
method: "POST",
|
|
1251
|
-
body: sql,
|
|
1252
|
-
}),
|
|
1253
|
-
close: () => {
|
|
1254
|
-
/* cleanup */
|
|
1255
|
-
},
|
|
1256
|
-
};
|
|
1257
|
-
}
|
|
1261
|
+
### untrack(fn)
|
|
1258
1262
|
|
|
1259
|
-
|
|
1260
|
-
const userStore = store({
|
|
1261
|
-
name: "user",
|
|
1262
|
-
state: { users: [] as User[] },
|
|
1263
|
-
setup({ create }) {
|
|
1264
|
-
// Each call creates a fresh instance with specific config
|
|
1265
|
-
const logger = create(createLogger, "user-store");
|
|
1266
|
-
const db = create(createDatabase, { host: "localhost", port: 5432 });
|
|
1263
|
+
Read state without tracking dependencies.
|
|
1267
1264
|
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
logger.info("Fetching users...");
|
|
1271
|
-
await db.query("SELECT * FROM users");
|
|
1272
|
-
},
|
|
1273
|
-
};
|
|
1274
|
-
},
|
|
1275
|
-
});
|
|
1265
|
+
```ts
|
|
1266
|
+
import { untrack } from "storion";
|
|
1276
1267
|
|
|
1277
|
-
|
|
1278
|
-
const
|
|
1279
|
-
|
|
1280
|
-
const
|
|
1268
|
+
effect(() => {
|
|
1269
|
+
const count = state.count; // Tracked
|
|
1270
|
+
|
|
1271
|
+
const name = untrack(() => state.name); // Not tracked
|
|
1272
|
+
|
|
1273
|
+
console.log(count, name);
|
|
1274
|
+
});
|
|
1275
|
+
// Effect only re-runs when count changes, not when name changes
|
|
1281
1276
|
```
|
|
1282
1277
|
|
|
1283
|
-
|
|
1278
|
+
---
|
|
1284
1279
|
|
|
1285
|
-
|
|
1286
|
-
| ---------- | --------------------------- | --------------------------------------------- |
|
|
1287
|
-
| Caching | Yes (singleton per factory) | No (always fresh) |
|
|
1288
|
-
| Arguments | None (parameterless only) | Supports additional arguments |
|
|
1289
|
-
| Use case | Shared services | Configured instances, child stores |
|
|
1290
|
-
| Middleware | Applied | Applied (without args) / Bypassed (with args) |
|
|
1280
|
+
## Advanced Patterns
|
|
1291
1281
|
|
|
1292
1282
|
### Middleware
|
|
1293
1283
|
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
**With Storion:** Compose middleware and apply it conditionally using patterns like `"user*"` (startsWith), `"*Store"` (endsWith), or custom predicates.
|
|
1284
|
+
Middleware intercepts store creation for cross-cutting concerns.
|
|
1297
1285
|
|
|
1298
1286
|
```ts
|
|
1299
1287
|
import { container, compose, applyFor, applyExcept } from "storion";
|
|
1300
1288
|
import type { StoreMiddleware } from "storion";
|
|
1301
1289
|
|
|
1302
|
-
//
|
|
1290
|
+
// Simple middleware
|
|
1303
1291
|
const loggingMiddleware: StoreMiddleware = (ctx) => {
|
|
1304
|
-
console.log(`Creating
|
|
1292
|
+
console.log(`Creating: ${ctx.displayName}`);
|
|
1305
1293
|
const instance = ctx.next();
|
|
1306
1294
|
console.log(`Created: ${instance.id}`);
|
|
1307
1295
|
return instance;
|
|
1308
1296
|
};
|
|
1309
1297
|
|
|
1310
|
-
//
|
|
1298
|
+
// Middleware with store-specific logic
|
|
1311
1299
|
const persistMiddleware: StoreMiddleware = (ctx) => {
|
|
1312
1300
|
const instance = ctx.next();
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
// Add persistence logic...
|
|
1301
|
+
|
|
1302
|
+
if (ctx.spec.options.meta?.persist) {
|
|
1303
|
+
// Add persistence logic
|
|
1317
1304
|
}
|
|
1305
|
+
|
|
1318
1306
|
return instance;
|
|
1319
1307
|
};
|
|
1320
1308
|
|
|
1309
|
+
// Apply conditionally
|
|
1321
1310
|
const app = container({
|
|
1322
1311
|
middleware: compose(
|
|
1323
|
-
// Apply
|
|
1312
|
+
// Apply to stores starting with "user"
|
|
1324
1313
|
applyFor("user*", loggingMiddleware),
|
|
1325
1314
|
|
|
1326
|
-
// Apply
|
|
1315
|
+
// Apply except to cache stores
|
|
1327
1316
|
applyExcept("*Cache", persistMiddleware),
|
|
1328
1317
|
|
|
1329
1318
|
// Apply to specific stores
|
|
1330
1319
|
applyFor(["authStore", "settingsStore"], loggingMiddleware),
|
|
1331
1320
|
|
|
1332
|
-
// Apply based on
|
|
1333
|
-
applyFor(
|
|
1334
|
-
(ctx) => ctx.spec.options.meta?.persist === true,
|
|
1335
|
-
persistMiddleware
|
|
1336
|
-
)
|
|
1321
|
+
// Apply based on condition
|
|
1322
|
+
applyFor((ctx) => ctx.spec.options.meta?.debug === true, loggingMiddleware)
|
|
1337
1323
|
),
|
|
1338
1324
|
});
|
|
1339
1325
|
```
|
|
1340
1326
|
|
|
1341
|
-
|
|
1327
|
+
**Pattern matching:**
|
|
1342
1328
|
|
|
1343
|
-
|
|
1329
|
+
| Pattern | Matches |
|
|
1330
|
+
| ------------------ | ---------------------- |
|
|
1331
|
+
| `"user*"` | Starts with "user" |
|
|
1332
|
+
| `"*Store"` | Ends with "Store" |
|
|
1333
|
+
| `["a", "b"]` | Exact match "a" or "b" |
|
|
1334
|
+
| `(ctx) => boolean` | Custom predicate |
|
|
1344
1335
|
|
|
1345
|
-
###
|
|
1336
|
+
### Parameterized Services
|
|
1346
1337
|
|
|
1347
|
-
|
|
1348
|
-
| ---------------------- | ---------------------------------------------- |
|
|
1349
|
-
| `store(options)` | Create a store specification |
|
|
1350
|
-
| `container(options?)` | Create a container for store instances and DI |
|
|
1351
|
-
| `effect(fn, options?)` | Create reactive side effects with cleanup |
|
|
1352
|
-
| `pick(fn, equality?)` | Fine-grained derived value tracking |
|
|
1353
|
-
| `batch(fn)` | Batch multiple mutations into one notification |
|
|
1354
|
-
| `untrack(fn)` | Read state without tracking dependencies |
|
|
1355
|
-
|
|
1356
|
-
#### Store Options
|
|
1338
|
+
For services that need configuration:
|
|
1357
1339
|
|
|
1358
1340
|
```ts
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1341
|
+
// Parameterized service factory
|
|
1342
|
+
function dbService(resolver, config: { host: string; port: number }) {
|
|
1343
|
+
return {
|
|
1344
|
+
query: (sql: string) =>
|
|
1345
|
+
fetch(`http://${config.host}:${config.port}/query`, {
|
|
1346
|
+
method: "POST",
|
|
1347
|
+
body: sql,
|
|
1348
|
+
}),
|
|
1349
|
+
};
|
|
1367
1350
|
}
|
|
1351
|
+
|
|
1352
|
+
// Use with create() instead of get()
|
|
1353
|
+
const myStore = store({
|
|
1354
|
+
name: "data",
|
|
1355
|
+
state: { items: [] },
|
|
1356
|
+
setup({ create }) {
|
|
1357
|
+
// create() always makes a fresh instance and accepts args
|
|
1358
|
+
const db = create(dbService, { host: "localhost", port: 5432 });
|
|
1359
|
+
|
|
1360
|
+
return {
|
|
1361
|
+
fetchItems: async () => {
|
|
1362
|
+
return db.query("SELECT * FROM items");
|
|
1363
|
+
},
|
|
1364
|
+
};
|
|
1365
|
+
},
|
|
1366
|
+
});
|
|
1368
1367
|
```
|
|
1369
1368
|
|
|
1370
|
-
**
|
|
1369
|
+
**get() vs create():**
|
|
1370
|
+
|
|
1371
|
+
| Aspect | `get()` | `create()` |
|
|
1372
|
+
| --------- | --------------- | -------------------- |
|
|
1373
|
+
| Caching | Yes (singleton) | No (always fresh) |
|
|
1374
|
+
| Arguments | None | Supports extra args |
|
|
1375
|
+
| Use case | Shared services | Configured instances |
|
|
1376
|
+
|
|
1377
|
+
### Store-Level Equality
|
|
1378
|
+
|
|
1379
|
+
Configure how state changes are detected:
|
|
1371
1380
|
|
|
1372
1381
|
```ts
|
|
1373
|
-
const
|
|
1374
|
-
name: "
|
|
1382
|
+
const mapStore = store({
|
|
1383
|
+
name: "map",
|
|
1375
1384
|
state: {
|
|
1376
|
-
theme: "light",
|
|
1377
1385
|
coords: { x: 0, y: 0 },
|
|
1378
|
-
|
|
1379
|
-
|
|
1386
|
+
markers: [] as Marker[],
|
|
1387
|
+
settings: { zoom: 1, rotation: 0 },
|
|
1380
1388
|
},
|
|
1381
|
-
// Per-property equality configuration
|
|
1382
1389
|
equality: {
|
|
1383
|
-
|
|
1384
|
-
coords: "shallow",
|
|
1385
|
-
|
|
1386
|
-
|
|
1390
|
+
// Shallow: only notify if x or y actually changed
|
|
1391
|
+
coords: "shallow",
|
|
1392
|
+
// Deep: recursive comparison for complex objects
|
|
1393
|
+
settings: "deep",
|
|
1394
|
+
// Custom function
|
|
1395
|
+
markers: (a, b) => a.length === b.length,
|
|
1387
1396
|
},
|
|
1388
1397
|
setup({ state }) {
|
|
1389
1398
|
return {
|
|
1390
1399
|
setCoords: (x: number, y: number) => {
|
|
1391
|
-
//
|
|
1400
|
+
// This creates a new object, but shallow equality
|
|
1401
|
+
// prevents notification if x and y are the same
|
|
1392
1402
|
state.coords = { x, y };
|
|
1393
1403
|
},
|
|
1394
1404
|
};
|
|
@@ -1396,179 +1406,75 @@ const myStore = store({
|
|
|
1396
1406
|
});
|
|
1397
1407
|
```
|
|
1398
1408
|
|
|
1399
|
-
|
|
1400
|
-
| ------------------- | ----------------------------------------------- |
|
|
1401
|
-
| `"strict"` | Default `===` comparison |
|
|
1402
|
-
| `"shallow"` | Compares object/array properties one level deep |
|
|
1403
|
-
| `"deep"` | Recursively compares nested structures |
|
|
1404
|
-
| `(a, b) => boolean` | Custom comparison function |
|
|
1405
|
-
|
|
1406
|
-
#### StoreContext (in setup)
|
|
1409
|
+
### Testing with Mocks
|
|
1407
1410
|
|
|
1408
1411
|
```ts
|
|
1409
|
-
|
|
1410
|
-
state: TState; // First-level props only (state.x = y)
|
|
1411
|
-
get<T>(spec: StoreSpec<T>): StoreTuple; // Get dependency store (cached)
|
|
1412
|
-
get<T>(factory: Factory<T>): T; // Get DI service (cached)
|
|
1413
|
-
create<T>(spec: StoreSpec<T>): StoreInstance<T>; // Create child store (fresh)
|
|
1414
|
-
create<T>(factory: Factory<T>): T; // Create service (fresh)
|
|
1415
|
-
create<R, A>(factory: (r, ...a: A) => R, ...a: A): R; // Parameterized factory
|
|
1416
|
-
focus<P extends Path>(path: P): Focus; // Lens-like accessor
|
|
1417
|
-
update(fn: (draft: TState) => void): void; // For nested/array mutations
|
|
1418
|
-
dirty(prop?: keyof TState): boolean; // Check if state changed
|
|
1419
|
-
reset(): void; // Reset to initial state
|
|
1420
|
-
onDispose(fn: VoidFunction): void; // Register cleanup
|
|
1421
|
-
}
|
|
1422
|
-
```
|
|
1423
|
-
|
|
1424
|
-
> **Note:** `state` allows direct assignment only for first-level properties. Use `update()` for nested objects, arrays, or batch updates.
|
|
1412
|
+
import { container } from "storion";
|
|
1425
1413
|
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
| Method | Caching | Use case |
|
|
1429
|
-
| ---------- | -------- | ------------------------------------------------------ |
|
|
1430
|
-
| `get()` | Cached | Shared dependencies, singleton services |
|
|
1431
|
-
| `create()` | No cache | Child stores, parameterized factories, fresh instances |
|
|
1432
|
-
|
|
1433
|
-
```ts
|
|
1434
|
-
setup({ get, create }) {
|
|
1435
|
-
// get() - cached, same instance every time
|
|
1436
|
-
const api = get(apiService); // Singleton
|
|
1414
|
+
// Production code
|
|
1415
|
+
const app = container();
|
|
1437
1416
|
|
|
1438
|
-
|
|
1439
|
-
|
|
1417
|
+
// Test setup
|
|
1418
|
+
const testApp = container();
|
|
1440
1419
|
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1420
|
+
// Override services with mocks
|
|
1421
|
+
testApp.set(apiService, () => ({
|
|
1422
|
+
get: async () => ({ id: "1", name: "Test User" }),
|
|
1423
|
+
post: async () => ({}),
|
|
1424
|
+
}));
|
|
1444
1425
|
|
|
1445
|
-
|
|
1446
|
-
}
|
|
1426
|
+
// Now stores will use the mock
|
|
1427
|
+
const { actions } = testApp.get(userStore);
|
|
1428
|
+
await actions.fetchUser("1"); // Uses mock apiService
|
|
1447
1429
|
```
|
|
1448
1430
|
|
|
1449
|
-
###
|
|
1450
|
-
|
|
1451
|
-
| Export | Description |
|
|
1452
|
-
| -------------------------- | ----------------------------------------- |
|
|
1453
|
-
| `StoreProvider` | Provides container to React tree |
|
|
1454
|
-
| `useStore(selector)` | Hook to consume stores with selector |
|
|
1455
|
-
| `useStore(spec)` | Hook for component-local store |
|
|
1456
|
-
| `useContainer()` | Access container from context |
|
|
1457
|
-
| `create(options)` | Create store + hook for single-store apps |
|
|
1458
|
-
| `withStore(hook, render?)` | HOC pattern for store consumption |
|
|
1431
|
+
### Child Containers
|
|
1459
1432
|
|
|
1460
|
-
|
|
1433
|
+
For scoped dependencies (e.g., per-request in SSR):
|
|
1461
1434
|
|
|
1462
1435
|
```ts
|
|
1463
|
-
|
|
1464
|
-
const result = useStore(({ get, create, mixin, once }) => {
|
|
1465
|
-
const [state, actions] = get(myStore);
|
|
1466
|
-
const service = get(myFactory); // Cached
|
|
1436
|
+
const rootApp = container();
|
|
1467
1437
|
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
// Run once on mount
|
|
1472
|
-
once(() => actions.init());
|
|
1473
|
-
|
|
1474
|
-
return { value: state.value, action: actions.doSomething };
|
|
1438
|
+
// Create child container with overrides
|
|
1439
|
+
const requestApp = container({
|
|
1440
|
+
parent: rootApp,
|
|
1475
1441
|
});
|
|
1476
|
-
```
|
|
1477
1442
|
|
|
1478
|
-
|
|
1443
|
+
// Child inherits from parent but can have its own instances
|
|
1444
|
+
requestApp.set(sessionService, () => createSessionForRequest());
|
|
1479
1445
|
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
| `async(focus, handler, options?)` | Create async action |
|
|
1483
|
-
| `async.fresh<T>()` | Create fresh mode initial state |
|
|
1484
|
-
| `async.stale<T>(initial)` | Create stale mode initial state |
|
|
1485
|
-
| `async.wait(state)` | Extract data or throw (Suspense-compatible) |
|
|
1486
|
-
| `async.all(...states)` | Wait for all states to be ready |
|
|
1487
|
-
| `async.any(...states)` | Get first ready state |
|
|
1488
|
-
| `async.race(states)` | Race between states |
|
|
1489
|
-
| `async.derive(focus, computeFn)` | Derive async state from other async states |
|
|
1490
|
-
| `async.hasData(state)` | Check if state has data |
|
|
1491
|
-
| `async.isLoading(state)` | Check if state is loading |
|
|
1492
|
-
| `async.isError(state)` | Check if state has error |
|
|
1493
|
-
|
|
1494
|
-
#### AsyncState Types
|
|
1495
|
-
|
|
1496
|
-
```ts
|
|
1497
|
-
interface AsyncState<T, M extends "fresh" | "stale"> {
|
|
1498
|
-
status: "idle" | "pending" | "success" | "error";
|
|
1499
|
-
mode: M;
|
|
1500
|
-
data: M extends "stale" ? T : T | undefined;
|
|
1501
|
-
error: Error | undefined;
|
|
1502
|
-
timestamp: number | undefined;
|
|
1503
|
-
}
|
|
1504
|
-
```
|
|
1505
|
-
|
|
1506
|
-
### Middleware
|
|
1507
|
-
|
|
1508
|
-
| Export | Description |
|
|
1509
|
-
| ------------- | -------------------------------------------------- |
|
|
1510
|
-
| `compose` | Compose multiple StoreMiddleware into one |
|
|
1511
|
-
| `applyFor` | Apply middleware conditionally (pattern/predicate) |
|
|
1512
|
-
| `applyExcept` | Apply middleware except for matching patterns |
|
|
1513
|
-
|
|
1514
|
-
#### Middleware Context (Discriminated Union)
|
|
1515
|
-
|
|
1516
|
-
Middleware context uses a discriminated union with `type` field:
|
|
1517
|
-
|
|
1518
|
-
```ts
|
|
1519
|
-
// For stores (container middleware)
|
|
1520
|
-
interface StoreMiddlewareContext {
|
|
1521
|
-
type: "store"; // Discriminant
|
|
1522
|
-
spec: StoreSpec; // Always present for stores
|
|
1523
|
-
factory: Factory;
|
|
1524
|
-
resolver: Resolver;
|
|
1525
|
-
next: () => unknown;
|
|
1526
|
-
displayName: string; // Always present for stores
|
|
1527
|
-
}
|
|
1528
|
-
|
|
1529
|
-
// For plain factories (resolver middleware)
|
|
1530
|
-
interface FactoryMiddlewareContext {
|
|
1531
|
-
type: "factory"; // Discriminant
|
|
1532
|
-
factory: Factory;
|
|
1533
|
-
resolver: Resolver;
|
|
1534
|
-
next: () => unknown;
|
|
1535
|
-
displayName: string | undefined;
|
|
1536
|
-
}
|
|
1537
|
-
|
|
1538
|
-
type MiddlewareContext = FactoryMiddlewareContext | StoreMiddlewareContext;
|
|
1446
|
+
// Cleanup after request
|
|
1447
|
+
requestApp.dispose();
|
|
1539
1448
|
```
|
|
1540
1449
|
|
|
1541
|
-
|
|
1450
|
+
### Store Lifecycle
|
|
1542
1451
|
|
|
1543
1452
|
```ts
|
|
1544
|
-
|
|
1545
|
-
|
|
1453
|
+
const myStore = store({
|
|
1454
|
+
name: "myStore",
|
|
1455
|
+
lifetime: "autoDispose", // Dispose when no subscribers
|
|
1456
|
+
state: { ... },
|
|
1457
|
+
setup({ onDispose }) {
|
|
1458
|
+
const interval = setInterval(() => {}, 1000);
|
|
1459
|
+
|
|
1460
|
+
// Cleanup when store is disposed
|
|
1461
|
+
onDispose(() => {
|
|
1462
|
+
clearInterval(interval);
|
|
1463
|
+
});
|
|
1546
1464
|
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
console.log(`Created: ${instance.id}`);
|
|
1551
|
-
return instance as StoreInstance;
|
|
1552
|
-
};
|
|
1465
|
+
return { ... };
|
|
1466
|
+
},
|
|
1467
|
+
});
|
|
1553
1468
|
```
|
|
1554
1469
|
|
|
1555
|
-
**
|
|
1470
|
+
**Lifetime options:**
|
|
1556
1471
|
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
// Use type narrowing
|
|
1562
|
-
if (ctx.type === "store") {
|
|
1563
|
-
console.log(`Store: ${ctx.spec.displayName}`);
|
|
1564
|
-
} else {
|
|
1565
|
-
console.log(`Factory: ${ctx.displayName ?? "anonymous"}`);
|
|
1566
|
-
}
|
|
1567
|
-
return ctx.next();
|
|
1568
|
-
};
|
|
1569
|
-
```
|
|
1472
|
+
| Value | Behavior |
|
|
1473
|
+
| --------------- | ------------------------------------------- |
|
|
1474
|
+
| `"singleton"` | Lives until container is disposed (default) |
|
|
1475
|
+
| `"autoDispose"` | Disposed when last subscriber unsubscribes |
|
|
1570
1476
|
|
|
1571
|
-
###
|
|
1477
|
+
### DevTools Integration
|
|
1572
1478
|
|
|
1573
1479
|
```ts
|
|
1574
1480
|
import { devtools } from "storion/devtools";
|
|
@@ -1576,18 +1482,14 @@ import { devtools } from "storion/devtools";
|
|
|
1576
1482
|
const app = container({
|
|
1577
1483
|
middleware: devtools({
|
|
1578
1484
|
name: "My App",
|
|
1579
|
-
// Enable in development only
|
|
1580
1485
|
enabled: process.env.NODE_ENV === "development",
|
|
1581
1486
|
}),
|
|
1582
1487
|
});
|
|
1583
1488
|
```
|
|
1584
1489
|
|
|
1585
|
-
### Devtools Panel (`storion/devtools-panel`)
|
|
1586
|
-
|
|
1587
1490
|
```tsx
|
|
1588
1491
|
import { DevtoolsPanel } from "storion/devtools-panel";
|
|
1589
1492
|
|
|
1590
|
-
// Mount anywhere in your app (dev only)
|
|
1591
1493
|
function App() {
|
|
1592
1494
|
return (
|
|
1593
1495
|
<>
|
|
@@ -1600,52 +1502,104 @@ function App() {
|
|
|
1600
1502
|
|
|
1601
1503
|
---
|
|
1602
1504
|
|
|
1603
|
-
##
|
|
1505
|
+
## Error Handling
|
|
1604
1506
|
|
|
1605
|
-
###
|
|
1507
|
+
### Effect Errors
|
|
1606
1508
|
|
|
1607
|
-
|
|
1509
|
+
Errors in effects are caught and can be handled:
|
|
1608
1510
|
|
|
1609
1511
|
```ts
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1512
|
+
const myStore = store({
|
|
1513
|
+
name: "myStore",
|
|
1514
|
+
state: { ... },
|
|
1515
|
+
onError: (error) => {
|
|
1516
|
+
console.error("Store error:", error);
|
|
1517
|
+
// Send to error tracking service
|
|
1518
|
+
},
|
|
1519
|
+
setup({ state }) {
|
|
1520
|
+
effect(() => {
|
|
1521
|
+
if (state.invalid) {
|
|
1522
|
+
throw new Error("Invalid state!");
|
|
1523
|
+
}
|
|
1524
|
+
});
|
|
1525
|
+
|
|
1526
|
+
return { ... };
|
|
1527
|
+
},
|
|
1528
|
+
});
|
|
1529
|
+
```
|
|
1530
|
+
|
|
1531
|
+
### Async Errors
|
|
1532
|
+
|
|
1533
|
+
```ts
|
|
1534
|
+
const userAsync = async(
|
|
1535
|
+
focus("user"),
|
|
1536
|
+
async (ctx) => {
|
|
1537
|
+
const res = await fetch("/api/user", { signal: ctx.signal });
|
|
1538
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
1539
|
+
return res.json();
|
|
1540
|
+
},
|
|
1541
|
+
{
|
|
1542
|
+
onError: (error) => {
|
|
1543
|
+
// Handle or log the error
|
|
1615
1544
|
},
|
|
1616
|
-
|
|
1617
|
-
|
|
1545
|
+
retry: {
|
|
1546
|
+
count: 3,
|
|
1547
|
+
delay: (attempt) => Math.min(1000 * 2 ** attempt, 10000),
|
|
1618
1548
|
},
|
|
1619
|
-
}
|
|
1549
|
+
}
|
|
1550
|
+
);
|
|
1551
|
+
```
|
|
1552
|
+
|
|
1553
|
+
### React Error Boundaries
|
|
1554
|
+
|
|
1555
|
+
```tsx
|
|
1556
|
+
function App() {
|
|
1557
|
+
return (
|
|
1558
|
+
<ErrorBoundary fallback={<ErrorPage />}>
|
|
1559
|
+
<Suspense fallback={<Spinner />}>
|
|
1560
|
+
<UserProfile />
|
|
1561
|
+
</Suspense>
|
|
1562
|
+
</ErrorBoundary>
|
|
1563
|
+
);
|
|
1620
1564
|
}
|
|
1621
1565
|
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
addItem: (item: string) => {
|
|
1631
|
-
update((draft) => {
|
|
1632
|
-
draft.items.push(item);
|
|
1633
|
-
});
|
|
1634
|
-
},
|
|
1635
|
-
// First-level props can be assigned directly
|
|
1636
|
-
setCount: (n: number) => {
|
|
1637
|
-
state.count = n; // This works!
|
|
1638
|
-
},
|
|
1639
|
-
};
|
|
1566
|
+
function UserProfile() {
|
|
1567
|
+
const { user } = useStore(({ get }) => {
|
|
1568
|
+
const [state] = get(userStore);
|
|
1569
|
+
// async.wait() throws on error, caught by ErrorBoundary
|
|
1570
|
+
return { user: async.wait(state.currentUser) };
|
|
1571
|
+
});
|
|
1572
|
+
|
|
1573
|
+
return <div>{user.name}</div>;
|
|
1640
1574
|
}
|
|
1641
1575
|
```
|
|
1642
1576
|
|
|
1643
|
-
|
|
1577
|
+
---
|
|
1578
|
+
|
|
1579
|
+
## Limitations & Anti-patterns
|
|
1644
1580
|
|
|
1645
|
-
|
|
1581
|
+
### ❌ Don't Mutate Nested State Directly
|
|
1582
|
+
|
|
1583
|
+
Direct mutation only works for first-level properties:
|
|
1646
1584
|
|
|
1647
1585
|
```ts
|
|
1648
|
-
// ❌ Wrong -
|
|
1586
|
+
// ❌ Wrong - won't trigger reactivity
|
|
1587
|
+
state.profile.name = "John";
|
|
1588
|
+
state.items.push("new item");
|
|
1589
|
+
|
|
1590
|
+
// ✅ Correct - use update()
|
|
1591
|
+
update((draft) => {
|
|
1592
|
+
draft.profile.name = "John";
|
|
1593
|
+
draft.items.push("new item");
|
|
1594
|
+
});
|
|
1595
|
+
```
|
|
1596
|
+
|
|
1597
|
+
### ❌ Don't Call get() Inside Actions
|
|
1598
|
+
|
|
1599
|
+
`get()` is for setup-time dependencies, not runtime:
|
|
1600
|
+
|
|
1601
|
+
```ts
|
|
1602
|
+
// ❌ Wrong
|
|
1649
1603
|
setup({ get }) {
|
|
1650
1604
|
return {
|
|
1651
1605
|
doSomething: () => {
|
|
@@ -1654,31 +1608,30 @@ setup({ get }) {
|
|
|
1654
1608
|
};
|
|
1655
1609
|
}
|
|
1656
1610
|
|
|
1657
|
-
// ✅ Correct -
|
|
1611
|
+
// ✅ Correct - capture at setup time
|
|
1658
1612
|
setup({ get }) {
|
|
1659
1613
|
const [otherState, otherActions] = get(otherStore);
|
|
1660
1614
|
|
|
1661
1615
|
return {
|
|
1662
1616
|
doSomething: () => {
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
}
|
|
1617
|
+
// Use the captured state/actions
|
|
1618
|
+
if (otherState.ready) { ... }
|
|
1666
1619
|
},
|
|
1667
1620
|
};
|
|
1668
1621
|
}
|
|
1669
1622
|
```
|
|
1670
1623
|
|
|
1671
|
-
### ❌ Don't
|
|
1624
|
+
### ❌ Don't Use Async Effects
|
|
1672
1625
|
|
|
1673
|
-
Effects must be synchronous
|
|
1626
|
+
Effects must be synchronous:
|
|
1674
1627
|
|
|
1675
1628
|
```ts
|
|
1676
|
-
// ❌ Wrong
|
|
1629
|
+
// ❌ Wrong
|
|
1677
1630
|
effect(async (ctx) => {
|
|
1678
|
-
const data = await fetchData();
|
|
1631
|
+
const data = await fetchData();
|
|
1679
1632
|
});
|
|
1680
1633
|
|
|
1681
|
-
// ✅ Correct
|
|
1634
|
+
// ✅ Correct
|
|
1682
1635
|
effect((ctx) => {
|
|
1683
1636
|
ctx.safe(fetchData()).then((data) => {
|
|
1684
1637
|
state.data = data;
|
|
@@ -1686,80 +1639,121 @@ effect((ctx) => {
|
|
|
1686
1639
|
});
|
|
1687
1640
|
```
|
|
1688
1641
|
|
|
1689
|
-
###
|
|
1642
|
+
### ❌ Don't Pass Anonymous Functions to trigger()
|
|
1690
1643
|
|
|
1691
|
-
|
|
1644
|
+
Anonymous functions create new references on every render:
|
|
1692
1645
|
|
|
1693
1646
|
```ts
|
|
1694
|
-
//
|
|
1695
|
-
|
|
1647
|
+
// ❌ Wrong - anonymous function called every render
|
|
1648
|
+
trigger(() => {
|
|
1649
|
+
actions.search(query);
|
|
1650
|
+
}, [query]);
|
|
1696
1651
|
|
|
1697
|
-
//
|
|
1698
|
-
|
|
1699
|
-
const fullName = pick(() => `${state.profile.first} ${state.profile.last}`);
|
|
1652
|
+
// ✅ Correct - stable function reference
|
|
1653
|
+
trigger(actions.search, [query], query);
|
|
1700
1654
|
```
|
|
1701
1655
|
|
|
1702
|
-
###
|
|
1656
|
+
### ❌ Don't Call refresh() Synchronously
|
|
1657
|
+
|
|
1658
|
+
Calling `ctx.refresh()` during effect execution throws an error:
|
|
1659
|
+
|
|
1660
|
+
```ts
|
|
1661
|
+
// ❌ Wrong - throws error
|
|
1662
|
+
effect((ctx) => {
|
|
1663
|
+
ctx.refresh(); // Error!
|
|
1664
|
+
});
|
|
1665
|
+
|
|
1666
|
+
// ✅ Correct - async or return pattern
|
|
1667
|
+
effect((ctx) => {
|
|
1668
|
+
setTimeout(() => ctx.refresh(), 1000);
|
|
1669
|
+
// or
|
|
1670
|
+
return ctx.refresh;
|
|
1671
|
+
});
|
|
1672
|
+
```
|
|
1673
|
+
|
|
1674
|
+
### ❌ Don't Create Stores Inside Components
|
|
1675
|
+
|
|
1676
|
+
Store specs should be defined at module level:
|
|
1703
1677
|
|
|
1704
1678
|
```ts
|
|
1705
|
-
//
|
|
1706
|
-
|
|
1707
|
-
|
|
1679
|
+
// ❌ Wrong - creates new spec on every render
|
|
1680
|
+
function Component() {
|
|
1681
|
+
const myStore = store({ ... }); // Don't do this!
|
|
1708
1682
|
}
|
|
1709
1683
|
|
|
1710
|
-
//
|
|
1711
|
-
|
|
1712
|
-
|
|
1684
|
+
// ✅ Correct - define at module level
|
|
1685
|
+
const myStore = store({ ... });
|
|
1686
|
+
|
|
1687
|
+
function Component() {
|
|
1688
|
+
const { state } = useStore(({ get }) => get(myStore));
|
|
1713
1689
|
}
|
|
1714
1690
|
```
|
|
1715
1691
|
|
|
1716
|
-
|
|
1692
|
+
### ❌ Don't Forget to Handle All Async States
|
|
1717
1693
|
|
|
1718
|
-
|
|
1694
|
+
```tsx
|
|
1695
|
+
// ❌ Incomplete - misses error and idle states
|
|
1696
|
+
function User() {
|
|
1697
|
+
const { user } = useStore(({ get }) => {
|
|
1698
|
+
const [state] = get(userStore);
|
|
1699
|
+
return { user: state.currentUser };
|
|
1700
|
+
});
|
|
1719
1701
|
|
|
1720
|
-
|
|
1702
|
+
if (user.status === "pending") return <Spinner />;
|
|
1703
|
+
return <div>{user.data.name}</div>; // Crashes if error or idle!
|
|
1704
|
+
}
|
|
1721
1705
|
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
const
|
|
1725
|
-
name: "my-store",
|
|
1726
|
-
state: { count: 0, name: "" },
|
|
1727
|
-
setup({ state }) {
|
|
1728
|
-
return {
|
|
1729
|
-
inc: () => state.count++, // () => void
|
|
1730
|
-
setName: (n: string) => (state.name = n), // (n: string) => string
|
|
1731
|
-
};
|
|
1732
|
-
},
|
|
1733
|
-
});
|
|
1706
|
+
// ✅ Complete handling
|
|
1707
|
+
function User() {
|
|
1708
|
+
const { user } = useStore(...);
|
|
1734
1709
|
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1710
|
+
if (user.status === "idle") return <button>Load User</button>;
|
|
1711
|
+
if (user.status === "pending") return <Spinner />;
|
|
1712
|
+
if (user.status === "error") return <Error error={user.error} />;
|
|
1713
|
+
return <div>{user.data.name}</div>;
|
|
1739
1714
|
}
|
|
1715
|
+
```
|
|
1740
1716
|
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1717
|
+
### Limitation: No Deep Property Tracking
|
|
1718
|
+
|
|
1719
|
+
Storion tracks first-level property access, not deep paths:
|
|
1720
|
+
|
|
1721
|
+
```ts
|
|
1722
|
+
// Both track "profile" property, not "profile.name"
|
|
1723
|
+
const name1 = state.profile.name;
|
|
1724
|
+
const name2 = state.profile.email;
|
|
1725
|
+
|
|
1726
|
+
// To get finer tracking, use pick()
|
|
1727
|
+
const name = pick(() => state.profile.name);
|
|
1728
|
+
```
|
|
1729
|
+
|
|
1730
|
+
### Limitation: Equality Check Timing
|
|
1731
|
+
|
|
1732
|
+
Store-level equality runs on write, component-level equality runs on read:
|
|
1733
|
+
|
|
1734
|
+
```ts
|
|
1735
|
+
// Store level - prevents notification
|
|
1736
|
+
store({
|
|
1737
|
+
equality: { coords: "shallow" },
|
|
1747
1738
|
setup({ state }) {
|
|
1748
1739
|
return {
|
|
1749
|
-
|
|
1750
|
-
|
|
1740
|
+
setCoords: (x, y) => {
|
|
1741
|
+
// If same x,y, no subscribers are notified
|
|
1742
|
+
state.coords = { x, y };
|
|
1751
1743
|
},
|
|
1752
1744
|
};
|
|
1753
1745
|
},
|
|
1754
1746
|
});
|
|
1747
|
+
|
|
1748
|
+
// Component level - prevents re-render
|
|
1749
|
+
const x = pick(() => state.coords.x);
|
|
1750
|
+
// Component only re-renders if x specifically changed
|
|
1755
1751
|
```
|
|
1756
1752
|
|
|
1757
1753
|
---
|
|
1758
1754
|
|
|
1759
1755
|
## Contributing
|
|
1760
1756
|
|
|
1761
|
-
We welcome contributions! Here's how to get started:
|
|
1762
|
-
|
|
1763
1757
|
### Prerequisites
|
|
1764
1758
|
|
|
1765
1759
|
- Node.js 18+
|
|
@@ -1768,40 +1762,20 @@ We welcome contributions! Here's how to get started:
|
|
|
1768
1762
|
### Setup
|
|
1769
1763
|
|
|
1770
1764
|
```bash
|
|
1771
|
-
# Clone the repo
|
|
1772
1765
|
git clone https://github.com/linq2js/storion.git
|
|
1773
1766
|
cd storion
|
|
1774
|
-
|
|
1775
|
-
# Install dependencies
|
|
1776
1767
|
pnpm install
|
|
1777
|
-
|
|
1778
|
-
# Build the library
|
|
1779
1768
|
pnpm --filter storion build
|
|
1780
1769
|
```
|
|
1781
1770
|
|
|
1782
1771
|
### Development
|
|
1783
1772
|
|
|
1784
1773
|
```bash
|
|
1785
|
-
# Watch mode
|
|
1786
|
-
pnpm --filter storion
|
|
1787
|
-
|
|
1788
|
-
# Run tests
|
|
1789
|
-
pnpm --filter storion test
|
|
1790
|
-
|
|
1791
|
-
# Run tests with UI
|
|
1792
|
-
pnpm --filter storion test:ui
|
|
1793
|
-
|
|
1794
|
-
# Type check
|
|
1795
|
-
pnpm --filter storion build:check
|
|
1774
|
+
pnpm --filter storion dev # Watch mode
|
|
1775
|
+
pnpm --filter storion test # Run tests
|
|
1776
|
+
pnpm --filter storion test:ui # Tests with UI
|
|
1796
1777
|
```
|
|
1797
1778
|
|
|
1798
|
-
### Code Style
|
|
1799
|
-
|
|
1800
|
-
- Prefer **type inference** over explicit interfaces (add types only for unions, nullable, discriminated unions)
|
|
1801
|
-
- Keep examples **copy/paste runnable**
|
|
1802
|
-
- Write tests for new features
|
|
1803
|
-
- Follow existing patterns in the codebase
|
|
1804
|
-
|
|
1805
1779
|
### Commit Messages
|
|
1806
1780
|
|
|
1807
1781
|
Use [Conventional Commits](https://www.conventionalcommits.org/):
|
|
@@ -1810,17 +1784,8 @@ Use [Conventional Commits](https://www.conventionalcommits.org/):
|
|
|
1810
1784
|
feat(core): add new feature
|
|
1811
1785
|
fix(react): resolve hook issue
|
|
1812
1786
|
docs: update README
|
|
1813
|
-
chore: bump dependencies
|
|
1814
1787
|
```
|
|
1815
1788
|
|
|
1816
|
-
### Pull Requests
|
|
1817
|
-
|
|
1818
|
-
1. Fork the repo and create your branch from `main`
|
|
1819
|
-
2. Add tests for new functionality
|
|
1820
|
-
3. Ensure all tests pass
|
|
1821
|
-
4. Update documentation as needed
|
|
1822
|
-
5. Submit a PR with a clear description
|
|
1823
|
-
|
|
1824
1789
|
---
|
|
1825
1790
|
|
|
1826
1791
|
## License
|