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