storion 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +721 -456
- package/dist/async/async.d.ts +87 -0
- package/dist/async/async.d.ts.map +1 -0
- package/dist/async/index.d.ts +13 -0
- package/dist/async/index.d.ts.map +1 -0
- package/dist/async/index.js +451 -0
- package/dist/async/types.d.ts +275 -0
- package/dist/async/types.d.ts.map +1 -0
- package/dist/collection.d.ts +42 -0
- package/dist/collection.d.ts.map +1 -0
- package/dist/core/container.d.ts +33 -2
- package/dist/core/container.d.ts.map +1 -1
- package/dist/core/createResolver.d.ts +47 -0
- package/dist/core/createResolver.d.ts.map +1 -0
- package/dist/core/equality.d.ts +23 -3
- package/dist/core/equality.d.ts.map +1 -1
- package/dist/core/fnWrapper.d.ts +54 -0
- package/dist/core/fnWrapper.d.ts.map +1 -0
- package/dist/core/pick.d.ts +6 -6
- package/dist/core/pick.d.ts.map +1 -1
- package/dist/core/store.d.ts +8 -8
- package/dist/core/store.d.ts.map +1 -1
- package/dist/core/storeContext.d.ts +63 -0
- package/dist/core/storeContext.d.ts.map +1 -0
- package/dist/devtools/controller.d.ts +4 -0
- package/dist/devtools/controller.d.ts.map +1 -0
- package/dist/devtools/index.d.ts +16 -0
- package/dist/devtools/index.d.ts.map +1 -0
- package/dist/devtools/index.js +229 -0
- package/dist/devtools/middleware.d.ts +22 -0
- package/dist/devtools/middleware.d.ts.map +1 -0
- package/dist/devtools/types.d.ts +116 -0
- package/dist/devtools/types.d.ts.map +1 -0
- package/dist/devtools-panel/DevtoolsPanel.d.ts +17 -0
- package/dist/devtools-panel/DevtoolsPanel.d.ts.map +1 -0
- package/dist/devtools-panel/components/CompareModal.d.ts +10 -0
- package/dist/devtools-panel/components/CompareModal.d.ts.map +1 -0
- package/dist/devtools-panel/components/EventEntry.d.ts +14 -0
- package/dist/devtools-panel/components/EventEntry.d.ts.map +1 -0
- package/dist/devtools-panel/components/EventFilterBar.d.ts +10 -0
- package/dist/devtools-panel/components/EventFilterBar.d.ts.map +1 -0
- package/dist/devtools-panel/components/EventsTab.d.ts +15 -0
- package/dist/devtools-panel/components/EventsTab.d.ts.map +1 -0
- package/dist/devtools-panel/components/ResizeHandle.d.ts +8 -0
- package/dist/devtools-panel/components/ResizeHandle.d.ts.map +1 -0
- package/dist/devtools-panel/components/StoreEntry.d.ts +13 -0
- package/dist/devtools-panel/components/StoreEntry.d.ts.map +1 -0
- package/dist/devtools-panel/components/StoresTab.d.ts +12 -0
- package/dist/devtools-panel/components/StoresTab.d.ts.map +1 -0
- package/dist/devtools-panel/components/TabLayout.d.ts +48 -0
- package/dist/devtools-panel/components/TabLayout.d.ts.map +1 -0
- package/dist/devtools-panel/components/icons.d.ts +27 -0
- package/dist/devtools-panel/components/icons.d.ts.map +1 -0
- package/dist/devtools-panel/components/index.d.ts +15 -0
- package/dist/devtools-panel/components/index.d.ts.map +1 -0
- package/dist/devtools-panel/hooks.d.ts +23 -0
- package/dist/devtools-panel/hooks.d.ts.map +1 -0
- package/dist/devtools-panel/index.d.ts +25 -0
- package/dist/devtools-panel/index.d.ts.map +1 -0
- package/dist/devtools-panel/index.js +3326 -0
- package/dist/devtools-panel/mount.d.ts +41 -0
- package/dist/devtools-panel/mount.d.ts.map +1 -0
- package/dist/devtools-panel/styles.d.ts +50 -0
- package/dist/devtools-panel/styles.d.ts.map +1 -0
- package/dist/devtools-panel/types.d.ts +15 -0
- package/dist/devtools-panel/types.d.ts.map +1 -0
- package/dist/devtools-panel/utils.d.ts +21 -0
- package/dist/devtools-panel/utils.d.ts.map +1 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/is.d.ts +69 -0
- package/dist/is.d.ts.map +1 -0
- package/dist/react/create.d.ts +1 -1
- package/dist/react/index.d.ts +1 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +209 -33
- package/dist/react/useLocalStore.d.ts.map +1 -1
- package/dist/react/useStore.d.ts +2 -2
- package/dist/react/useStore.d.ts.map +1 -1
- package/dist/react/withStore.d.ts +140 -0
- package/dist/react/withStore.d.ts.map +1 -0
- package/dist/{index-rLf6DusB.js → store-XP2pujaJ.js} +537 -740
- package/dist/storion.js +740 -9
- package/dist/trigger.d.ts +40 -0
- package/dist/trigger.d.ts.map +1 -0
- package/dist/types.d.ts +516 -50
- package/dist/types.d.ts.map +1 -1
- package/package.json +13 -1
package/README.md
CHANGED
|
@@ -1,384 +1,589 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/linq2js/storion/main/.github/logo.svg" alt="Storion Logo" width="120" height="120" />
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<h1 align="center">Storion</h1>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
<strong>Reactive stores for modern apps. Type-safe. Auto-tracked. Effortlessly composable.</strong>
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
<p align="center">
|
|
12
|
+
<a href="https://www.npmjs.com/package/storion"><img src="https://img.shields.io/npm/v/storion?style=flat-square&color=blue" alt="npm version"></a>
|
|
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
|
+
<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
|
+
<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
|
+
</p>
|
|
18
|
+
|
|
19
|
+
<p align="center">
|
|
20
|
+
<a href="#features">Features</a> •
|
|
21
|
+
<a href="#installation">Installation</a> •
|
|
22
|
+
<a href="#quick-start">Quick Start</a> •
|
|
23
|
+
<a href="#usage">Usage</a> •
|
|
24
|
+
<a href="#api-reference">API Reference</a> •
|
|
25
|
+
<a href="#contributing">Contributing</a>
|
|
26
|
+
</p>
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## What is Storion?
|
|
31
|
+
|
|
32
|
+
Storion is a lightweight state management library with **automatic dependency tracking**:
|
|
33
|
+
|
|
34
|
+
- **You read state** → Storion tracks the read
|
|
35
|
+
- **That read changes** → only then your effect/component updates
|
|
36
|
+
|
|
37
|
+
No manual selectors to "optimize", no accidental over-subscription to large objects. Just write natural code and let Storion handle the reactivity.
|
|
38
|
+
|
|
39
|
+
```tsx
|
|
40
|
+
// Component only re-renders when `count` actually changes
|
|
41
|
+
function Counter() {
|
|
42
|
+
const { count, inc } = useStore(({ get }) => {
|
|
43
|
+
const [state, actions] = get(counterStore);
|
|
44
|
+
return { count: state.count, inc: actions.inc };
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
return <button onClick={inc}>{count}</button>;
|
|
48
|
+
}
|
|
49
|
+
```
|
|
2
50
|
|
|
3
|
-
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## Features
|
|
54
|
+
|
|
55
|
+
- 🎯 **Auto-tracking** — Dependencies tracked automatically when you read state
|
|
56
|
+
- 🔒 **Type-safe** — Full TypeScript support with excellent inference
|
|
57
|
+
- ⚡ **Fine-grained updates** — Only re-render what actually changed
|
|
58
|
+
- 🧩 **Composable** — Mix stores, use DI, create derived values
|
|
59
|
+
- 🔄 **Reactive effects** — Side effects that automatically respond to state changes
|
|
60
|
+
- 📦 **Tiny footprint** — ~4KB minified + gzipped
|
|
61
|
+
- 🛠️ **DevTools** — Built-in devtools panel for debugging
|
|
62
|
+
- 🔌 **Middleware** — Extensible with conditional middleware patterns
|
|
63
|
+
- ⏳ **Async helpers** — First-class async state management with cancellation
|
|
64
|
+
|
|
65
|
+
---
|
|
4
66
|
|
|
5
|
-
|
|
6
|
-
[](https://bundlephobia.com/package/storion)
|
|
7
|
-
[](LICENSE)
|
|
67
|
+
## Installation
|
|
8
68
|
|
|
9
69
|
```bash
|
|
70
|
+
# npm
|
|
10
71
|
npm install storion
|
|
72
|
+
|
|
73
|
+
# pnpm
|
|
74
|
+
pnpm add storion
|
|
75
|
+
|
|
76
|
+
# yarn
|
|
77
|
+
yarn add storion
|
|
11
78
|
```
|
|
12
79
|
|
|
13
|
-
|
|
80
|
+
**Peer dependency:** React is optional, required only if using `storion/react`.
|
|
14
81
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
- 🌐 **Framework-agnostic** — Works with React, or standalone with vanilla JS.
|
|
82
|
+
```bash
|
|
83
|
+
# If using React integration
|
|
84
|
+
npm install storion react
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
---
|
|
22
88
|
|
|
23
89
|
## Quick Start
|
|
24
90
|
|
|
25
|
-
### Single
|
|
91
|
+
### Option 1: Single Store with `create()` (Simplest)
|
|
92
|
+
|
|
93
|
+
Perfect for small apps or isolated features:
|
|
26
94
|
|
|
27
95
|
```tsx
|
|
28
96
|
import { create } from "storion/react";
|
|
29
97
|
|
|
30
|
-
// Define and create in one step
|
|
31
98
|
const [counter, useCounter] = create({
|
|
32
99
|
name: "counter",
|
|
33
100
|
state: { count: 0 },
|
|
34
101
|
setup({ state }) {
|
|
35
102
|
return {
|
|
36
|
-
|
|
37
|
-
|
|
103
|
+
inc: () => state.count++,
|
|
104
|
+
dec: () => state.count--,
|
|
38
105
|
};
|
|
39
106
|
},
|
|
40
107
|
});
|
|
41
108
|
|
|
109
|
+
// Use in React
|
|
42
110
|
function Counter() {
|
|
43
|
-
const { count,
|
|
111
|
+
const { count, inc, dec } = useCounter((state, actions) => ({
|
|
44
112
|
count: state.count,
|
|
45
|
-
|
|
113
|
+
inc: actions.inc,
|
|
114
|
+
dec: actions.dec,
|
|
46
115
|
}));
|
|
47
116
|
|
|
48
|
-
return
|
|
117
|
+
return (
|
|
118
|
+
<div>
|
|
119
|
+
<button onClick={dec}>-</button>
|
|
120
|
+
<span>{count}</span>
|
|
121
|
+
<button onClick={inc}>+</button>
|
|
122
|
+
</div>
|
|
123
|
+
);
|
|
49
124
|
}
|
|
125
|
+
|
|
126
|
+
// Use outside React
|
|
127
|
+
counter.actions.inc();
|
|
128
|
+
console.log(counter.state.count);
|
|
50
129
|
```
|
|
51
130
|
|
|
52
|
-
|
|
131
|
+
### Option 2: Multi-Store with Container (Scalable)
|
|
53
132
|
|
|
54
|
-
|
|
133
|
+
Best for larger apps with multiple stores:
|
|
55
134
|
|
|
56
135
|
```tsx
|
|
57
|
-
import { store, container
|
|
136
|
+
import { store, container } from "storion";
|
|
137
|
+
import { StoreProvider, useStore } from "storion/react";
|
|
58
138
|
|
|
59
139
|
// Define stores
|
|
60
|
-
const
|
|
61
|
-
name: "
|
|
62
|
-
state: {
|
|
63
|
-
setup() {
|
|
64
|
-
return {
|
|
140
|
+
const authStore = store({
|
|
141
|
+
name: "auth",
|
|
142
|
+
state: { userId: null as string | null },
|
|
143
|
+
setup({ state }) {
|
|
144
|
+
return {
|
|
145
|
+
login: (id: string) => {
|
|
146
|
+
state.userId = id;
|
|
147
|
+
},
|
|
148
|
+
logout: () => {
|
|
149
|
+
state.userId = null;
|
|
150
|
+
},
|
|
151
|
+
};
|
|
65
152
|
},
|
|
66
153
|
});
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
154
|
+
|
|
155
|
+
const todosStore = store({
|
|
156
|
+
name: "todos",
|
|
157
|
+
state: { items: [] as string[] },
|
|
158
|
+
setup({ state, update }) {
|
|
159
|
+
return {
|
|
160
|
+
add: (text: string) => {
|
|
161
|
+
update((draft) => {
|
|
162
|
+
draft.items.push(text);
|
|
163
|
+
});
|
|
164
|
+
},
|
|
165
|
+
remove: (index: number) => {
|
|
166
|
+
update((draft) => {
|
|
167
|
+
draft.items.splice(index, 1);
|
|
168
|
+
});
|
|
169
|
+
},
|
|
170
|
+
};
|
|
72
171
|
},
|
|
73
172
|
});
|
|
74
173
|
|
|
75
|
-
// Create
|
|
174
|
+
// Create container
|
|
76
175
|
const app = container();
|
|
77
176
|
|
|
177
|
+
// Provide to React
|
|
78
178
|
function App() {
|
|
79
179
|
return (
|
|
80
180
|
<StoreProvider container={app}>
|
|
81
|
-
<
|
|
181
|
+
<Screen />
|
|
82
182
|
</StoreProvider>
|
|
83
183
|
);
|
|
84
184
|
}
|
|
85
185
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const [
|
|
90
|
-
|
|
186
|
+
// Consume multiple stores
|
|
187
|
+
function Screen() {
|
|
188
|
+
const { userId, items, add, login } = useStore(({ get }) => {
|
|
189
|
+
const [auth, authActions] = get(authStore);
|
|
190
|
+
const [todos, todosActions] = get(todosStore);
|
|
191
|
+
return {
|
|
192
|
+
userId: auth.userId,
|
|
193
|
+
items: todos.items,
|
|
194
|
+
add: todosActions.add,
|
|
195
|
+
login: authActions.login,
|
|
196
|
+
};
|
|
91
197
|
});
|
|
92
|
-
|
|
198
|
+
|
|
199
|
+
return (
|
|
200
|
+
<div>
|
|
201
|
+
<p>User: {userId ?? "Not logged in"}</p>
|
|
202
|
+
<button onClick={() => login("user-1")}>Login</button>
|
|
203
|
+
<ul>
|
|
204
|
+
{items.map((item, i) => (
|
|
205
|
+
<li key={i}>{item}</li>
|
|
206
|
+
))}
|
|
207
|
+
</ul>
|
|
208
|
+
<button onClick={() => add("New todo")}>Add Todo</button>
|
|
209
|
+
</div>
|
|
210
|
+
);
|
|
93
211
|
}
|
|
94
212
|
```
|
|
95
213
|
|
|
96
214
|
---
|
|
97
215
|
|
|
98
|
-
##
|
|
216
|
+
## Usage
|
|
99
217
|
|
|
100
|
-
|
|
101
|
-
┌─────────────────────────────────────────────────────────────────┐
|
|
102
|
-
│ Container │
|
|
103
|
-
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
|
|
104
|
-
│ │ Store A │ │ Store B │ │ Store C │ │
|
|
105
|
-
│ │ ┌───────────┐ │ │ ┌───────────┐ │ │ ┌───────────┐ │ │
|
|
106
|
-
│ │ │ State │◄─┼──┼──│ State │ │ │ │ State │ │ │
|
|
107
|
-
│ │ └───────────┘ │ │ └───────────┘ │ │ └───────────┘ │ │
|
|
108
|
-
│ │ ┌───────────┐ │ │ ┌───────────┐ │ │ ┌───────────┐ │ │
|
|
109
|
-
│ │ │ Actions │──┼──┼─►│ Actions │ │ │ │ Actions │ │ │
|
|
110
|
-
│ │ └───────────┘ │ │ └───────────┘ │ │ └───────────┘ │ │
|
|
111
|
-
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
|
|
112
|
-
│ ▲ ▲ ▲ │
|
|
113
|
-
└────────────┼───────────────────┼───────────────────┼─────────────┘
|
|
114
|
-
│ │ │
|
|
115
|
-
┌───────┴───────┐ ┌───────┴───────┐ ┌───────┴───────┐
|
|
116
|
-
│ useStore │ │ Effect │ │ subscribe │
|
|
117
|
-
│ (React Hook) │ │ (Reactive) │ │ (Vanilla JS) │
|
|
118
|
-
└───────────────┘ └───────────────┘ └───────────────┘
|
|
119
|
-
```
|
|
120
|
-
|
|
121
|
-
### Core Concepts
|
|
218
|
+
### Defining a Store
|
|
122
219
|
|
|
123
|
-
|
|
124
|
-
| ------------------ | ------------------------------------------------------------------------------------ |
|
|
125
|
-
| **Store Spec** | Blueprint defining state shape, setup function, and options. Created with `store()`. |
|
|
126
|
-
| **Store Instance** | Live store with reactive state and actions. Created when accessed via container. |
|
|
127
|
-
| **Container** | Factory that creates and manages store instances. Enables dependency injection. |
|
|
128
|
-
| **Effect** | Reactive function that auto-tracks dependencies and re-runs on changes. |
|
|
129
|
-
| **Action** | Function returned from `setup()` that can mutate state. |
|
|
220
|
+
**The problem:** You have related pieces of state and operations that belong together, but managing them with `useState` leads to scattered logic and prop drilling.
|
|
130
221
|
|
|
131
|
-
|
|
222
|
+
**With Storion:** Group state and actions in a single store. Actions have direct access to state, and the store can be shared across your app.
|
|
132
223
|
|
|
133
|
-
```
|
|
134
|
-
|
|
135
|
-
│ UI / App │─────►│ Action │─────►│ State │
|
|
136
|
-
└──────────────┘ └──────────────┘ └──────────────┘
|
|
137
|
-
▲ │
|
|
138
|
-
│ ┌──────────────┐ │
|
|
139
|
-
└──────────────│ Effect │◄────────────┘
|
|
140
|
-
└──────────────┘
|
|
141
|
-
```
|
|
224
|
+
```ts
|
|
225
|
+
import { store } from "storion";
|
|
142
226
|
|
|
143
|
-
|
|
227
|
+
export const userStore = store({
|
|
228
|
+
name: "user",
|
|
229
|
+
state: {
|
|
230
|
+
profile: { name: "", email: "" },
|
|
231
|
+
theme: "light" as "light" | "dark",
|
|
232
|
+
},
|
|
233
|
+
setup({ state, update }) {
|
|
234
|
+
return {
|
|
235
|
+
// Direct mutation - only works for first-level properties
|
|
236
|
+
setTheme: (theme: "light" | "dark") => {
|
|
237
|
+
state.theme = theme;
|
|
238
|
+
},
|
|
144
239
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
}
|
|
240
|
+
// For nested state, use update() with immer-style draft
|
|
241
|
+
setName: (name: string) => {
|
|
242
|
+
update((draft) => {
|
|
243
|
+
draft.profile.name = name;
|
|
244
|
+
});
|
|
245
|
+
},
|
|
152
246
|
|
|
153
|
-
//
|
|
154
|
-
|
|
155
|
-
|
|
247
|
+
// Batch update multiple nested properties
|
|
248
|
+
updateProfile: (profile: Partial<typeof state.profile>) => {
|
|
249
|
+
update((draft) => {
|
|
250
|
+
Object.assign(draft.profile, profile);
|
|
251
|
+
});
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
},
|
|
156
255
|
});
|
|
157
|
-
|
|
158
|
-
// ❌ Wrong - mutation outside action/effect
|
|
159
|
-
function Component() {
|
|
160
|
-
const [s] = resolve(store);
|
|
161
|
-
s.count++; // Don't do this! Use actions instead.
|
|
162
|
-
}
|
|
163
256
|
```
|
|
164
257
|
|
|
165
|
-
|
|
258
|
+
> **Important:** Direct mutation (`state.prop = value`) only works for **first-level properties**. For nested state or array mutations, always use `update()` which provides an immer-powered draft.
|
|
166
259
|
|
|
167
|
-
|
|
168
|
-
store(options) container.get(spec) component unmounts
|
|
169
|
-
│ │ │
|
|
170
|
-
▼ ▼ ▼
|
|
171
|
-
┌─────────┐ ┌─────────┐ ┌─────────┐
|
|
172
|
-
│ Spec │─────────────►│Instance │───────────────►│Disposed │
|
|
173
|
-
│ Created │ │ Created │ │(if auto)│
|
|
174
|
-
└─────────┘ └─────────┘ └─────────┘
|
|
175
|
-
│
|
|
176
|
-
▼
|
|
177
|
-
┌───────────────────┐
|
|
178
|
-
│ setup() runs │
|
|
179
|
-
│ • resolve() deps │
|
|
180
|
-
│ • effects start │
|
|
181
|
-
│ • actions created │
|
|
182
|
-
└───────────────────┘
|
|
183
|
-
```
|
|
260
|
+
### Using Focus (Lens-like State Access)
|
|
184
261
|
|
|
185
|
-
|
|
262
|
+
**The problem:** Updating deeply nested state is verbose. You end up writing `update(draft => { draft.a.b.c = value })` repeatedly, or creating many small `update()` calls.
|
|
186
263
|
|
|
187
|
-
|
|
264
|
+
**With Storion:** `focus()` gives you a getter/setter pair for any path. The setter supports direct values, reducers, and immer-style mutations.
|
|
188
265
|
|
|
189
|
-
|
|
266
|
+
```ts
|
|
267
|
+
import { store } from "storion";
|
|
190
268
|
|
|
191
|
-
|
|
269
|
+
export const settingsStore = store({
|
|
270
|
+
name: "settings",
|
|
271
|
+
state: {
|
|
272
|
+
user: { name: "", email: "" },
|
|
273
|
+
preferences: {
|
|
274
|
+
theme: "light" as "light" | "dark",
|
|
275
|
+
notifications: true,
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
setup({ focus }) {
|
|
279
|
+
// Focus on nested paths - returns [getter, setter]
|
|
280
|
+
const [getTheme, setTheme] = focus("preferences.theme");
|
|
281
|
+
const [getUser, setUser] = focus("user");
|
|
192
282
|
|
|
193
|
-
|
|
194
|
-
//
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
283
|
+
return {
|
|
284
|
+
// Direct value
|
|
285
|
+
setTheme: (theme: "light" | "dark") => {
|
|
286
|
+
setTheme(theme);
|
|
287
|
+
},
|
|
288
|
+
|
|
289
|
+
// Reducer - returns new value
|
|
290
|
+
toggleTheme: () => {
|
|
291
|
+
setTheme((prev) => (prev === "light" ? "dark" : "light"));
|
|
292
|
+
},
|
|
293
|
+
|
|
294
|
+
// Produce - immer-style mutation (no return)
|
|
295
|
+
updateUserName: (name: string) => {
|
|
296
|
+
setUser((draft) => {
|
|
297
|
+
draft.name = name;
|
|
298
|
+
});
|
|
299
|
+
},
|
|
300
|
+
|
|
301
|
+
// Getter is reactive - can be used in effects
|
|
302
|
+
getTheme,
|
|
303
|
+
};
|
|
304
|
+
},
|
|
202
305
|
});
|
|
203
|
-
// Only re-renders when user.name specifically changes
|
|
204
306
|
```
|
|
205
307
|
|
|
206
|
-
|
|
308
|
+
**Focus setter supports three patterns:**
|
|
207
309
|
|
|
208
|
-
|
|
310
|
+
| Pattern | Example | Use when |
|
|
311
|
+
| ------------ | ------------------------------- | ----------------------------- |
|
|
312
|
+
| Direct value | `set(newValue)` | Replacing entire value |
|
|
313
|
+
| Reducer | `set(prev => newValue)` | Computing from previous |
|
|
314
|
+
| Produce | `set(draft => { draft.x = y })` | Partial updates (immer-style) |
|
|
209
315
|
|
|
210
|
-
|
|
211
|
-
import { pick, useStore } from "storion/react";
|
|
316
|
+
### Reactive Effects
|
|
212
317
|
|
|
213
|
-
|
|
214
|
-
const [state] = resolve(userStore);
|
|
215
|
-
return {
|
|
216
|
-
// Only re-renders when the RESULT changes, not when first/last change
|
|
217
|
-
fullName: pick(() => `${state.user.first} ${state.user.last}`),
|
|
218
|
-
};
|
|
219
|
-
});
|
|
220
|
-
```
|
|
318
|
+
**The problem:** You need to sync with external systems (WebSocket, localStorage, event listeners) when state changes, and properly clean up when the state changes again or the component unmounts.
|
|
221
319
|
|
|
222
|
-
|
|
320
|
+
**With Storion:** Effects automatically track which state properties you read and re-run only when those change. Register cleanup with `ctx.onCleanup()`.
|
|
223
321
|
|
|
224
|
-
|
|
322
|
+
```ts
|
|
323
|
+
import { store, effect } from "storion";
|
|
225
324
|
|
|
226
|
-
|
|
227
|
-
|
|
325
|
+
export const syncStore = store({
|
|
326
|
+
name: "sync",
|
|
327
|
+
state: {
|
|
328
|
+
userId: null as string | null,
|
|
329
|
+
syncStatus: "idle" as "idle" | "syncing" | "synced",
|
|
330
|
+
},
|
|
331
|
+
setup({ state }) {
|
|
332
|
+
effect((ctx) => {
|
|
333
|
+
// Effect tracks state.userId and re-runs when it changes
|
|
334
|
+
if (!state.userId) return;
|
|
335
|
+
|
|
336
|
+
const ws = new WebSocket(`/ws?user=${state.userId}`);
|
|
337
|
+
state.syncStatus = "syncing";
|
|
228
338
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
339
|
+
ws.onopen = () => {
|
|
340
|
+
state.syncStatus = "synced";
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
// Cleanup when effect re-runs or store disposes
|
|
344
|
+
ctx.onCleanup(() => ws.close());
|
|
345
|
+
});
|
|
235
346
|
|
|
236
347
|
return {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
348
|
+
login: (id: string) => {
|
|
349
|
+
state.userId = id;
|
|
350
|
+
},
|
|
351
|
+
logout: () => {
|
|
352
|
+
state.userId = null;
|
|
240
353
|
},
|
|
241
354
|
};
|
|
242
355
|
},
|
|
243
356
|
});
|
|
244
357
|
```
|
|
245
358
|
|
|
246
|
-
###
|
|
359
|
+
### Effect with Safe Async
|
|
247
360
|
|
|
248
|
-
|
|
361
|
+
**The problem:** When an effect re-runs before an async operation completes, you get stale data or "state update on unmounted component" warnings. Managing this manually is error-prone.
|
|
249
362
|
|
|
250
|
-
|
|
251
|
-
import { store, effect } from "storion/react";
|
|
252
|
-
|
|
253
|
-
const analyticsStore = store({
|
|
254
|
-
name: "analytics",
|
|
255
|
-
state: { pageViews: 0 },
|
|
256
|
-
setup({ state, resolve }) {
|
|
257
|
-
const [routerState] = resolve(routerStore);
|
|
258
|
-
|
|
259
|
-
// Runs whenever routerState.path changes
|
|
260
|
-
effect(() => {
|
|
261
|
-
trackPageView(routerState.path);
|
|
262
|
-
state.pageViews++;
|
|
263
|
-
});
|
|
363
|
+
**With Storion:** Use `ctx.safe()` to wrap promises that should be ignored if stale, or `ctx.signal` for fetch cancellation.
|
|
264
364
|
|
|
265
|
-
|
|
266
|
-
|
|
365
|
+
```ts
|
|
366
|
+
effect((ctx) => {
|
|
367
|
+
const userId = state.userId;
|
|
368
|
+
if (!userId) return;
|
|
369
|
+
|
|
370
|
+
// ctx.safe() wraps promises to never resolve if stale
|
|
371
|
+
ctx.safe(fetchUserData(userId)).then((data) => {
|
|
372
|
+
// Only runs if effect hasn't re-run
|
|
373
|
+
state.userData = data;
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
// Or use abort signal for fetch
|
|
377
|
+
fetch(`/api/user/${userId}`, { signal: ctx.signal })
|
|
378
|
+
.then((res) => res.json())
|
|
379
|
+
.then((data) => {
|
|
380
|
+
state.userData = data;
|
|
381
|
+
});
|
|
267
382
|
});
|
|
268
383
|
```
|
|
269
384
|
|
|
270
|
-
###
|
|
385
|
+
### Fine-Grained Updates with `pick()`
|
|
271
386
|
|
|
272
|
-
|
|
387
|
+
**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.
|
|
388
|
+
|
|
389
|
+
**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.
|
|
273
390
|
|
|
274
391
|
```tsx
|
|
275
|
-
import {
|
|
392
|
+
import { pick } from "storion";
|
|
276
393
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
return {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
394
|
+
function UserName() {
|
|
395
|
+
// Without pick: re-renders when ANY profile property changes
|
|
396
|
+
const { name } = useStore(({ get }) => {
|
|
397
|
+
const [state] = get(userStore);
|
|
398
|
+
return { name: state.profile.name };
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
// With pick: re-renders ONLY when profile.name changes
|
|
402
|
+
const { name } = useStore(({ get }) => {
|
|
403
|
+
const [state] = get(userStore);
|
|
404
|
+
return { name: pick(() => state.profile.name) };
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
return <h1>{name}</h1>;
|
|
408
|
+
}
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
### Async State Management
|
|
412
|
+
|
|
413
|
+
**The problem:** Every async operation needs loading, error, and success states. You write the same boilerplate: `isLoading`, `error`, `data`, plus handling race conditions, retries, and cancellation.
|
|
414
|
+
|
|
415
|
+
**With Storion:** The `async()` helper manages all async states automatically. Choose "fresh" mode (clear data while loading) or "stale" mode (keep previous data like SWR).
|
|
416
|
+
|
|
417
|
+
```ts
|
|
418
|
+
import { store } from "storion";
|
|
419
|
+
import { async, type AsyncState } from "storion/async";
|
|
420
|
+
|
|
421
|
+
interface Product {
|
|
422
|
+
id: string;
|
|
423
|
+
name: string;
|
|
424
|
+
price: number;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export const productStore = store({
|
|
428
|
+
name: "products",
|
|
429
|
+
state: {
|
|
430
|
+
// Fresh mode: data is undefined during loading
|
|
431
|
+
featured: async.fresh<Product>(),
|
|
432
|
+
// Stale mode: preserves previous data during loading (SWR pattern)
|
|
433
|
+
list: async.stale<Product[]>([]),
|
|
434
|
+
},
|
|
435
|
+
setup({ focus }) {
|
|
436
|
+
const featuredActions = async<Product, "fresh", [string]>(
|
|
437
|
+
focus("featured"),
|
|
438
|
+
async (ctx, productId) => {
|
|
439
|
+
const res = await fetch(`/api/products/${productId}`, {
|
|
440
|
+
signal: ctx.signal,
|
|
441
|
+
});
|
|
442
|
+
return res.json();
|
|
287
443
|
},
|
|
444
|
+
{
|
|
445
|
+
retry: { count: 3, delay: (attempt) => attempt * 1000 },
|
|
446
|
+
onError: (err) => console.error("Failed to fetch product:", err),
|
|
447
|
+
}
|
|
448
|
+
);
|
|
449
|
+
|
|
450
|
+
const listActions = async<Product[], "stale", []>(
|
|
451
|
+
focus("list"),
|
|
452
|
+
async () => {
|
|
453
|
+
const res = await fetch("/api/products");
|
|
454
|
+
return res.json();
|
|
455
|
+
}
|
|
456
|
+
);
|
|
457
|
+
|
|
458
|
+
return {
|
|
459
|
+
fetchFeatured: featuredActions.dispatch,
|
|
460
|
+
fetchList: listActions.dispatch,
|
|
461
|
+
refreshList: listActions.refresh,
|
|
462
|
+
cancelFeatured: featuredActions.cancel,
|
|
288
463
|
};
|
|
289
464
|
},
|
|
290
465
|
});
|
|
291
466
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
const
|
|
467
|
+
// In React - handle async states
|
|
468
|
+
function ProductList() {
|
|
469
|
+
const { list, fetchList } = useStore(({ get }) => {
|
|
470
|
+
const [state, actions] = get(productStore);
|
|
471
|
+
return { list: state.list, fetchList: actions.fetchList };
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
useEffect(() => {
|
|
475
|
+
fetchList();
|
|
476
|
+
}, []);
|
|
477
|
+
|
|
478
|
+
if (list.status === "pending" && !list.data?.length) {
|
|
479
|
+
return <Spinner />;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
if (list.status === "error") {
|
|
483
|
+
return <Error message={list.error.message} />;
|
|
484
|
+
}
|
|
295
485
|
|
|
296
486
|
return (
|
|
297
|
-
<
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
value={state.password}
|
|
304
|
-
onChange={(e) => actions.setPassword(e.target.value)}
|
|
305
|
-
/>
|
|
306
|
-
<button disabled={!dirty()}>Submit</button>
|
|
307
|
-
<button type="button" onClick={reset}>
|
|
308
|
-
Reset
|
|
309
|
-
</button>
|
|
310
|
-
</form>
|
|
487
|
+
<ul>
|
|
488
|
+
{list.data?.map((p) => (
|
|
489
|
+
<li key={p.id}>{p.name}</li>
|
|
490
|
+
))}
|
|
491
|
+
{list.status === "pending" && <li>Loading more...</li>}
|
|
492
|
+
</ul>
|
|
311
493
|
);
|
|
312
494
|
}
|
|
313
495
|
```
|
|
314
496
|
|
|
315
|
-
###
|
|
497
|
+
### Dependency Injection
|
|
316
498
|
|
|
317
|
-
|
|
499
|
+
**The problem:** Your stores need shared services (API clients, loggers, config) but you don't want to import singletons directly—it makes testing hard and creates tight coupling.
|
|
500
|
+
|
|
501
|
+
**With Storion:** The container acts as a DI container. Define factory functions and resolve them with `get()`. Services are cached as singletons automatically.
|
|
502
|
+
|
|
503
|
+
```ts
|
|
504
|
+
import { container, type Resolver } from "storion";
|
|
505
|
+
|
|
506
|
+
// Define service factory
|
|
507
|
+
interface ApiService {
|
|
508
|
+
get<T>(url: string): Promise<T>;
|
|
509
|
+
post<T>(url: string, data: unknown): Promise<T>;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function createApiService(resolver: Resolver): ApiService {
|
|
513
|
+
const baseUrl = resolver.get(configFactory).apiUrl;
|
|
318
514
|
|
|
319
|
-
```tsx
|
|
320
|
-
// In your store's setup function:
|
|
321
|
-
setup({ state, update }) {
|
|
322
515
|
return {
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
});
|
|
516
|
+
async get(url) {
|
|
517
|
+
const res = await fetch(`${baseUrl}${url}`);
|
|
518
|
+
return res.json();
|
|
327
519
|
},
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
520
|
+
async post(url, data) {
|
|
521
|
+
const res = await fetch(`${baseUrl}${url}`, {
|
|
522
|
+
method: "POST",
|
|
523
|
+
body: JSON.stringify(data),
|
|
332
524
|
});
|
|
525
|
+
return res.json();
|
|
333
526
|
},
|
|
334
527
|
};
|
|
335
528
|
}
|
|
336
|
-
```
|
|
337
|
-
|
|
338
|
-
### ⚙️ Flexible Equality
|
|
339
529
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
import { store } from "storion/react";
|
|
530
|
+
function configFactory(): { apiUrl: string } {
|
|
531
|
+
return { apiUrl: process.env.API_URL ?? "http://localhost:3000" };
|
|
532
|
+
}
|
|
344
533
|
|
|
534
|
+
// Use in store
|
|
345
535
|
const userStore = store({
|
|
346
536
|
name: "user",
|
|
347
|
-
state: {
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
default: "strict",
|
|
357
|
-
},
|
|
358
|
-
setup({ state }) {
|
|
359
|
-
/* ... */
|
|
537
|
+
state: { user: null },
|
|
538
|
+
setup({ get }) {
|
|
539
|
+
const api = get(createApiService); // Singleton, cached
|
|
540
|
+
|
|
541
|
+
return {
|
|
542
|
+
fetchUser: async (id: string) => {
|
|
543
|
+
return api.get(`/users/${id}`);
|
|
544
|
+
},
|
|
545
|
+
};
|
|
360
546
|
},
|
|
361
547
|
});
|
|
362
548
|
```
|
|
363
549
|
|
|
364
|
-
###
|
|
550
|
+
### Middleware
|
|
551
|
+
|
|
552
|
+
**The problem:** You need cross-cutting behavior (logging, persistence, devtools) applied to some or all stores, without modifying each store individually.
|
|
365
553
|
|
|
366
|
-
|
|
554
|
+
**With Storion:** Compose middleware and apply it conditionally using patterns like `"user*"` (startsWith), `"*Store"` (endsWith), or custom predicates.
|
|
367
555
|
|
|
368
|
-
```
|
|
369
|
-
import {
|
|
556
|
+
```ts
|
|
557
|
+
import { container, compose, applyFor, applyExcept } from "storion";
|
|
558
|
+
|
|
559
|
+
// Logging middleware
|
|
560
|
+
const loggingMiddleware = (spec, next) => {
|
|
561
|
+
const instance = next(spec);
|
|
562
|
+
console.log(`Store created: ${spec.name}`);
|
|
563
|
+
return instance;
|
|
564
|
+
};
|
|
370
565
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
566
|
+
// Persistence middleware
|
|
567
|
+
const persistMiddleware = (spec, next) => {
|
|
568
|
+
const instance = next(spec);
|
|
569
|
+
// Add persistence logic...
|
|
570
|
+
return instance;
|
|
571
|
+
};
|
|
374
572
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
573
|
+
const app = container({
|
|
574
|
+
middleware: compose(
|
|
575
|
+
// Apply logging to all stores starting with "user"
|
|
576
|
+
applyFor("user*", loggingMiddleware),
|
|
378
577
|
|
|
379
|
-
//
|
|
380
|
-
|
|
381
|
-
|
|
578
|
+
// Apply persistence except for cache stores
|
|
579
|
+
applyExcept("*Cache", persistMiddleware),
|
|
580
|
+
|
|
581
|
+
// Apply to specific stores
|
|
582
|
+
applyFor(["authStore", "settingsStore"], loggingMiddleware),
|
|
583
|
+
|
|
584
|
+
// Apply based on custom condition
|
|
585
|
+
applyFor((spec) => spec.options.meta?.persist === true, persistMiddleware)
|
|
586
|
+
),
|
|
382
587
|
});
|
|
383
588
|
```
|
|
384
589
|
|
|
@@ -386,235 +591,245 @@ instance.subscribe("@save", (event) => {
|
|
|
386
591
|
|
|
387
592
|
## API Reference
|
|
388
593
|
|
|
389
|
-
### `
|
|
594
|
+
### Core (`storion`)
|
|
390
595
|
|
|
391
|
-
|
|
392
|
-
|
|
596
|
+
| Export | Description |
|
|
597
|
+
| ---------------------- | ---------------------------------------------- |
|
|
598
|
+
| `store(options)` | Create a store specification |
|
|
599
|
+
| `container(options?)` | Create a container for store instances and DI |
|
|
600
|
+
| `effect(fn, options?)` | Create reactive side effects with cleanup |
|
|
601
|
+
| `pick(fn, equality?)` | Fine-grained derived value tracking |
|
|
602
|
+
| `batch(fn)` | Batch multiple mutations into one notification |
|
|
603
|
+
| `untrack(fn)` | Read state without tracking dependencies |
|
|
393
604
|
|
|
394
|
-
|
|
395
|
-
name: "myStore", // Optional, auto-generated if omitted
|
|
396
|
-
state: { count: 0 }, // Initial state (required)
|
|
397
|
-
setup({ state, resolve, update, dirty, reset, use }) {
|
|
398
|
-
// state - Mutable proxy, writes notify subscribers
|
|
399
|
-
// resolve - Access other stores: [state, actions]
|
|
400
|
-
// update - Immer-style or partial updates
|
|
401
|
-
// dirty - Check if state modified: dirty() or dirty("prop")
|
|
402
|
-
// reset - Reset to initial state
|
|
403
|
-
// use - Apply mixins: use(mixin, ...args)
|
|
605
|
+
#### Store Options
|
|
404
606
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
lifetime
|
|
411
|
-
|
|
412
|
-
onDispatch
|
|
413
|
-
onError
|
|
414
|
-
|
|
415
|
-
denormalize: (data) => ({}), // For hydrate() deserialization
|
|
416
|
-
});
|
|
607
|
+
```ts
|
|
608
|
+
interface StoreOptions<TState, TActions> {
|
|
609
|
+
name?: string; // Store name for debugging
|
|
610
|
+
state: TState; // Initial state
|
|
611
|
+
setup: (ctx: StoreContext) => TActions; // Setup function
|
|
612
|
+
lifetime?: "singleton" | "autoDispose"; // Instance lifetime
|
|
613
|
+
equality?: Equality | EqualityMap; // Custom equality for state
|
|
614
|
+
onDispatch?: (event: DispatchEvent) => void; // Action dispatch callback
|
|
615
|
+
onError?: (error: unknown) => void; // Error callback
|
|
616
|
+
}
|
|
417
617
|
```
|
|
418
618
|
|
|
419
|
-
|
|
619
|
+
#### StoreContext (in setup)
|
|
420
620
|
|
|
421
621
|
```ts
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
622
|
+
interface StoreContext<TState, TActions> {
|
|
623
|
+
state: TState; // First-level props only (state.x = y)
|
|
624
|
+
get<T>(spec: StoreSpec<T>): StoreTuple; // Get dependency store
|
|
625
|
+
get<T>(factory: Factory<T>): T; // Get DI service
|
|
626
|
+
focus<P extends Path>(path: P): Focus; // Lens-like accessor
|
|
627
|
+
update(fn: (draft: TState) => void): void; // For nested/array mutations
|
|
628
|
+
dirty(prop?: keyof TState): boolean; // Check if state changed
|
|
629
|
+
reset(): void; // Reset to initial state
|
|
630
|
+
onDispose(fn: VoidFunction): void; // Register cleanup
|
|
631
|
+
}
|
|
632
|
+
```
|
|
431
633
|
|
|
432
|
-
|
|
433
|
-
app.has(myStore); // boolean
|
|
634
|
+
> **Note:** `state` allows direct assignment only for first-level properties. Use `update()` for nested objects, arrays, or batch updates.
|
|
434
635
|
|
|
435
|
-
|
|
436
|
-
app.clear();
|
|
636
|
+
### React (`storion/react`)
|
|
437
637
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
638
|
+
| Export | Description |
|
|
639
|
+
| -------------------------- | ----------------------------------------- |
|
|
640
|
+
| `StoreProvider` | Provides container to React tree |
|
|
641
|
+
| `useStore(selector)` | Hook to consume stores with selector |
|
|
642
|
+
| `useStore(spec)` | Hook for component-local store |
|
|
643
|
+
| `useContainer()` | Access container from context |
|
|
644
|
+
| `create(options)` | Create store + hook for single-store apps |
|
|
645
|
+
| `withStore(hook, render?)` | HOC pattern for store consumption |
|
|
442
646
|
|
|
443
|
-
|
|
647
|
+
#### useStore Selector
|
|
444
648
|
|
|
445
649
|
```ts
|
|
446
|
-
|
|
650
|
+
// Selector receives context with get() for accessing stores
|
|
651
|
+
const result = useStore(({ get, mixin, once }) => {
|
|
652
|
+
const [state, actions] = get(myStore);
|
|
653
|
+
const service = get(myFactory);
|
|
447
654
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
console.log(state.count);
|
|
655
|
+
// Run once on mount
|
|
656
|
+
once(() => actions.init());
|
|
451
657
|
|
|
452
|
-
|
|
453
|
-
ctx.onCleanup(() => {
|
|
454
|
-
console.log("cleaning up");
|
|
455
|
-
});
|
|
658
|
+
return { value: state.value, action: actions.doSomething };
|
|
456
659
|
});
|
|
660
|
+
```
|
|
457
661
|
|
|
458
|
-
|
|
459
|
-
effect(fn, {
|
|
460
|
-
name: "myEffect", // For debugging
|
|
461
|
-
onError: "keepAlive", // "failFast" | "keepAlive" | custom handler
|
|
462
|
-
});
|
|
662
|
+
### Async (`storion/async`)
|
|
463
663
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
664
|
+
| Export | Description |
|
|
665
|
+
| --------------------------------- | ------------------------------------------- |
|
|
666
|
+
| `async(focus, handler, options?)` | Create async action |
|
|
667
|
+
| `async.fresh<T>()` | Create fresh mode initial state |
|
|
668
|
+
| `async.stale<T>(initial)` | Create stale mode initial state |
|
|
669
|
+
| `async.wait(state)` | Extract data or throw (Suspense-compatible) |
|
|
670
|
+
| `async.all(...states)` | Wait for all states to be ready |
|
|
671
|
+
| `async.any(...states)` | Get first ready state |
|
|
672
|
+
| `async.race(states)` | Race between states |
|
|
673
|
+
| `async.hasData(state)` | Check if state has data |
|
|
674
|
+
| `async.isLoading(state)` | Check if state is loading |
|
|
675
|
+
| `async.isError(state)` | Check if state has error |
|
|
467
676
|
|
|
468
|
-
|
|
677
|
+
#### AsyncState Types
|
|
469
678
|
|
|
470
679
|
```ts
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
});
|
|
680
|
+
interface AsyncState<T, M extends "fresh" | "stale"> {
|
|
681
|
+
status: "idle" | "pending" | "success" | "error";
|
|
682
|
+
mode: M;
|
|
683
|
+
data: M extends "stale" ? T : T | undefined;
|
|
684
|
+
error: Error | undefined;
|
|
685
|
+
timestamp: number | undefined;
|
|
686
|
+
}
|
|
479
687
|
```
|
|
480
688
|
|
|
481
|
-
### `
|
|
689
|
+
### Devtools (`storion/devtools`)
|
|
482
690
|
|
|
483
691
|
```ts
|
|
484
|
-
import {
|
|
692
|
+
import { devtools } from "storion/devtools";
|
|
485
693
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
694
|
+
const app = container({
|
|
695
|
+
middleware: devtools({
|
|
696
|
+
name: "My App",
|
|
697
|
+
// Enable in development only
|
|
698
|
+
enabled: process.env.NODE_ENV === "development",
|
|
699
|
+
}),
|
|
489
700
|
});
|
|
490
701
|
```
|
|
491
702
|
|
|
492
|
-
###
|
|
703
|
+
### Devtools Panel (`storion/devtools-panel`)
|
|
493
704
|
|
|
494
|
-
```
|
|
495
|
-
import {
|
|
705
|
+
```tsx
|
|
706
|
+
import { DevtoolsPanel } from "storion/devtools-panel";
|
|
496
707
|
|
|
497
|
-
//
|
|
498
|
-
|
|
499
|
-
|
|
708
|
+
// Mount anywhere in your app (dev only)
|
|
709
|
+
function App() {
|
|
710
|
+
return (
|
|
711
|
+
<>
|
|
712
|
+
<MyApp />
|
|
713
|
+
{process.env.NODE_ENV === "development" && <DevtoolsPanel />}
|
|
714
|
+
</>
|
|
715
|
+
);
|
|
716
|
+
}
|
|
500
717
|
```
|
|
501
718
|
|
|
502
|
-
|
|
719
|
+
---
|
|
503
720
|
|
|
504
|
-
|
|
505
|
-
const instance = container.get(myStore);
|
|
506
|
-
|
|
507
|
-
// Properties
|
|
508
|
-
instance.id; // "myStore:1"
|
|
509
|
-
instance.spec; // The StoreSpec
|
|
510
|
-
instance.state; // Readonly state proxy
|
|
511
|
-
instance.actions; // Actions with reactive last()
|
|
512
|
-
instance.deps; // Dependency instances
|
|
513
|
-
|
|
514
|
-
// Subscribe
|
|
515
|
-
instance.subscribe(() => {}); // All changes
|
|
516
|
-
instance.subscribe("count", ({ next, prev }) => {}); // Specific prop
|
|
517
|
-
instance.subscribe("@increment", (event) => {}); // Specific action
|
|
518
|
-
instance.subscribe("@*", (event) => {}); // All actions
|
|
519
|
-
|
|
520
|
-
// Lifecycle
|
|
521
|
-
instance.onDispose(() => {});
|
|
522
|
-
instance.dispose();
|
|
523
|
-
instance.disposed(); // boolean
|
|
524
|
-
|
|
525
|
-
// State management
|
|
526
|
-
instance.dirty(); // Any prop modified?
|
|
527
|
-
instance.dirty("count"); // Specific prop modified?
|
|
528
|
-
instance.reset(); // Reset to initial state
|
|
529
|
-
|
|
530
|
-
// Persistence
|
|
531
|
-
instance.dehydrate(); // Get serializable state
|
|
532
|
-
instance.hydrate(data); // Restore state (skips dirty props)
|
|
533
|
-
```
|
|
721
|
+
## Edge Cases & Best Practices
|
|
534
722
|
|
|
535
|
-
###
|
|
723
|
+
### ❌ Don't directly mutate nested state or arrays
|
|
536
724
|
|
|
537
|
-
|
|
538
|
-
import { useStore, StoreProvider, create } from "storion/react";
|
|
725
|
+
Direct mutation only works for first-level properties. Use `update()` for nested objects and arrays:
|
|
539
726
|
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
return {
|
|
544
|
-
|
|
727
|
+
```ts
|
|
728
|
+
// ❌ Wrong - nested mutation won't trigger reactivity
|
|
729
|
+
setup({ state }) {
|
|
730
|
+
return {
|
|
731
|
+
setName: (name: string) => {
|
|
732
|
+
state.profile.name = name; // Won't work!
|
|
733
|
+
},
|
|
734
|
+
addItem: (item: string) => {
|
|
735
|
+
state.items.push(item); // Won't work!
|
|
736
|
+
},
|
|
737
|
+
};
|
|
738
|
+
}
|
|
545
739
|
|
|
546
|
-
//
|
|
547
|
-
|
|
740
|
+
// ✅ Correct - use update() for nested/array mutations
|
|
741
|
+
setup({ state, update }) {
|
|
742
|
+
return {
|
|
743
|
+
setName: (name: string) => {
|
|
744
|
+
update((draft) => {
|
|
745
|
+
draft.profile.name = name;
|
|
746
|
+
});
|
|
747
|
+
},
|
|
748
|
+
addItem: (item: string) => {
|
|
749
|
+
update((draft) => {
|
|
750
|
+
draft.items.push(item);
|
|
751
|
+
});
|
|
752
|
+
},
|
|
753
|
+
// First-level props can be assigned directly
|
|
754
|
+
setCount: (n: number) => {
|
|
755
|
+
state.count = n; // This works!
|
|
756
|
+
},
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
```
|
|
548
760
|
|
|
549
|
-
|
|
550
|
-
<StoreProvider container={app}>
|
|
551
|
-
<App />
|
|
552
|
-
</StoreProvider>;
|
|
761
|
+
### ❌ Don't call `get()` inside actions
|
|
553
762
|
|
|
554
|
-
|
|
555
|
-
const [instance, useCounter] = create({
|
|
556
|
-
state: { count: 0 },
|
|
557
|
-
setup({ state }) {
|
|
558
|
-
return { increment: () => state.count++ };
|
|
559
|
-
},
|
|
560
|
-
});
|
|
763
|
+
`get()` is for declaring dependencies during setup, not runtime:
|
|
561
764
|
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
765
|
+
```ts
|
|
766
|
+
// ❌ Wrong - calling get() inside action
|
|
767
|
+
setup({ get }) {
|
|
768
|
+
return {
|
|
769
|
+
doSomething: () => {
|
|
770
|
+
const [other] = get(otherStore); // Don't do this!
|
|
771
|
+
},
|
|
772
|
+
};
|
|
773
|
+
}
|
|
565
774
|
|
|
566
|
-
|
|
775
|
+
// ✅ Correct - declare dependency at setup time
|
|
776
|
+
setup({ get }) {
|
|
777
|
+
const [otherState, otherActions] = get(otherStore);
|
|
567
778
|
|
|
568
|
-
|
|
569
|
-
|
|
779
|
+
return {
|
|
780
|
+
doSomething: () => {
|
|
781
|
+
if (otherState.ready) {
|
|
782
|
+
// Use the reactive state captured during setup
|
|
783
|
+
}
|
|
784
|
+
},
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
```
|
|
570
788
|
|
|
571
|
-
|
|
572
|
-
container({ middleware: [logger, devtools, persist] });
|
|
789
|
+
### ❌ Don't return Promises from effects
|
|
573
790
|
|
|
574
|
-
|
|
575
|
-
const conditionalLogger = applyFor("user*", logger); // Wildcard match
|
|
576
|
-
const multiMiddleware = applyFor(/Store$/, [logger, devtools]); // RegExp match
|
|
577
|
-
const persistOnly = applyFor((spec) => spec.meta?.persist, persistMiddleware); // Predicate
|
|
791
|
+
Effects must be synchronous. Use `ctx.safe()` for async:
|
|
578
792
|
|
|
579
|
-
|
|
580
|
-
|
|
793
|
+
```ts
|
|
794
|
+
// ❌ Wrong - async effect
|
|
795
|
+
effect(async (ctx) => {
|
|
796
|
+
const data = await fetchData(); // Don't do this!
|
|
797
|
+
});
|
|
581
798
|
|
|
582
|
-
//
|
|
583
|
-
|
|
799
|
+
// ✅ Correct - use ctx.safe()
|
|
800
|
+
effect((ctx) => {
|
|
801
|
+
ctx.safe(fetchData()).then((data) => {
|
|
802
|
+
state.data = data;
|
|
803
|
+
});
|
|
804
|
+
});
|
|
584
805
|
```
|
|
585
806
|
|
|
586
|
-
###
|
|
807
|
+
### ✅ Use `pick()` for computed values from nested state
|
|
808
|
+
|
|
809
|
+
When reading nested state in selectors, use `pick()` for fine-grained reactivity:
|
|
587
810
|
|
|
588
811
|
```ts
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
const logger: StoreMiddleware = (spec, next) => {
|
|
596
|
-
console.log(`Creating store: ${spec.name}`);
|
|
597
|
-
const instance = next(spec); // Call next to create the instance
|
|
598
|
-
console.log(`Created: ${instance.id}`);
|
|
599
|
-
return instance;
|
|
600
|
-
};
|
|
812
|
+
// Re-renders when profile object changes (coarse tracking)
|
|
813
|
+
const name = state.profile.name;
|
|
814
|
+
|
|
815
|
+
// Re-renders only when the actual name value changes (fine tracking)
|
|
816
|
+
const name = pick(() => state.profile.name);
|
|
817
|
+
const fullName = pick(() => `${state.profile.first} ${state.profile.last}`);
|
|
601
818
|
```
|
|
602
819
|
|
|
603
|
-
|
|
820
|
+
### ✅ Use stale mode for SWR patterns
|
|
604
821
|
|
|
605
|
-
|
|
822
|
+
```ts
|
|
823
|
+
// Fresh mode: data is undefined during loading
|
|
824
|
+
state: {
|
|
825
|
+
data: async.fresh<Data>(),
|
|
826
|
+
}
|
|
606
827
|
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
| Cross-store deps | Built-in | Manual | Manual | Built-in |
|
|
613
|
-
| TypeScript | Excellent | Good | Good | Excellent |
|
|
614
|
-
| React Strict Mode | ✅ | ✅ | ✅ | ✅ |
|
|
615
|
-
| Effects | Built-in | External | External | External |
|
|
616
|
-
| Middleware | ✅ | ✅ | ✅ | Limited |
|
|
617
|
-
| DevTools | 🚧 | ✅ | ✅ | ✅ |
|
|
828
|
+
// Stale mode: preserves previous data during loading (SWR pattern)
|
|
829
|
+
state: {
|
|
830
|
+
data: async.stale<Data>(initialData),
|
|
831
|
+
}
|
|
832
|
+
```
|
|
618
833
|
|
|
619
834
|
---
|
|
620
835
|
|
|
@@ -622,57 +837,107 @@ const logger: StoreMiddleware = (spec, next) => {
|
|
|
622
837
|
|
|
623
838
|
Storion is written in TypeScript and provides excellent type inference:
|
|
624
839
|
|
|
625
|
-
```
|
|
626
|
-
|
|
840
|
+
```ts
|
|
841
|
+
// State and action types are inferred
|
|
842
|
+
const myStore = store({
|
|
843
|
+
name: "my-store",
|
|
844
|
+
state: { count: 0, name: "" },
|
|
845
|
+
setup({ state }) {
|
|
846
|
+
return {
|
|
847
|
+
inc: () => state.count++, // () => void
|
|
848
|
+
setName: (n: string) => (state.name = n), // (n: string) => string
|
|
849
|
+
};
|
|
850
|
+
},
|
|
851
|
+
});
|
|
627
852
|
|
|
628
|
-
|
|
629
|
-
|
|
853
|
+
// Using with explicit types when needed (unions, nullable)
|
|
854
|
+
interface MyState {
|
|
855
|
+
userId: string | null;
|
|
856
|
+
status: "idle" | "loading" | "ready";
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
const typedStore = store({
|
|
860
|
+
name: "typed",
|
|
630
861
|
state: {
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
},
|
|
862
|
+
userId: null as string | null,
|
|
863
|
+
status: "idle" as "idle" | "loading" | "ready",
|
|
864
|
+
} satisfies MyState,
|
|
634
865
|
setup({ state }) {
|
|
635
866
|
return {
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
},
|
|
639
|
-
toggle: (id: number) => {
|
|
640
|
-
/* ... */
|
|
641
|
-
},
|
|
642
|
-
setFilter: (f: typeof state.filter) => {
|
|
643
|
-
state.filter = f;
|
|
867
|
+
setUser: (id: string | null) => {
|
|
868
|
+
state.userId = id;
|
|
644
869
|
},
|
|
645
870
|
};
|
|
646
871
|
},
|
|
647
872
|
});
|
|
648
|
-
|
|
649
|
-
// Full inference - no generics needed!
|
|
650
|
-
const { items, add } = useStore(({ resolve }) => {
|
|
651
|
-
const [state, actions] = resolve(todoStore);
|
|
652
|
-
return { items: state.items, add: actions.add };
|
|
653
|
-
});
|
|
654
|
-
// items: Todo[], add: (text: string) => void
|
|
655
873
|
```
|
|
656
874
|
|
|
657
875
|
---
|
|
658
876
|
|
|
659
|
-
##
|
|
877
|
+
## Contributing
|
|
878
|
+
|
|
879
|
+
We welcome contributions! Here's how to get started:
|
|
880
|
+
|
|
881
|
+
### Prerequisites
|
|
882
|
+
|
|
883
|
+
- Node.js 18+
|
|
884
|
+
- pnpm 8+
|
|
885
|
+
|
|
886
|
+
### Setup
|
|
660
887
|
|
|
661
888
|
```bash
|
|
662
|
-
#
|
|
663
|
-
|
|
889
|
+
# Clone the repo
|
|
890
|
+
git clone https://github.com/linq2js/storion.git
|
|
891
|
+
cd storion
|
|
664
892
|
|
|
665
|
-
#
|
|
666
|
-
|
|
893
|
+
# Install dependencies
|
|
894
|
+
pnpm install
|
|
667
895
|
|
|
668
|
-
#
|
|
669
|
-
pnpm
|
|
896
|
+
# Build the library
|
|
897
|
+
pnpm --filter storion build
|
|
898
|
+
```
|
|
899
|
+
|
|
900
|
+
### Development
|
|
901
|
+
|
|
902
|
+
```bash
|
|
903
|
+
# Watch mode
|
|
904
|
+
pnpm --filter storion dev
|
|
905
|
+
|
|
906
|
+
# Run tests
|
|
907
|
+
pnpm --filter storion test
|
|
908
|
+
|
|
909
|
+
# Run tests with UI
|
|
910
|
+
pnpm --filter storion test:ui
|
|
911
|
+
|
|
912
|
+
# Type check
|
|
913
|
+
pnpm --filter storion build:check
|
|
914
|
+
```
|
|
915
|
+
|
|
916
|
+
### Code Style
|
|
917
|
+
|
|
918
|
+
- Prefer **type inference** over explicit interfaces (add types only for unions, nullable, discriminated unions)
|
|
919
|
+
- Keep examples **copy/paste runnable**
|
|
920
|
+
- Write tests for new features
|
|
921
|
+
- Follow existing patterns in the codebase
|
|
922
|
+
|
|
923
|
+
### Commit Messages
|
|
924
|
+
|
|
925
|
+
Use [Conventional Commits](https://www.conventionalcommits.org/):
|
|
926
|
+
|
|
927
|
+
```
|
|
928
|
+
feat(core): add new feature
|
|
929
|
+
fix(react): resolve hook issue
|
|
930
|
+
docs: update README
|
|
931
|
+
chore: bump dependencies
|
|
670
932
|
```
|
|
671
933
|
|
|
672
|
-
###
|
|
934
|
+
### Pull Requests
|
|
673
935
|
|
|
674
|
-
|
|
675
|
-
|
|
936
|
+
1. Fork the repo and create your branch from `main`
|
|
937
|
+
2. Add tests for new functionality
|
|
938
|
+
3. Ensure all tests pass
|
|
939
|
+
4. Update documentation as needed
|
|
940
|
+
5. Submit a PR with a clear description
|
|
676
941
|
|
|
677
942
|
---
|
|
678
943
|
|
|
@@ -683,5 +948,5 @@ MIT © [linq2js](https://github.com/linq2js)
|
|
|
683
948
|
---
|
|
684
949
|
|
|
685
950
|
<p align="center">
|
|
686
|
-
<sub>Built with ❤️ for
|
|
951
|
+
<sub>Built with ❤️ for the React community</sub>
|
|
687
952
|
</p>
|