storion 0.2.3 → 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 +705 -573
- 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,753 +1,835 @@
|
|
|
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
|
+
```
|
|
50
|
+
|
|
51
|
+
---
|
|
2
52
|
|
|
3
|
-
|
|
53
|
+
## Features
|
|
4
54
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
+
---
|
|
66
|
+
|
|
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
|
-
##
|
|
99
|
-
|
|
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
|
-
```
|
|
216
|
+
## Usage
|
|
120
217
|
|
|
121
|
-
###
|
|
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;
|
|
228
335
|
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
336
|
+
const ws = new WebSocket(`/ws?user=${state.userId}`);
|
|
337
|
+
state.syncStatus = "syncing";
|
|
338
|
+
|
|
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()`
|
|
386
|
+
|
|
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.
|
|
271
388
|
|
|
272
|
-
|
|
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
|
-
state.email = v;
|
|
284
|
-
},
|
|
285
|
-
setPassword: (v: string) => {
|
|
286
|
-
state.password = v;
|
|
287
|
-
},
|
|
288
|
-
};
|
|
289
|
-
},
|
|
290
|
-
});
|
|
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
|
+
});
|
|
291
400
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
+
});
|
|
295
406
|
|
|
296
|
-
return
|
|
297
|
-
<form>
|
|
298
|
-
<input
|
|
299
|
-
value={state.email}
|
|
300
|
-
onChange={(e) => actions.setEmail(e.target.value)}
|
|
301
|
-
/>
|
|
302
|
-
<input
|
|
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>
|
|
311
|
-
);
|
|
407
|
+
return <h1>{name}</h1>;
|
|
312
408
|
}
|
|
313
409
|
```
|
|
314
410
|
|
|
315
|
-
###
|
|
411
|
+
### Async State Management
|
|
316
412
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
```tsx
|
|
320
|
-
// In your store's setup function:
|
|
321
|
-
setup({ state, update }) {
|
|
322
|
-
return {
|
|
323
|
-
addTodo: (text: string) => {
|
|
324
|
-
update(draft => {
|
|
325
|
-
draft.todos.push({ id: Date.now(), text, done: false });
|
|
326
|
-
});
|
|
327
|
-
},
|
|
328
|
-
toggleTodo: (id: number) => {
|
|
329
|
-
update(draft => {
|
|
330
|
-
const todo = draft.todos.find(t => t.id === id);
|
|
331
|
-
if (todo) todo.done = !todo.done;
|
|
332
|
-
});
|
|
333
|
-
},
|
|
334
|
-
};
|
|
335
|
-
}
|
|
336
|
-
```
|
|
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.
|
|
337
414
|
|
|
338
|
-
|
|
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).
|
|
339
416
|
|
|
340
|
-
|
|
417
|
+
```ts
|
|
418
|
+
import { store } from "storion";
|
|
419
|
+
import { async, type AsyncState } from "storion/async";
|
|
341
420
|
|
|
342
|
-
|
|
343
|
-
|
|
421
|
+
interface Product {
|
|
422
|
+
id: string;
|
|
423
|
+
name: string;
|
|
424
|
+
price: number;
|
|
425
|
+
}
|
|
344
426
|
|
|
345
|
-
const
|
|
346
|
-
name: "
|
|
427
|
+
export const productStore = store({
|
|
428
|
+
name: "products",
|
|
347
429
|
state: {
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
// Deep compare profile, shallow compare settings, strict for rest
|
|
353
|
-
equality: {
|
|
354
|
-
profile: "deep",
|
|
355
|
-
settings: "shallow",
|
|
356
|
-
default: "strict",
|
|
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[]>([]),
|
|
357
434
|
},
|
|
358
|
-
setup({
|
|
359
|
-
|
|
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();
|
|
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,
|
|
463
|
+
};
|
|
360
464
|
},
|
|
361
465
|
});
|
|
362
|
-
```
|
|
363
466
|
|
|
364
|
-
|
|
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
|
+
});
|
|
365
473
|
|
|
366
|
-
|
|
474
|
+
useEffect(() => {
|
|
475
|
+
fetchList();
|
|
476
|
+
}, []);
|
|
367
477
|
|
|
368
|
-
|
|
369
|
-
|
|
478
|
+
if (list.status === "pending" && !list.data?.length) {
|
|
479
|
+
return <Spinner />;
|
|
480
|
+
}
|
|
370
481
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
// Runs every time saveAction is dispatched
|
|
376
|
-
showNotification(`Saved at ${new Date()}`);
|
|
377
|
-
});
|
|
482
|
+
if (list.status === "error") {
|
|
483
|
+
return <Error message={list.error.message} />;
|
|
484
|
+
}
|
|
378
485
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
}
|
|
486
|
+
return (
|
|
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>
|
|
493
|
+
);
|
|
494
|
+
}
|
|
383
495
|
```
|
|
384
496
|
|
|
385
|
-
###
|
|
497
|
+
### Dependency Injection
|
|
386
498
|
|
|
387
|
-
|
|
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.
|
|
388
500
|
|
|
389
|
-
|
|
390
|
-
import { store, effect, type StoreContext } from "storion/react";
|
|
501
|
+
**With Storion:** The container acts as a DI container. Define factory functions and resolve them with `get()`. Services are cached as singletons automatically.
|
|
391
502
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
users: User[];
|
|
395
|
-
}
|
|
503
|
+
```ts
|
|
504
|
+
import { container, type Resolver } from "storion";
|
|
396
505
|
|
|
397
|
-
|
|
398
|
-
|
|
506
|
+
// Define service factory
|
|
507
|
+
interface ApiService {
|
|
508
|
+
get<T>(url: string): Promise<T>;
|
|
509
|
+
post<T>(url: string, data: unknown): Promise<T>;
|
|
399
510
|
}
|
|
400
511
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
// Mixin 1: Notifications (base mixin)
|
|
406
|
-
const notificationMixin = ({ state }: StoreContext<NotificationState>) => ({
|
|
407
|
-
notify: (msg: string) => {
|
|
408
|
-
state.notifications.push({ id: Date.now(), message: msg });
|
|
409
|
-
},
|
|
410
|
-
clearAll: () => {
|
|
411
|
-
state.notifications = [];
|
|
412
|
-
},
|
|
413
|
-
});
|
|
414
|
-
|
|
415
|
-
// Mixin 2: User management — uses notificationMixin!
|
|
416
|
-
const userMixin = ({
|
|
417
|
-
state,
|
|
418
|
-
use,
|
|
419
|
-
}: StoreContext<UserState & NotificationState>) => {
|
|
420
|
-
const { notify } = use(notificationMixin); // Compose another mixin
|
|
512
|
+
function createApiService(resolver: Resolver): ApiService {
|
|
513
|
+
const baseUrl = resolver.get(configFactory).apiUrl;
|
|
421
514
|
|
|
422
515
|
return {
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
516
|
+
async get(url) {
|
|
517
|
+
const res = await fetch(`${baseUrl}${url}`);
|
|
518
|
+
return res.json();
|
|
426
519
|
},
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
520
|
+
async post(url, data) {
|
|
521
|
+
const res = await fetch(`${baseUrl}${url}`, {
|
|
522
|
+
method: "POST",
|
|
523
|
+
body: JSON.stringify(data),
|
|
524
|
+
});
|
|
525
|
+
return res.json();
|
|
430
526
|
},
|
|
431
527
|
};
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
// Mixin 3: Post management — also uses notificationMixin!
|
|
435
|
-
const postMixin = ({
|
|
436
|
-
state,
|
|
437
|
-
use,
|
|
438
|
-
}: StoreContext<PostState & NotificationState>) => {
|
|
439
|
-
const { notify } = use(notificationMixin);
|
|
440
|
-
|
|
441
|
-
effect(() => {
|
|
442
|
-
console.log(`Posts updated: ${state.posts.length} total`);
|
|
443
|
-
});
|
|
528
|
+
}
|
|
444
529
|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
notify(`New post: ${post.title}`);
|
|
449
|
-
},
|
|
450
|
-
deletePost: (id: string) => {
|
|
451
|
-
state.posts = state.posts.filter((p) => p.id !== id);
|
|
452
|
-
notify(`Post deleted`);
|
|
453
|
-
},
|
|
454
|
-
};
|
|
455
|
-
};
|
|
530
|
+
function configFactory(): { apiUrl: string } {
|
|
531
|
+
return { apiUrl: process.env.API_URL ?? "http://localhost:3000" };
|
|
532
|
+
}
|
|
456
533
|
|
|
457
|
-
//
|
|
458
|
-
const
|
|
459
|
-
name: "
|
|
460
|
-
state: {
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
notifications: [] as Notification[],
|
|
464
|
-
},
|
|
465
|
-
setup({ use }) {
|
|
466
|
-
// Each mixin works with its slice of state
|
|
467
|
-
const userActions = use(userMixin);
|
|
468
|
-
const postActions = use(postMixin);
|
|
469
|
-
const notificationActions = use(notificationMixin);
|
|
534
|
+
// Use in store
|
|
535
|
+
const userStore = store({
|
|
536
|
+
name: "user",
|
|
537
|
+
state: { user: null },
|
|
538
|
+
setup({ get }) {
|
|
539
|
+
const api = get(createApiService); // Singleton, cached
|
|
470
540
|
|
|
471
541
|
return {
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
542
|
+
fetchUser: async (id: string) => {
|
|
543
|
+
return api.get(`/users/${id}`);
|
|
544
|
+
},
|
|
475
545
|
};
|
|
476
546
|
},
|
|
477
547
|
});
|
|
478
548
|
```
|
|
479
549
|
|
|
480
|
-
|
|
550
|
+
### Middleware
|
|
481
551
|
|
|
482
|
-
-
|
|
483
|
-
- ♻️ **Reuse** — Mixins are decoupled, reusable across stores
|
|
484
|
-
- 🧪 **Testable** — Test mixins in isolation with minimal state
|
|
485
|
-
- 🎭 **Effects** — Mixins can define their own reactive effects
|
|
552
|
+
**The problem:** You need cross-cutting behavior (logging, persistence, devtools) applied to some or all stores, without modifying each store individually.
|
|
486
553
|
|
|
487
|
-
**
|
|
554
|
+
**With Storion:** Compose middleware and apply it conditionally using patterns like `"user*"` (startsWith), `"*Store"` (endsWith), or custom predicates.
|
|
488
555
|
|
|
489
556
|
```ts
|
|
490
|
-
|
|
491
|
-
fetch: () => fetch(endpoint),
|
|
492
|
-
});
|
|
557
|
+
import { container, compose, applyFor, applyExcept } from "storion";
|
|
493
558
|
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
559
|
+
// Logging middleware
|
|
560
|
+
const loggingMiddleware = (spec, next) => {
|
|
561
|
+
const instance = next(spec);
|
|
562
|
+
console.log(`Store created: ${spec.name}`);
|
|
563
|
+
return instance;
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
// Persistence middleware
|
|
567
|
+
const persistMiddleware = (spec, next) => {
|
|
568
|
+
const instance = next(spec);
|
|
569
|
+
// Add persistence logic...
|
|
570
|
+
return instance;
|
|
571
|
+
};
|
|
497
572
|
|
|
498
|
-
|
|
573
|
+
const app = container({
|
|
574
|
+
middleware: compose(
|
|
575
|
+
// Apply logging to all stores starting with "user"
|
|
576
|
+
applyFor("user*", loggingMiddleware),
|
|
499
577
|
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
//
|
|
504
|
-
|
|
505
|
-
({ state }: StoreContext<NotificationState>) => ({
|
|
506
|
-
notify: (msg: string) =>
|
|
507
|
-
state.notifications.push({ id: Date.now(), message: msg }),
|
|
508
|
-
clearAll: () => {
|
|
509
|
-
state.notifications = [];
|
|
510
|
-
},
|
|
511
|
-
})
|
|
512
|
-
);
|
|
578
|
+
// Apply persistence except for cache stores
|
|
579
|
+
applyExcept("*Cache", persistMiddleware),
|
|
580
|
+
|
|
581
|
+
// Apply to specific stores
|
|
582
|
+
applyFor(["authStore", "settingsStore"], loggingMiddleware),
|
|
513
583
|
|
|
514
|
-
//
|
|
515
|
-
|
|
584
|
+
// Apply based on custom condition
|
|
585
|
+
applyFor((spec) => spec.options.meta?.persist === true, persistMiddleware)
|
|
586
|
+
),
|
|
587
|
+
});
|
|
516
588
|
```
|
|
517
589
|
|
|
518
590
|
---
|
|
519
591
|
|
|
520
592
|
## API Reference
|
|
521
593
|
|
|
522
|
-
### `
|
|
594
|
+
### Core (`storion`)
|
|
523
595
|
|
|
524
|
-
|
|
525
|
-
|
|
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 |
|
|
526
604
|
|
|
527
|
-
|
|
528
|
-
name: "myStore", // Optional, auto-generated if omitted
|
|
529
|
-
state: { count: 0 }, // Initial state (required)
|
|
530
|
-
setup({ state, resolve, update, dirty, reset, use }) {
|
|
531
|
-
// state - Mutable proxy, writes notify subscribers
|
|
532
|
-
// resolve - Access other stores: [state, actions]
|
|
533
|
-
// update - Immer-style or partial updates
|
|
534
|
-
// dirty - Check if state modified: dirty() or dirty("prop")
|
|
535
|
-
// reset - Reset to initial state
|
|
536
|
-
// use - Apply mixins: use(mixin, ...args)
|
|
605
|
+
#### Store Options
|
|
537
606
|
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
lifetime
|
|
544
|
-
|
|
545
|
-
onDispatch
|
|
546
|
-
onError
|
|
547
|
-
|
|
548
|
-
denormalize: (data) => ({}), // For hydrate() deserialization
|
|
549
|
-
});
|
|
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
|
+
}
|
|
550
617
|
```
|
|
551
618
|
|
|
552
|
-
|
|
619
|
+
#### StoreContext (in setup)
|
|
553
620
|
|
|
554
621
|
```ts
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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
|
+
```
|
|
564
633
|
|
|
565
|
-
|
|
566
|
-
app.has(myStore); // boolean
|
|
634
|
+
> **Note:** `state` allows direct assignment only for first-level properties. Use `update()` for nested objects, arrays, or batch updates.
|
|
567
635
|
|
|
568
|
-
|
|
569
|
-
app.clear();
|
|
636
|
+
### React (`storion/react`)
|
|
570
637
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
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 |
|
|
575
646
|
|
|
576
|
-
|
|
647
|
+
#### useStore Selector
|
|
577
648
|
|
|
578
649
|
```ts
|
|
579
|
-
|
|
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);
|
|
580
654
|
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
console.log(state.count);
|
|
655
|
+
// Run once on mount
|
|
656
|
+
once(() => actions.init());
|
|
584
657
|
|
|
585
|
-
|
|
586
|
-
ctx.onCleanup(() => {
|
|
587
|
-
console.log("cleaning up");
|
|
588
|
-
});
|
|
658
|
+
return { value: state.value, action: actions.doSomething };
|
|
589
659
|
});
|
|
660
|
+
```
|
|
590
661
|
|
|
591
|
-
|
|
592
|
-
effect(fn, {
|
|
593
|
-
name: "myEffect", // For debugging
|
|
594
|
-
onError: "keepAlive", // "failFast" | "keepAlive" | custom handler
|
|
595
|
-
});
|
|
662
|
+
### Async (`storion/async`)
|
|
596
663
|
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
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 |
|
|
600
676
|
|
|
601
|
-
|
|
677
|
+
#### AsyncState Types
|
|
602
678
|
|
|
603
679
|
```ts
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
});
|
|
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
|
+
}
|
|
612
687
|
```
|
|
613
688
|
|
|
614
|
-
### `
|
|
689
|
+
### Devtools (`storion/devtools`)
|
|
615
690
|
|
|
616
691
|
```ts
|
|
617
|
-
import {
|
|
692
|
+
import { devtools } from "storion/devtools";
|
|
618
693
|
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
694
|
+
const app = container({
|
|
695
|
+
middleware: devtools({
|
|
696
|
+
name: "My App",
|
|
697
|
+
// Enable in development only
|
|
698
|
+
enabled: process.env.NODE_ENV === "development",
|
|
699
|
+
}),
|
|
622
700
|
});
|
|
623
701
|
```
|
|
624
702
|
|
|
625
|
-
###
|
|
703
|
+
### Devtools Panel (`storion/devtools-panel`)
|
|
626
704
|
|
|
627
|
-
```
|
|
628
|
-
import {
|
|
705
|
+
```tsx
|
|
706
|
+
import { DevtoolsPanel } from "storion/devtools-panel";
|
|
629
707
|
|
|
630
|
-
//
|
|
631
|
-
|
|
632
|
-
|
|
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
|
+
}
|
|
633
717
|
```
|
|
634
718
|
|
|
635
|
-
|
|
719
|
+
---
|
|
636
720
|
|
|
637
|
-
|
|
638
|
-
const instance = container.get(myStore);
|
|
639
|
-
|
|
640
|
-
// Properties
|
|
641
|
-
instance.id; // "myStore:1"
|
|
642
|
-
instance.spec; // The StoreSpec
|
|
643
|
-
instance.state; // Readonly state proxy
|
|
644
|
-
instance.actions; // Actions with reactive last()
|
|
645
|
-
instance.deps; // Dependency instances
|
|
646
|
-
|
|
647
|
-
// Subscribe
|
|
648
|
-
instance.subscribe(() => {}); // All changes
|
|
649
|
-
instance.subscribe("count", ({ next, prev }) => {}); // Specific prop
|
|
650
|
-
instance.subscribe("@increment", (event) => {}); // Specific action
|
|
651
|
-
instance.subscribe("@*", (event) => {}); // All actions
|
|
652
|
-
|
|
653
|
-
// Lifecycle
|
|
654
|
-
instance.onDispose(() => {});
|
|
655
|
-
instance.dispose();
|
|
656
|
-
instance.disposed(); // boolean
|
|
657
|
-
|
|
658
|
-
// State management
|
|
659
|
-
instance.dirty(); // Any prop modified?
|
|
660
|
-
instance.dirty("count"); // Specific prop modified?
|
|
661
|
-
instance.reset(); // Reset to initial state
|
|
662
|
-
|
|
663
|
-
// Persistence
|
|
664
|
-
instance.dehydrate(); // Get serializable state
|
|
665
|
-
instance.hydrate(data); // Restore state (skips dirty props)
|
|
666
|
-
```
|
|
721
|
+
## Edge Cases & Best Practices
|
|
667
722
|
|
|
668
|
-
###
|
|
723
|
+
### ❌ Don't directly mutate nested state or arrays
|
|
669
724
|
|
|
670
|
-
|
|
671
|
-
import { useStore, StoreProvider, create } from "storion/react";
|
|
725
|
+
Direct mutation only works for first-level properties. Use `update()` for nested objects and arrays:
|
|
672
726
|
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
return {
|
|
677
|
-
|
|
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
|
+
}
|
|
678
739
|
|
|
679
|
-
//
|
|
680
|
-
|
|
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
|
+
```
|
|
681
760
|
|
|
682
|
-
|
|
683
|
-
<StoreProvider container={app}>
|
|
684
|
-
<App />
|
|
685
|
-
</StoreProvider>;
|
|
761
|
+
### ❌ Don't call `get()` inside actions
|
|
686
762
|
|
|
687
|
-
|
|
688
|
-
const [instance, useCounter] = create({
|
|
689
|
-
state: { count: 0 },
|
|
690
|
-
setup({ state }) {
|
|
691
|
-
return { increment: () => state.count++ };
|
|
692
|
-
},
|
|
693
|
-
});
|
|
763
|
+
`get()` is for declaring dependencies during setup, not runtime:
|
|
694
764
|
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
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
|
+
}
|
|
698
774
|
|
|
699
|
-
|
|
775
|
+
// ✅ Correct - declare dependency at setup time
|
|
776
|
+
setup({ get }) {
|
|
777
|
+
const [otherState, otherActions] = get(otherStore);
|
|
700
778
|
|
|
701
|
-
|
|
702
|
-
|
|
779
|
+
return {
|
|
780
|
+
doSomething: () => {
|
|
781
|
+
if (otherState.ready) {
|
|
782
|
+
// Use the reactive state captured during setup
|
|
783
|
+
}
|
|
784
|
+
},
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
```
|
|
703
788
|
|
|
704
|
-
|
|
705
|
-
container({ middleware: [logger, devtools, persist] });
|
|
789
|
+
### ❌ Don't return Promises from effects
|
|
706
790
|
|
|
707
|
-
|
|
708
|
-
const conditionalLogger = applyFor("user*", logger); // Wildcard match
|
|
709
|
-
const multiMiddleware = applyFor(/Store$/, [logger, devtools]); // RegExp match
|
|
710
|
-
const persistOnly = applyFor((spec) => spec.meta?.persist, persistMiddleware); // Predicate
|
|
791
|
+
Effects must be synchronous. Use `ctx.safe()` for async:
|
|
711
792
|
|
|
712
|
-
|
|
713
|
-
|
|
793
|
+
```ts
|
|
794
|
+
// ❌ Wrong - async effect
|
|
795
|
+
effect(async (ctx) => {
|
|
796
|
+
const data = await fetchData(); // Don't do this!
|
|
797
|
+
});
|
|
714
798
|
|
|
715
|
-
//
|
|
716
|
-
|
|
799
|
+
// ✅ Correct - use ctx.safe()
|
|
800
|
+
effect((ctx) => {
|
|
801
|
+
ctx.safe(fetchData()).then((data) => {
|
|
802
|
+
state.data = data;
|
|
803
|
+
});
|
|
804
|
+
});
|
|
717
805
|
```
|
|
718
806
|
|
|
719
|
-
###
|
|
807
|
+
### ✅ Use `pick()` for computed values from nested state
|
|
808
|
+
|
|
809
|
+
When reading nested state in selectors, use `pick()` for fine-grained reactivity:
|
|
720
810
|
|
|
721
811
|
```ts
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
const logger: StoreMiddleware = (spec, next) => {
|
|
729
|
-
console.log(`Creating store: ${spec.name}`);
|
|
730
|
-
const instance = next(spec); // Call next to create the instance
|
|
731
|
-
console.log(`Created: ${instance.id}`);
|
|
732
|
-
return instance;
|
|
733
|
-
};
|
|
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}`);
|
|
734
818
|
```
|
|
735
819
|
|
|
736
|
-
|
|
820
|
+
### ✅ Use stale mode for SWR patterns
|
|
737
821
|
|
|
738
|
-
|
|
822
|
+
```ts
|
|
823
|
+
// Fresh mode: data is undefined during loading
|
|
824
|
+
state: {
|
|
825
|
+
data: async.fresh<Data>(),
|
|
826
|
+
}
|
|
739
827
|
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
| Cross-store deps | Built-in | Manual | Manual | Built-in |
|
|
746
|
-
| TypeScript | Excellent | Good | Good | Excellent |
|
|
747
|
-
| React Strict Mode | ✅ | ✅ | ✅ | ✅ |
|
|
748
|
-
| Effects | Built-in | External | External | External |
|
|
749
|
-
| Middleware | ✅ | ✅ | ✅ | Limited |
|
|
750
|
-
| DevTools | 🚧 | ✅ | ✅ | ✅ |
|
|
828
|
+
// Stale mode: preserves previous data during loading (SWR pattern)
|
|
829
|
+
state: {
|
|
830
|
+
data: async.stale<Data>(initialData),
|
|
831
|
+
}
|
|
832
|
+
```
|
|
751
833
|
|
|
752
834
|
---
|
|
753
835
|
|
|
@@ -755,57 +837,107 @@ const logger: StoreMiddleware = (spec, next) => {
|
|
|
755
837
|
|
|
756
838
|
Storion is written in TypeScript and provides excellent type inference:
|
|
757
839
|
|
|
758
|
-
```
|
|
759
|
-
|
|
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
|
+
});
|
|
760
852
|
|
|
761
|
-
|
|
762
|
-
|
|
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",
|
|
763
861
|
state: {
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
},
|
|
862
|
+
userId: null as string | null,
|
|
863
|
+
status: "idle" as "idle" | "loading" | "ready",
|
|
864
|
+
} satisfies MyState,
|
|
767
865
|
setup({ state }) {
|
|
768
866
|
return {
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
},
|
|
772
|
-
toggle: (id: number) => {
|
|
773
|
-
/* ... */
|
|
774
|
-
},
|
|
775
|
-
setFilter: (f: typeof state.filter) => {
|
|
776
|
-
state.filter = f;
|
|
867
|
+
setUser: (id: string | null) => {
|
|
868
|
+
state.userId = id;
|
|
777
869
|
},
|
|
778
870
|
};
|
|
779
871
|
},
|
|
780
872
|
});
|
|
781
|
-
|
|
782
|
-
// Full inference - no generics needed!
|
|
783
|
-
const { items, add } = useStore(({ resolve }) => {
|
|
784
|
-
const [state, actions] = resolve(todoStore);
|
|
785
|
-
return { items: state.items, add: actions.add };
|
|
786
|
-
});
|
|
787
|
-
// items: Todo[], add: (text: string) => void
|
|
788
873
|
```
|
|
789
874
|
|
|
790
875
|
---
|
|
791
876
|
|
|
792
|
-
##
|
|
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
|
|
793
887
|
|
|
794
888
|
```bash
|
|
795
|
-
#
|
|
796
|
-
|
|
889
|
+
# Clone the repo
|
|
890
|
+
git clone https://github.com/linq2js/storion.git
|
|
891
|
+
cd storion
|
|
797
892
|
|
|
798
|
-
#
|
|
799
|
-
|
|
893
|
+
# Install dependencies
|
|
894
|
+
pnpm install
|
|
800
895
|
|
|
801
|
-
#
|
|
802
|
-
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
|
|
803
932
|
```
|
|
804
933
|
|
|
805
|
-
###
|
|
934
|
+
### Pull Requests
|
|
806
935
|
|
|
807
|
-
|
|
808
|
-
|
|
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
|
|
809
941
|
|
|
810
942
|
---
|
|
811
943
|
|
|
@@ -816,5 +948,5 @@ MIT © [linq2js](https://github.com/linq2js)
|
|
|
816
948
|
---
|
|
817
949
|
|
|
818
950
|
<p align="center">
|
|
819
|
-
<sub>Built with ❤️ for
|
|
951
|
+
<sub>Built with ❤️ for the React community</sub>
|
|
820
952
|
</p>
|