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

|
|
1759
|
-
|
|
1760
|
-
```ts
|
|
1761
|
-
import { devtools } from "storion/devtools";
|
|
1762
|
-
|
|
1763
|
-
const app = container({
|
|
1764
|
-
middleware: devtools({
|
|
1765
|
-
name: "My App",
|
|
1766
|
-
enabled: process.env.NODE_ENV === "development",
|
|
1767
|
-
}),
|
|
1768
|
-
});
|
|
1769
|
-
```
|
|
1770
|
-
|
|
1771
|
-
```tsx
|
|
1772
|
-
import { DevtoolsPanel } from "storion/devtools-panel";
|
|
1773
|
-
|
|
1774
|
-
function App() {
|
|
1775
|
-
return (
|
|
1776
|
-
<>
|
|
1777
|
-
<MyApp />
|
|
1778
|
-
{process.env.NODE_ENV === "development" && <DevtoolsPanel />}
|
|
1779
|
-
</>
|
|
1780
|
-
);
|
|
1781
|
-
}
|
|
1782
|
-
```
|
|
1783
|
-
|
|
1784
|
-
---
|
|
1785
|
-
|
|
1786
|
-
## Error Handling
|
|
1787
|
-
|
|
1788
|
-
### Effect Errors
|
|
1789
|
-
|
|
1790
|
-
Errors in effects are caught and can be handled:
|
|
1791
|
-
|
|
1792
|
-
```ts
|
|
1793
|
-
const myStore = store({
|
|
1794
|
-
name: "myStore",
|
|
1795
|
-
state: { ... },
|
|
1796
|
-
onError: (error) => {
|
|
1797
|
-
console.error("Store error:", error);
|
|
1798
|
-
// Send to error tracking service
|
|
1799
|
-
},
|
|
1800
|
-
setup({ state }) {
|
|
1801
|
-
effect(() => {
|
|
1802
|
-
if (state.invalid) {
|
|
1803
|
-
throw new Error("Invalid state!");
|
|
1804
|
-
}
|
|
1805
|
-
});
|
|
1806
|
-
|
|
1807
|
-
return { ... };
|
|
1808
|
-
},
|
|
1809
|
-
});
|
|
1810
|
-
```
|
|
1811
|
-
|
|
1812
|
-
**Important:** Even if an effect throws an error, it **still re-runs** when its tracked states change. The effect keeps its dependency tracking from before the error occurred.
|
|
1813
|
-
|
|
1814
|
-
```ts
|
|
1815
|
-
effect(() => {
|
|
1816
|
-
console.log("Effect running, count:", state.count); // Tracks `count`
|
|
1817
|
-
|
|
1818
|
-
if (state.count > 5) {
|
|
1819
|
-
throw new Error("Count too high!");
|
|
1820
|
-
}
|
|
1821
|
-
});
|
|
1822
|
-
|
|
1823
|
-
// Later...
|
|
1824
|
-
state.count = 10; // Effect re-runs, throws error, calls onError
|
|
1825
|
-
state.count = 3; // Effect re-runs again, no error this time
|
|
1826
|
-
state.count = 8; // Effect re-runs, throws error again
|
|
1827
|
-
```
|
|
1828
|
-
|
|
1829
|
-
This behavior ensures that effects can recover when state returns to a valid condition.
|
|
1830
|
-
|
|
1831
|
-
### Async Errors
|
|
1832
|
-
|
|
1833
|
-
```ts
|
|
1834
|
-
const userAsync = async(
|
|
1835
|
-
focus("user"),
|
|
1836
|
-
async (ctx) => {
|
|
1837
|
-
const res = await fetch("/api/user", { signal: ctx.signal });
|
|
1838
|
-
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
1839
|
-
return res.json();
|
|
1840
|
-
},
|
|
1841
|
-
{
|
|
1842
|
-
onError: (error) => {
|
|
1843
|
-
// Handle or log the error
|
|
1844
|
-
},
|
|
1845
|
-
retry: {
|
|
1846
|
-
count: 3,
|
|
1847
|
-
delay: (attempt) => Math.min(1000 * 2 ** attempt, 10000),
|
|
1848
|
-
},
|
|
1849
|
-
}
|
|
1850
|
-
);
|
|
1851
|
-
```
|
|
1852
|
-
|
|
1853
|
-
### React Error Boundaries
|
|
1854
|
-
|
|
1855
|
-
```tsx
|
|
1856
|
-
function App() {
|
|
1857
|
-
return (
|
|
1858
|
-
<ErrorBoundary fallback={<ErrorPage />}>
|
|
1859
|
-
<Suspense fallback={<Spinner />}>
|
|
1860
|
-
<UserProfile />
|
|
1861
|
-
</Suspense>
|
|
1862
|
-
</ErrorBoundary>
|
|
1863
|
-
);
|
|
1864
|
-
}
|
|
1865
|
-
|
|
1866
|
-
function UserProfile() {
|
|
1867
|
-
const { user } = useStore(({ get }) => {
|
|
1868
|
-
const [state] = get(userStore);
|
|
1869
|
-
// async.wait() throws on error, caught by ErrorBoundary
|
|
1870
|
-
return { user: async.wait(state.currentUser) };
|
|
1871
|
-
});
|
|
1872
|
-
|
|
1873
|
-
return <div>{user.name}</div>;
|
|
1874
|
-
}
|
|
1875
|
-
```
|
|
1876
|
-
|
|
1877
|
-
---
|
|
1878
|
-
|
|
1879
|
-
## Limitations & Anti-patterns
|
|
1880
|
-
|
|
1881
|
-
### ❌ Don't Mutate Nested State Directly
|
|
1882
|
-
|
|
1883
|
-
Direct mutation only works for first-level properties:
|
|
1884
|
-
|
|
1885
|
-
```ts
|
|
1886
|
-
// ❌ Wrong - won't trigger reactivity
|
|
1887
|
-
state.profile.name = "John";
|
|
1888
|
-
state.items.push("new item");
|
|
1889
|
-
|
|
1890
|
-
// ✅ Correct - use update()
|
|
1891
|
-
update((draft) => {
|
|
1892
|
-
draft.profile.name = "John";
|
|
1893
|
-
draft.items.push("new item");
|
|
1894
|
-
});
|
|
1895
|
-
```
|
|
1896
|
-
|
|
1897
|
-
### ❌ Don't Call get() Inside Actions
|
|
1898
|
-
|
|
1899
|
-
`get()` is for setup-time dependencies, not runtime:
|
|
1900
|
-
|
|
1901
|
-
```ts
|
|
1902
|
-
// ❌ Wrong
|
|
1903
|
-
setup({ get }) {
|
|
1904
|
-
return {
|
|
1905
|
-
doSomething: () => {
|
|
1906
|
-
const [other] = get(otherStore); // Don't do this!
|
|
1907
|
-
},
|
|
1908
|
-
};
|
|
1909
|
-
}
|
|
1910
|
-
|
|
1911
|
-
// ✅ Correct - capture at setup time
|
|
1912
|
-
setup({ get }) {
|
|
1913
|
-
const [otherState, otherActions] = get(otherStore);
|
|
1914
|
-
|
|
1915
|
-
return {
|
|
1916
|
-
doSomething: () => {
|
|
1917
|
-
// Use the captured state/actions
|
|
1918
|
-
if (otherState.ready) { ... }
|
|
1919
|
-
},
|
|
1920
|
-
};
|
|
1921
|
-
}
|
|
1922
|
-
```
|
|
1923
|
-
|
|
1924
|
-
### ❌ Don't Use Async Effects
|
|
1925
|
-
|
|
1926
|
-
Effects must be synchronous:
|
|
1927
|
-
|
|
1928
|
-
```ts
|
|
1929
|
-
// ❌ Wrong
|
|
1930
|
-
effect(async (ctx) => {
|
|
1931
|
-
const data = await fetchData();
|
|
1932
|
-
});
|
|
1933
|
-
|
|
1934
|
-
// ✅ Correct
|
|
1935
|
-
effect((ctx) => {
|
|
1936
|
-
ctx.safe(fetchData()).then((data) => {
|
|
1937
|
-
state.data = data;
|
|
1938
|
-
});
|
|
1939
|
-
});
|
|
1940
|
-
```
|
|
1941
|
-
|
|
1942
|
-
### ❌ Don't Pass Anonymous Functions to trigger()
|
|
1943
|
-
|
|
1944
|
-
Anonymous functions create new references on every render:
|
|
1945
|
-
|
|
1946
|
-
```ts
|
|
1947
|
-
// ❌ Wrong - anonymous function called every render
|
|
1948
|
-
trigger(() => {
|
|
1949
|
-
actions.search(query);
|
|
1950
|
-
}, [query]);
|
|
1951
|
-
|
|
1952
|
-
// ✅ Correct - stable function reference
|
|
1953
|
-
trigger(actions.search, [query], query);
|
|
1954
|
-
```
|
|
1955
|
-
|
|
1956
|
-
### ❌ Don't Call refresh() Synchronously
|
|
1957
|
-
|
|
1958
|
-
Calling `ctx.refresh()` during effect execution throws an error:
|
|
1959
|
-
|
|
1960
|
-
```ts
|
|
1961
|
-
// ❌ Wrong - throws error
|
|
1962
|
-
effect((ctx) => {
|
|
1963
|
-
ctx.refresh(); // Error!
|
|
1964
|
-
});
|
|
1965
|
-
|
|
1966
|
-
// ✅ Correct - async or return pattern
|
|
1967
|
-
effect((ctx) => {
|
|
1968
|
-
setTimeout(() => ctx.refresh(), 1000);
|
|
1969
|
-
// or
|
|
1970
|
-
return ctx.refresh;
|
|
1971
|
-
});
|
|
1972
|
-
```
|
|
1973
|
-
|
|
1974
|
-
### ❌ Don't Create Stores Inside Components
|
|
1975
|
-
|
|
1976
|
-
Store specs should be defined at module level:
|
|
1977
|
-
|
|
1978
|
-
```ts
|
|
1979
|
-
// ❌ Wrong - creates new spec on every render
|
|
1980
|
-
function Component() {
|
|
1981
|
-
const myStore = store({ ... }); // Don't do this!
|
|
1982
|
-
}
|
|
1983
|
-
|
|
1984
|
-
// ✅ Correct - define at module level
|
|
1985
|
-
const myStore = store({ ... });
|
|
1986
|
-
|
|
1987
|
-
function Component() {
|
|
1988
|
-
const { state } = useStore(({ get }) => get(myStore));
|
|
1989
|
-
}
|
|
1990
|
-
```
|
|
1991
|
-
|
|
1992
|
-
### ❌ Don't Forget to Handle All Async States
|
|
1993
|
-
|
|
1994
|
-
```tsx
|
|
1995
|
-
// ❌ Incomplete - misses error and idle states
|
|
1996
|
-
function User() {
|
|
1997
|
-
const { user } = useStore(({ get }) => {
|
|
1998
|
-
const [state] = get(userStore);
|
|
1999
|
-
return { user: state.currentUser };
|
|
2000
|
-
});
|
|
2001
|
-
|
|
2002
|
-
if (user.status === "pending") return <Spinner />;
|
|
2003
|
-
return <div>{user.data.name}</div>; // Crashes if error or idle!
|
|
2004
|
-
}
|
|
2005
|
-
|
|
2006
|
-
// ✅ Complete handling
|
|
2007
|
-
function User() {
|
|
2008
|
-
const { user } = useStore(...);
|
|
2009
|
-
|
|
2010
|
-
if (user.status === "idle") return <button>Load User</button>;
|
|
2011
|
-
if (user.status === "pending") return <Spinner />;
|
|
2012
|
-
if (user.status === "error") return <Error error={user.error} />;
|
|
2013
|
-
return <div>{user.data.name}</div>;
|
|
2014
|
-
}
|
|
2015
|
-
```
|
|
2016
|
-
|
|
2017
|
-
### Limitation: No Deep Property Tracking
|
|
2018
|
-
|
|
2019
|
-
Storion tracks first-level property access, not deep paths:
|
|
2020
|
-
|
|
2021
|
-
```ts
|
|
2022
|
-
// Both track "profile" property, not "profile.name"
|
|
2023
|
-
const name1 = state.profile.name;
|
|
2024
|
-
const name2 = state.profile.email;
|
|
2025
|
-
|
|
2026
|
-
// To get finer tracking, use pick()
|
|
2027
|
-
const name = pick(() => state.profile.name);
|
|
2028
|
-
```
|
|
2029
|
-
|
|
2030
|
-
### Limitation: Equality Check Timing
|
|
2031
|
-
|
|
2032
|
-
Store-level equality runs on write, component-level equality runs on read:
|
|
2033
|
-
|
|
2034
|
-
```ts
|
|
2035
|
-
// Store level - prevents notification
|
|
2036
|
-
store({
|
|
2037
|
-
equality: { coords: "shallow" },
|
|
2038
|
-
setup({ state }) {
|
|
2039
|
-
return {
|
|
2040
|
-
setCoords: (x, y) => {
|
|
2041
|
-
// If same x,y, no subscribers are notified
|
|
2042
|
-
state.coords = { x, y };
|
|
2043
|
-
},
|
|
2044
|
-
};
|
|
2045
|
-
},
|
|
2046
|
-
});
|
|
2047
|
-
|
|
2048
|
-
// Component level - prevents re-render
|
|
2049
|
-
const x = pick(() => state.coords.x);
|
|
2050
|
-
// Component only re-renders if x specifically changed
|
|
2051
|
-
```
|
|
2052
|
-
|
|
2053
|
-
---
|
|
2054
|
-
|
|
2055
|
-
## Contributing
|
|
2056
|
-
|
|
2057
|
-
### Prerequisites
|
|
2058
|
-
|
|
2059
|
-
- Node.js 18+
|
|
2060
|
-
- pnpm 8+
|
|
2061
|
-
|
|
2062
|
-
### Setup
|
|
2063
|
-
|
|
2064
|
-
```bash
|
|
2065
|
-
git clone https://github.com/linq2js/storion.git
|
|
2066
|
-
cd storion
|
|
2067
|
-
pnpm install
|
|
2068
|
-
pnpm --filter storion build
|
|
2069
|
-
```
|
|
2070
|
-
|
|
2071
|
-
### Development
|
|
2072
|
-
|
|
2073
|
-
```bash
|
|
2074
|
-
pnpm --filter storion dev # Watch mode
|
|
2075
|
-
pnpm --filter storion test # Run tests
|
|
2076
|
-
pnpm --filter storion test:ui # Tests with UI
|
|
2077
|
-
```
|
|
2078
|
-
|
|
2079
|
-
### Commit Messages
|
|
2080
|
-
|
|
2081
|
-
Use [Conventional Commits](https://www.conventionalcommits.org/):
|
|
2082
|
-
|
|
2083
|
-
```
|
|
2084
|
-
feat(core): add new feature
|
|
2085
|
-
fix(react): resolve hook issue
|
|
2086
|
-
docs: update README
|
|
2087
|
-
```
|
|
2088
|
-
|
|
2089
|
-
### AI Assistance
|
|
2090
|
-
|
|
2091
|
-
For AI coding assistants, see [AI_GUIDE.md](./AI_GUIDE.md) for rules and patterns when generating Storion code.
|
|
2092
|
-
|
|
2093
|
-
---
|
|
150
|
+
- [Getting Started](https://linq2js.github.io/storion/guide/getting-started.html)
|
|
151
|
+
- [Core Concepts](https://linq2js.github.io/storion/guide/core-concepts.html)
|
|
152
|
+
- [Async State](https://linq2js.github.io/storion/guide/async.html)
|
|
153
|
+
- [API Reference](https://linq2js.github.io/storion/api/store.html)
|
|
154
|
+
- [Live Demos](https://linq2js.github.io/storion/demos.html)
|
|
2094
155
|
|
|
2095
156
|
## License
|
|
2096
157
|
|