storion 0.2.3 → 0.4.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.
Files changed (91) hide show
  1. package/README.md +771 -561
  2. package/dist/async/async.d.ts +87 -0
  3. package/dist/async/async.d.ts.map +1 -0
  4. package/dist/async/index.d.ts +13 -0
  5. package/dist/async/index.d.ts.map +1 -0
  6. package/dist/async/index.js +451 -0
  7. package/dist/async/types.d.ts +275 -0
  8. package/dist/async/types.d.ts.map +1 -0
  9. package/dist/collection.d.ts +42 -0
  10. package/dist/collection.d.ts.map +1 -0
  11. package/dist/core/container.d.ts +33 -2
  12. package/dist/core/container.d.ts.map +1 -1
  13. package/dist/core/createResolver.d.ts +47 -0
  14. package/dist/core/createResolver.d.ts.map +1 -0
  15. package/dist/core/effect.d.ts.map +1 -1
  16. package/dist/core/equality.d.ts +23 -3
  17. package/dist/core/equality.d.ts.map +1 -1
  18. package/dist/core/fnWrapper.d.ts +54 -0
  19. package/dist/core/fnWrapper.d.ts.map +1 -0
  20. package/dist/core/middleware.d.ts +10 -10
  21. package/dist/core/middleware.d.ts.map +1 -1
  22. package/dist/core/pick.d.ts +6 -6
  23. package/dist/core/pick.d.ts.map +1 -1
  24. package/dist/core/store.d.ts +8 -8
  25. package/dist/core/store.d.ts.map +1 -1
  26. package/dist/core/storeContext.d.ts +63 -0
  27. package/dist/core/storeContext.d.ts.map +1 -0
  28. package/dist/devtools/controller.d.ts +4 -0
  29. package/dist/devtools/controller.d.ts.map +1 -0
  30. package/dist/devtools/index.d.ts +16 -0
  31. package/dist/devtools/index.d.ts.map +1 -0
  32. package/dist/devtools/index.js +239 -0
  33. package/dist/devtools/middleware.d.ts +22 -0
  34. package/dist/devtools/middleware.d.ts.map +1 -0
  35. package/dist/devtools/types.d.ts +116 -0
  36. package/dist/devtools/types.d.ts.map +1 -0
  37. package/dist/devtools-panel/DevtoolsPanel.d.ts +17 -0
  38. package/dist/devtools-panel/DevtoolsPanel.d.ts.map +1 -0
  39. package/dist/devtools-panel/components/CompareModal.d.ts +10 -0
  40. package/dist/devtools-panel/components/CompareModal.d.ts.map +1 -0
  41. package/dist/devtools-panel/components/EventEntry.d.ts +14 -0
  42. package/dist/devtools-panel/components/EventEntry.d.ts.map +1 -0
  43. package/dist/devtools-panel/components/EventFilterBar.d.ts +10 -0
  44. package/dist/devtools-panel/components/EventFilterBar.d.ts.map +1 -0
  45. package/dist/devtools-panel/components/EventsTab.d.ts +15 -0
  46. package/dist/devtools-panel/components/EventsTab.d.ts.map +1 -0
  47. package/dist/devtools-panel/components/ResizeHandle.d.ts +8 -0
  48. package/dist/devtools-panel/components/ResizeHandle.d.ts.map +1 -0
  49. package/dist/devtools-panel/components/StoreEntry.d.ts +13 -0
  50. package/dist/devtools-panel/components/StoreEntry.d.ts.map +1 -0
  51. package/dist/devtools-panel/components/StoresTab.d.ts +12 -0
  52. package/dist/devtools-panel/components/StoresTab.d.ts.map +1 -0
  53. package/dist/devtools-panel/components/TabLayout.d.ts +48 -0
  54. package/dist/devtools-panel/components/TabLayout.d.ts.map +1 -0
  55. package/dist/devtools-panel/components/icons.d.ts +27 -0
  56. package/dist/devtools-panel/components/icons.d.ts.map +1 -0
  57. package/dist/devtools-panel/components/index.d.ts +15 -0
  58. package/dist/devtools-panel/components/index.d.ts.map +1 -0
  59. package/dist/devtools-panel/hooks.d.ts +23 -0
  60. package/dist/devtools-panel/hooks.d.ts.map +1 -0
  61. package/dist/devtools-panel/index.d.ts +25 -0
  62. package/dist/devtools-panel/index.d.ts.map +1 -0
  63. package/dist/devtools-panel/index.js +3326 -0
  64. package/dist/devtools-panel/mount.d.ts +41 -0
  65. package/dist/devtools-panel/mount.d.ts.map +1 -0
  66. package/dist/devtools-panel/styles.d.ts +50 -0
  67. package/dist/devtools-panel/styles.d.ts.map +1 -0
  68. package/dist/devtools-panel/types.d.ts +15 -0
  69. package/dist/devtools-panel/types.d.ts.map +1 -0
  70. package/dist/devtools-panel/utils.d.ts +21 -0
  71. package/dist/devtools-panel/utils.d.ts.map +1 -0
  72. package/dist/index.d.ts +6 -1
  73. package/dist/index.d.ts.map +1 -1
  74. package/dist/is.d.ts +69 -0
  75. package/dist/is.d.ts.map +1 -0
  76. package/dist/react/create.d.ts +1 -1
  77. package/dist/react/index.d.ts +1 -0
  78. package/dist/react/index.d.ts.map +1 -1
  79. package/dist/react/index.js +210 -34
  80. package/dist/react/useLocalStore.d.ts.map +1 -1
  81. package/dist/react/useStore.d.ts +2 -2
  82. package/dist/react/useStore.d.ts.map +1 -1
  83. package/dist/react/withStore.d.ts +140 -0
  84. package/dist/react/withStore.d.ts.map +1 -0
  85. package/dist/{index-rLf6DusB.js → store-Yv-9gPVf.js} +543 -742
  86. package/dist/storion.js +809 -9
  87. package/dist/trigger.d.ts +40 -0
  88. package/dist/trigger.d.ts.map +1 -0
  89. package/dist/types.d.ts +538 -71
  90. package/dist/types.d.ts.map +1 -1
  91. package/package.json +13 -1
package/README.md CHANGED
@@ -1,753 +1,913 @@
1
- # 🏪 Storion
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>
2
10
 
3
- **A tiny, type-safe reactive state management library with automatic dependency tracking.**
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>
4
27
 
5
- [![npm](https://img.shields.io/npm/v/storion)](https://www.npmjs.com/package/storion)
6
- [![bundle size](https://img.shields.io/bundlephobia/minzip/storion)](https://bundlephobia.com/package/storion)
7
- [![license](https://img.shields.io/npm/l/storion)](LICENSE)
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
+ ---
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
+ ---
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
- ## Why Storion?
80
+ **Peer dependency:** React is optional, required only if using `storion/react`.
14
81
 
15
- - 🎯 **Auto-tracking** — Dependencies tracked automatically. No selectors to optimize.
16
- - **Fine-grained** Only re-renders when _accessed_ properties change.
17
- - 🔗 **Cross-store** — Stores can compose other stores seamlessly.
18
- - 🎭 **Effects** — Built-in reactive effects with automatic cleanup.
19
- - 📦 **Tiny** — ~3KB gzipped. No dependencies.
20
- - 🦾 **Type-safe** — Full TypeScript inference. No awkward generics.
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-Store App
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
- increment: () => state.count++,
37
- decrement: () => state.count--,
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, increment } = useCounter((state, actions) => ({
111
+ const { count, inc, dec } = useCounter((state, actions) => ({
44
112
  count: state.count,
45
- increment: actions.increment,
113
+ inc: actions.inc,
114
+ dec: actions.dec,
46
115
  }));
47
116
 
48
- return <button onClick={increment}>{count}</button>;
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
- **That's it.** No providers, no boilerplate.
131
+ ### Option 2: Multi-Store with Container (Scalable)
53
132
 
54
- ### Multi-Store App
133
+ Best for larger apps with multiple stores:
55
134
 
56
135
  ```tsx
57
- import { store, container, StoreProvider, useStore } from "storion/react";
136
+ import { store, container } from "storion";
137
+ import { StoreProvider, useStore } from "storion/react";
58
138
 
59
139
  // Define stores
60
- const userStore = store({
61
- name: "user",
62
- state: { name: "" },
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
- const cartStore = store({
68
- name: "cart",
69
- state: { items: [] },
70
- setup() {
71
- return {};
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 a container
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
- <MyComponent />
181
+ <Screen />
82
182
  </StoreProvider>
83
183
  );
84
184
  }
85
185
 
86
- function MyComponent() {
87
- const { userName, items } = useStore(({ resolve }) => {
88
- const [user] = resolve(userStore);
89
- const [cart] = resolve(cartStore);
90
- return { userName: user.name, items: cart.items };
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
- ## Mental Model
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
- ### Core Concepts
218
+ ### Defining a Store
122
219
 
123
- | Concept | Description |
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
- ### Data Flow Rules
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
- **Key rule: Only Actions and Effects can mutate state.**
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
- ```tsx
146
- // Correct - mutation inside action
147
- setup({ state }) {
148
- return {
149
- increment: () => { state.count++; }, // Action mutates state
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
- // Correct - mutation inside effect
154
- effect(() => {
155
- state.computed = state.a + state.b; // Effect can mutate state
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
- ### Store Lifecycle
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
- ## Features That Set Storion Apart
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
- ### 🎯 Automatic Dependency Tracking
266
+ ```ts
267
+ import { store } from "storion";
190
268
 
191
- Unlike Redux/Zustand where you manually select state, Storion tracks what you _actually access_:
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
- ```tsx
194
- // Zustand - manual selection, easy to over-select
195
- const { user } = useStore((state) => ({ user: state.user }));
196
- // If ANY property of user changes, component re-renders
197
-
198
- // Storion - automatic fine-grained tracking
199
- const { name } = useStore(({ resolve }) => {
200
- const [state] = resolve(userStore);
201
- return { name: state.user.name }; // Only tracks user.name
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
- ### 🔬 Even Finer with `pick()`
308
+ **Focus setter supports three patterns:**
207
309
 
208
- Need to track a computed value instead of a property path?
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
- ```tsx
211
- import { pick, useStore } from "storion/react";
316
+ ### Reactive Effects
212
317
 
213
- const { fullName } = useStore(({ resolve }) => {
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) or compute derived state when dependencies change, and properly clean up when needed.
221
319
 
222
- ### 🔗 Cross-Store Composition
320
+ **With Storion:** Effects automatically track which state properties you read and re-run only when those change. Use them for side effects or computed state.
223
321
 
224
- Stores can seamlessly depend on other stores:
322
+ **Example 1: Computed/Derived State**
225
323
 
226
- ```tsx
227
- import { store } from "storion/react";
324
+ ```ts
325
+ import { store, effect } from "storion";
228
326
 
229
- const cartStore = store({
230
- name: "cart",
231
- state: { items: [] },
232
- setup({ state, resolve }) {
233
- // Access user store from within cart store
234
- const [userState] = resolve(userStore);
327
+ export const userStore = store({
328
+ name: "user",
329
+ state: {
330
+ firstName: "",
331
+ lastName: "",
332
+ fullName: "", // Computed from firstName + lastName
333
+ },
334
+ setup({ state }) {
335
+ // Auto-updates fullName when firstName or lastName changes
336
+ effect(() => {
337
+ state.fullName = `${state.firstName} ${state.lastName}`.trim();
338
+ });
235
339
 
236
340
  return {
237
- checkout: () => {
238
- if (!userState.isLoggedIn) throw new Error("Must be logged in");
239
- // ... checkout logic
341
+ setFirstName: (name: string) => {
342
+ state.firstName = name;
343
+ },
344
+ setLastName: (name: string) => {
345
+ state.lastName = name;
240
346
  },
241
347
  };
242
348
  },
243
349
  });
244
350
  ```
245
351
 
246
- ### 🎭 Reactive Effects
247
-
248
- Built-in effects that automatically track dependencies and clean up:
249
-
250
- ```tsx
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);
352
+ **Example 2: External System Sync**
258
353
 
259
- // Runs whenever routerState.path changes
260
- effect(() => {
261
- trackPageView(routerState.path);
262
- state.pageViews++;
263
- });
354
+ ```ts
355
+ import { store, effect } from "storion";
264
356
 
265
- return {};
357
+ export const syncStore = store({
358
+ name: "sync",
359
+ state: {
360
+ userId: null as string | null,
361
+ syncStatus: "idle" as "idle" | "syncing" | "synced",
266
362
  },
267
- });
268
- ```
363
+ setup({ state }) {
364
+ effect((ctx) => {
365
+ if (!state.userId) return;
269
366
 
270
- ### 📝 Local Stores (Component-Scoped)
367
+ const ws = new WebSocket(`/ws?user=${state.userId}`);
368
+ state.syncStatus = "syncing";
271
369
 
272
- Perfect for forms, modals, wizards — any component-local state:
370
+ ws.onopen = () => {
371
+ state.syncStatus = "synced";
372
+ };
273
373
 
274
- ```tsx
275
- import { store, useStore } from "storion/react";
374
+ // Cleanup when effect re-runs or store disposes
375
+ ctx.onCleanup(() => ws.close());
376
+ });
276
377
 
277
- const formStore = store({
278
- name: "form",
279
- state: { email: "", password: "" },
280
- setup({ state }) {
281
378
  return {
282
- setEmail: (v: string) => {
283
- state.email = v;
379
+ login: (id: string) => {
380
+ state.userId = id;
284
381
  },
285
- setPassword: (v: string) => {
286
- state.password = v;
382
+ logout: () => {
383
+ state.userId = null;
287
384
  },
288
385
  };
289
386
  },
290
387
  });
388
+ ```
291
389
 
292
- function LoginForm() {
293
- // Each component instance gets its own store!
294
- const [state, actions, { dirty, reset }] = useStore(formStore);
390
+ ### Effect with Safe Async
295
391
 
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
- );
312
- }
313
- ```
392
+ **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.
314
393
 
315
- ### 🔄 Immer-Style Updates
394
+ **With Storion:** Use `ctx.safe()` to wrap promises that should be ignored if stale, or `ctx.signal` for fetch cancellation.
316
395
 
317
- Update complex nested state with simple mutations:
396
+ ```ts
397
+ effect((ctx) => {
398
+ const userId = state.userId;
399
+ if (!userId) return;
400
+
401
+ // ctx.safe() wraps promises to never resolve if stale
402
+ ctx.safe(fetchUserData(userId)).then((data) => {
403
+ // Only runs if effect hasn't re-run
404
+ state.userData = data;
405
+ });
318
406
 
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
- }
407
+ // Or use abort signal for fetch
408
+ fetch(`/api/user/${userId}`, { signal: ctx.signal })
409
+ .then((res) => res.json())
410
+ .then((data) => {
411
+ state.userData = data;
412
+ });
413
+ });
336
414
  ```
337
415
 
338
- ### ⚙️ Flexible Equality
416
+ ### Fine-Grained Updates with `pick()`
417
+
418
+ **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.
339
419
 
340
- Configure how changes are detected per-property:
420
+ **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.
341
421
 
342
422
  ```tsx
343
- import { store } from "storion/react";
423
+ import { pick } from "storion";
344
424
 
345
- const userStore = store({
346
- name: "user",
347
- state: {
348
- profile: { name: "", bio: "" },
349
- settings: { theme: "dark" },
350
- lastLogin: new Date(),
351
- },
352
- // Deep compare profile, shallow compare settings, strict for rest
353
- equality: {
354
- profile: "deep",
355
- settings: "shallow",
356
- default: "strict",
357
- },
358
- setup({ state }) {
359
- /* ... */
360
- },
361
- });
425
+ function UserName() {
426
+ // Without pick: re-renders when ANY profile property changes
427
+ const { name } = useStore(({ get }) => {
428
+ const [state] = get(userStore);
429
+ return { name: state.profile.name };
430
+ });
431
+
432
+ // With pick: re-renders ONLY when profile.name changes
433
+ const { name } = useStore(({ get }) => {
434
+ const [state] = get(userStore);
435
+ return { name: pick(() => state.profile.name) };
436
+ });
437
+
438
+ return <h1>{name}</h1>;
439
+ }
362
440
  ```
363
441
 
364
- ### 🎬 Action-Based Reactivity
442
+ ### Async State Management
365
443
 
366
- React to action dispatches, not just state changes:
444
+ **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.
367
445
 
368
- ```tsx
369
- import { effect } from "storion/react";
446
+ **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).
370
447
 
371
- effect(() => {
372
- const lastSave = saveAction.last();
373
- if (!lastSave) return;
448
+ ```ts
449
+ import { store } from "storion";
450
+ import { async, type AsyncState } from "storion/async";
374
451
 
375
- // Runs every time saveAction is dispatched
376
- showNotification(`Saved at ${new Date()}`);
377
- });
452
+ interface Product {
453
+ id: string;
454
+ name: string;
455
+ price: number;
456
+ }
378
457
 
379
- // Or subscribe directly
380
- instance.subscribe("@save", (event) => {
381
- console.log("Save called with:", event.next.args);
458
+ export const productStore = store({
459
+ name: "products",
460
+ state: {
461
+ // Fresh mode: data is undefined during loading
462
+ featured: async.fresh<Product>(),
463
+ // Stale mode: preserves previous data during loading (SWR pattern)
464
+ list: async.stale<Product[]>([]),
465
+ },
466
+ setup({ focus }) {
467
+ const featuredActions = async<Product, "fresh", [string]>(
468
+ focus("featured"),
469
+ async (ctx, productId) => {
470
+ const res = await fetch(`/api/products/${productId}`, {
471
+ signal: ctx.signal,
472
+ });
473
+ return res.json();
474
+ },
475
+ {
476
+ retry: { count: 3, delay: (attempt) => attempt * 1000 },
477
+ onError: (err) => console.error("Failed to fetch product:", err),
478
+ }
479
+ );
480
+
481
+ const listActions = async<Product[], "stale", []>(
482
+ focus("list"),
483
+ async () => {
484
+ const res = await fetch("/api/products");
485
+ return res.json();
486
+ }
487
+ );
488
+
489
+ return {
490
+ fetchFeatured: featuredActions.dispatch,
491
+ fetchList: listActions.dispatch,
492
+ refreshList: listActions.refresh,
493
+ cancelFeatured: featuredActions.cancel,
494
+ };
495
+ },
382
496
  });
383
- ```
384
497
 
385
- ### 🧩 Mixins Split & Reuse Store Logic
498
+ // In React - handle async states
499
+ function ProductList() {
500
+ const { list, fetchList } = useStore(({ get }) => {
501
+ const [state, actions] = get(productStore);
502
+ return { list: state.list, fetchList: actions.fetchList };
503
+ });
386
504
 
387
- Large stores can be split into mixins using `use()`. Each mixin only knows about its own state slice:
505
+ useEffect(() => {
506
+ fetchList();
507
+ }, []);
388
508
 
389
- ```tsx
390
- import { store, effect, type StoreContext } from "storion/react";
509
+ if (list.status === "pending" && !list.data?.length) {
510
+ return <Spinner />;
511
+ }
391
512
 
392
- // Each mixin defines its OWN state shape
393
- interface UserState {
394
- users: User[];
395
- }
513
+ if (list.status === "error") {
514
+ return <Error message={list.error.message} />;
515
+ }
396
516
 
397
- interface PostState {
398
- posts: Post[];
517
+ return (
518
+ <ul>
519
+ {list.data?.map((p) => (
520
+ <li key={p.id}>{p.name}</li>
521
+ ))}
522
+ {list.status === "pending" && <li>Loading more...</li>}
523
+ </ul>
524
+ );
399
525
  }
526
+ ```
400
527
 
401
- interface NotificationState {
402
- notifications: Notification[];
403
- }
528
+ ### Dependency Injection
404
529
 
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
- });
530
+ **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.
414
531
 
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
532
+ **With Storion:** The container acts as a DI container. Define factory functions and resolve them with `get()`. Services are cached as singletons automatically.
421
533
 
422
- return {
423
- addUser: (user: User) => {
424
- state.users.push(user);
425
- notify(`User ${user.name} added`); // Use action from other mixin
426
- },
427
- removeUser: (id: string) => {
428
- state.users = state.users.filter((u) => u.id !== id);
429
- notify(`User removed`);
430
- },
431
- };
432
- };
534
+ ```ts
535
+ import { container, type Resolver } from "storion";
433
536
 
434
- // Mixin 3: Post management — also uses notificationMixin!
435
- const postMixin = ({
436
- state,
437
- use,
438
- }: StoreContext<PostState & NotificationState>) => {
439
- const { notify } = use(notificationMixin);
537
+ // Define service factory
538
+ interface ApiService {
539
+ get<T>(url: string): Promise<T>;
540
+ post<T>(url: string, data: unknown): Promise<T>;
541
+ }
440
542
 
441
- effect(() => {
442
- console.log(`Posts updated: ${state.posts.length} total`);
443
- });
543
+ function createApiService(resolver: Resolver): ApiService {
544
+ const baseUrl = resolver.get(configFactory).apiUrl;
444
545
 
445
546
  return {
446
- addPost: (post: Post) => {
447
- state.posts.push(post);
448
- notify(`New post: ${post.title}`);
547
+ async get(url) {
548
+ const res = await fetch(`${baseUrl}${url}`);
549
+ return res.json();
449
550
  },
450
- deletePost: (id: string) => {
451
- state.posts = state.posts.filter((p) => p.id !== id);
452
- notify(`Post deleted`);
551
+ async post(url, data) {
552
+ const res = await fetch(`${baseUrl}${url}`, {
553
+ method: "POST",
554
+ body: JSON.stringify(data),
555
+ });
556
+ return res.json();
453
557
  },
454
558
  };
455
- };
559
+ }
456
560
 
457
- // Compose: AppState = UserState & PostState & NotificationState
458
- const appStore = store({
459
- name: "app",
460
- state: {
461
- users: [] as User[],
462
- posts: [] as Post[],
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);
561
+ function configFactory(): { apiUrl: string } {
562
+ return { apiUrl: process.env.API_URL ?? "http://localhost:3000" };
563
+ }
564
+
565
+ // Use in store
566
+ const userStore = store({
567
+ name: "user",
568
+ state: { user: null },
569
+ setup({ get }) {
570
+ const api = get(createApiService); // Singleton, cached
470
571
 
471
572
  return {
472
- ...userActions,
473
- ...postActions,
474
- ...notificationActions,
573
+ fetchUser: async (id: string) => {
574
+ return api.get(`/users/${id}`);
575
+ },
475
576
  };
476
577
  },
477
578
  });
478
579
  ```
479
580
 
480
- **Benefits:**
581
+ ### Middleware
481
582
 
482
- - 📁 **Organization** Split 500+ line stores into focused modules
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
583
+ **The problem:** You need cross-cutting behavior (logging, persistence, devtools) applied to some or all stores, without modifying each store individually.
486
584
 
487
- **Note:** `use()` runs the mixin fresh each call (no singleton). This enables parameterized mixins:
585
+ **With Storion:** Compose middleware and apply it conditionally using patterns like `"user*"` (startsWith), `"*Store"` (endsWith), or custom predicates.
488
586
 
489
587
  ```ts
490
- const apiMixin = ({ state }, endpoint: string) => ({
491
- fetch: () => fetch(endpoint),
492
- });
493
-
494
- use(apiMixin, "/api/users"); // Different instances
495
- use(apiMixin, "/api/posts");
496
- ```
588
+ import { container, compose, applyFor, applyExcept } from "storion";
589
+ import type { StoreMiddleware } from "storion";
497
590
 
498
- If you need singleton per store, use `memoize` (e.g., from lodash):
591
+ // Logging middleware - ctx.spec is always available
592
+ const loggingMiddleware: StoreMiddleware = (ctx) => {
593
+ console.log(`Creating store: ${ctx.displayName}`);
594
+ const instance = ctx.next();
595
+ console.log(`Created: ${instance.id}`);
596
+ return instance;
597
+ };
499
598
 
500
- ```ts
501
- import memoize from "lodash/memoize";
502
-
503
- // Memoized by first arg (StoreContext) — singleton per store
504
- const notificationMixin = memoize(
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
- );
599
+ // Persistence middleware
600
+ const persistMiddleware: StoreMiddleware = (ctx) => {
601
+ const instance = ctx.next();
602
+ // Access store-specific options directly
603
+ const isPersistent = ctx.spec.options.meta?.persist === true;
604
+ if (isPersistent) {
605
+ // Add persistence logic...
606
+ }
607
+ return instance;
608
+ };
513
609
 
514
- // Same store context → same mixin instance
515
- // Different store context → new mixin instance
610
+ const app = container({
611
+ middleware: compose(
612
+ // Apply logging to all stores starting with "user"
613
+ applyFor("user*", loggingMiddleware),
614
+
615
+ // Apply persistence except for cache stores
616
+ applyExcept("*Cache", persistMiddleware),
617
+
618
+ // Apply to specific stores
619
+ applyFor(["authStore", "settingsStore"], loggingMiddleware),
620
+
621
+ // Apply based on custom condition
622
+ applyFor(
623
+ (ctx) => ctx.spec.options.meta?.persist === true,
624
+ persistMiddleware
625
+ )
626
+ ),
627
+ });
516
628
  ```
517
629
 
518
630
  ---
519
631
 
520
632
  ## API Reference
521
633
 
522
- ### `store(options)` — Define a Store
634
+ ### Core (`storion`)
523
635
 
524
- ```ts
525
- import { store } from "storion";
636
+ | Export | Description |
637
+ | ---------------------- | ---------------------------------------------- |
638
+ | `store(options)` | Create a store specification |
639
+ | `container(options?)` | Create a container for store instances and DI |
640
+ | `effect(fn, options?)` | Create reactive side effects with cleanup |
641
+ | `pick(fn, equality?)` | Fine-grained derived value tracking |
642
+ | `batch(fn)` | Batch multiple mutations into one notification |
643
+ | `untrack(fn)` | Read state without tracking dependencies |
526
644
 
527
- const myStore = store({
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)
645
+ #### Store Options
537
646
 
538
- return {
539
- increment: () => state.count++,
540
- };
541
- },
542
- equality: "shallow", // Or per-prop: { count: "deep", default: "strict" }
543
- lifetime: "autoDispose", // "keepAlive" (default) | "autoDispose"
544
- meta: { persist: true }, // Custom metadata for middleware
545
- onDispatch: (event) => {}, // Called after each action
546
- onError: (error) => {}, // Called on effect/action errors
547
- normalize: (state) => ({}), // For dehydrate() serialization
548
- denormalize: (data) => ({}), // For hydrate() deserialization
549
- });
647
+ ```ts
648
+ interface StoreOptions<TState, TActions> {
649
+ name?: string; // Store display name for debugging (becomes spec.displayName)
650
+ state: TState; // Initial state
651
+ setup: (ctx: StoreContext) => TActions; // Setup function
652
+ lifetime?: "singleton" | "autoDispose"; // Instance lifetime
653
+ equality?: Equality | EqualityMap; // Custom equality for state
654
+ onDispatch?: (event: DispatchEvent) => void; // Action dispatch callback
655
+ onError?: (error: unknown) => void; // Error callback
656
+ }
550
657
  ```
551
658
 
552
- ### `container(options?)` — Manage Store Instances
659
+ #### StoreContext (in setup)
553
660
 
554
661
  ```ts
555
- import { container } from "storion";
556
-
557
- const app = container({
558
- middleware: [logger, devtools], // Applied to all stores
559
- defaultLifetime: "autoDispose", // Override default lifetime
560
- });
561
-
562
- // Get or create a store instance
563
- const instance = app.get(myStore);
662
+ interface StoreContext<TState, TActions> {
663
+ state: TState; // First-level props only (state.x = y)
664
+ get<T>(spec: StoreSpec<T>): StoreTuple; // Get dependency store
665
+ get<T>(factory: Factory<T>): T; // Get DI service
666
+ focus<P extends Path>(path: P): Focus; // Lens-like accessor
667
+ update(fn: (draft: TState) => void): void; // For nested/array mutations
668
+ dirty(prop?: keyof TState): boolean; // Check if state changed
669
+ reset(): void; // Reset to initial state
670
+ onDispose(fn: VoidFunction): void; // Register cleanup
671
+ }
672
+ ```
564
673
 
565
- // Check if store exists
566
- app.has(myStore); // boolean
674
+ > **Note:** `state` allows direct assignment only for first-level properties. Use `update()` for nested objects, arrays, or batch updates.
567
675
 
568
- // Clear all instances
569
- app.clear();
676
+ ### React (`storion/react`)
570
677
 
571
- // Global container
572
- import { container } from "storion";
573
- const global = container.global;
574
- ```
678
+ | Export | Description |
679
+ | -------------------------- | ----------------------------------------- |
680
+ | `StoreProvider` | Provides container to React tree |
681
+ | `useStore(selector)` | Hook to consume stores with selector |
682
+ | `useStore(spec)` | Hook for component-local store |
683
+ | `useContainer()` | Access container from context |
684
+ | `create(options)` | Create store + hook for single-store apps |
685
+ | `withStore(hook, render?)` | HOC pattern for store consumption |
575
686
 
576
- ### `effect(fn, options?)` — Reactive Effects
687
+ #### useStore Selector
577
688
 
578
689
  ```ts
579
- import { effect } from "storion";
690
+ // Selector receives context with get() for accessing stores
691
+ const result = useStore(({ get, mixin, once }) => {
692
+ const [state, actions] = get(myStore);
693
+ const service = get(myFactory);
580
694
 
581
- const dispose = effect((ctx) => {
582
- // Runs immediately, re-runs when tracked values change
583
- console.log(state.count);
695
+ // Run once on mount
696
+ once(() => actions.init());
584
697
 
585
- // Register cleanup (runs before re-run and on dispose)
586
- ctx.onCleanup(() => {
587
- console.log("cleaning up");
588
- });
698
+ return { value: state.value, action: actions.doSomething };
589
699
  });
700
+ ```
590
701
 
591
- // Options
592
- effect(fn, {
593
- name: "myEffect", // For debugging
594
- onError: "keepAlive", // "failFast" | "keepAlive" | custom handler
595
- });
702
+ ### Async (`storion/async`)
596
703
 
597
- // Stop the effect
598
- dispose();
599
- ```
704
+ | Export | Description |
705
+ | --------------------------------- | ------------------------------------------- |
706
+ | `async(focus, handler, options?)` | Create async action |
707
+ | `async.fresh<T>()` | Create fresh mode initial state |
708
+ | `async.stale<T>(initial)` | Create stale mode initial state |
709
+ | `async.wait(state)` | Extract data or throw (Suspense-compatible) |
710
+ | `async.all(...states)` | Wait for all states to be ready |
711
+ | `async.any(...states)` | Get first ready state |
712
+ | `async.race(states)` | Race between states |
713
+ | `async.hasData(state)` | Check if state has data |
714
+ | `async.isLoading(state)` | Check if state is loading |
715
+ | `async.isError(state)` | Check if state has error |
600
716
 
601
- ### `batch(fn)` — Batch Updates
717
+ #### AsyncState Types
602
718
 
603
719
  ```ts
604
- import { batch } from "storion";
605
-
606
- // Multiple writes, single notification
607
- batch(() => {
608
- state.a = 1;
609
- state.b = 2;
610
- state.c = 3;
611
- });
720
+ interface AsyncState<T, M extends "fresh" | "stale"> {
721
+ status: "idle" | "pending" | "success" | "error";
722
+ mode: M;
723
+ data: M extends "stale" ? T : T | undefined;
724
+ error: Error | undefined;
725
+ timestamp: number | undefined;
726
+ }
612
727
  ```
613
728
 
614
- ### `untrack(fn)` — Read Without Tracking
729
+ ### Middleware
615
730
 
616
- ```ts
617
- import { untrack } from "storion";
731
+ | Export | Description |
732
+ | ------------- | -------------------------------------------------- |
733
+ | `compose` | Compose multiple StoreMiddleware into one |
734
+ | `applyFor` | Apply middleware conditionally (pattern/predicate) |
735
+ | `applyExcept` | Apply middleware except for matching patterns |
618
736
 
619
- effect(() => {
620
- const tracked = state.count; // Creates dependency
621
- const untracked = untrack(() => state.other); // No dependency
622
- });
623
- ```
737
+ #### StoreMiddlewareContext
624
738
 
625
- ### `pick(selector, equality?)` Fine-Grained Tracking
739
+ Container middleware uses `StoreMiddlewareContext` where `spec` is always available:
626
740
 
627
741
  ```ts
628
- import { pick } from "storion";
742
+ interface StoreMiddlewareContext<S, A> {
743
+ spec: StoreSpec<S, A>; // The store spec (always present)
744
+ resolver: Resolver; // The resolver/container instance
745
+ next: () => StoreInstance<S, A>; // Call next middleware or create the store
746
+ displayName: string; // Store name (always present for stores)
747
+ }
629
748
 
630
- // Only re-renders when the RESULT changes
631
- const fullName = pick(() => `${state.first} ${state.last}`);
632
- const total = pick(() => state.items.reduce((s, i) => s + i.price, 0), "deep");
749
+ type StoreMiddleware = <S, A>(
750
+ ctx: StoreMiddlewareContext<S, A>
751
+ ) => StoreInstance<S, A>;
633
752
  ```
634
753
 
635
- ### Store Instance
754
+ For generic resolver middleware (non-container), use `Middleware` with `MiddlewareContext`:
636
755
 
637
756
  ```ts
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
757
+ interface MiddlewareContext<T> {
758
+ factory: Factory<T>; // The factory being invoked
759
+ resolver: Resolver; // The resolver instance
760
+ next: () => T; // Call next middleware or the factory
761
+ displayName?: string; // Name (from factory.displayName or factory.name)
762
+ }
646
763
 
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
764
+ type Middleware = <T>(ctx: MiddlewareContext<T>) => T;
765
+ ```
652
766
 
653
- // Lifecycle
654
- instance.onDispose(() => {});
655
- instance.dispose();
656
- instance.disposed(); // boolean
767
+ ### Devtools (`storion/devtools`)
657
768
 
658
- // State management
659
- instance.dirty(); // Any prop modified?
660
- instance.dirty("count"); // Specific prop modified?
661
- instance.reset(); // Reset to initial state
769
+ ```ts
770
+ import { devtools } from "storion/devtools";
662
771
 
663
- // Persistence
664
- instance.dehydrate(); // Get serializable state
665
- instance.hydrate(data); // Restore state (skips dirty props)
772
+ const app = container({
773
+ middleware: devtools({
774
+ name: "My App",
775
+ // Enable in development only
776
+ enabled: process.env.NODE_ENV === "development",
777
+ }),
778
+ });
666
779
  ```
667
780
 
668
- ### React Hooks
781
+ ### Devtools Panel (`storion/devtools-panel`)
669
782
 
670
783
  ```tsx
671
- import { useStore, StoreProvider, create } from "storion/react";
784
+ import { DevtoolsPanel } from "storion/devtools-panel";
672
785
 
673
- // Selector-based (with container)
674
- const { count } = useStore(({ resolve }) => {
675
- const [state, actions] = resolve(myStore);
676
- return { count: state.count };
677
- });
786
+ // Mount anywhere in your app (dev only)
787
+ function App() {
788
+ return (
789
+ <>
790
+ <MyApp />
791
+ {process.env.NODE_ENV === "development" && <DevtoolsPanel />}
792
+ </>
793
+ );
794
+ }
795
+ ```
678
796
 
679
- // Local store (no provider needed)
680
- const [state, actions, { dirty, reset }] = useStore(formStore);
797
+ ---
681
798
 
682
- // Provider
683
- <StoreProvider container={app}>
684
- <App />
685
- </StoreProvider>;
799
+ ## Edge Cases & Best Practices
686
800
 
687
- // Shorthand for single-store apps
688
- const [instance, useCounter] = create({
689
- state: { count: 0 },
690
- setup({ state }) {
691
- return { increment: () => state.count++ };
692
- },
693
- });
801
+ ### Don't directly mutate nested state or arrays
802
+
803
+ Direct mutation only works for first-level properties. Use `update()` for nested objects and arrays:
694
804
 
695
- // useCounter selector receives (state, actions)
696
- const { count } = useCounter((state, actions) => ({ count: state.count }));
805
+ ```ts
806
+ // Wrong - nested mutation won't trigger reactivity
807
+ setup({ state }) {
808
+ return {
809
+ setName: (name: string) => {
810
+ state.profile.name = name; // Won't work!
811
+ },
812
+ addItem: (item: string) => {
813
+ state.items.push(item); // Won't work!
814
+ },
815
+ };
816
+ }
817
+
818
+ // ✅ Correct - use update() for nested/array mutations
819
+ setup({ state, update }) {
820
+ return {
821
+ setName: (name: string) => {
822
+ update((draft) => {
823
+ draft.profile.name = name;
824
+ });
825
+ },
826
+ addItem: (item: string) => {
827
+ update((draft) => {
828
+ draft.items.push(item);
829
+ });
830
+ },
831
+ // First-level props can be assigned directly
832
+ setCount: (n: number) => {
833
+ state.count = n; // This works!
834
+ },
835
+ };
836
+ }
697
837
  ```
698
838
 
699
- ### Middleware
839
+ ### ❌ Don't call `get()` inside actions
840
+
841
+ `get()` is for declaring dependencies during setup, not runtime:
700
842
 
701
843
  ```ts
702
- import { applyFor, applyExcept } from "storion";
844
+ // Wrong - calling get() inside action
845
+ setup({ get }) {
846
+ return {
847
+ doSomething: () => {
848
+ const [other] = get(otherStore); // Don't do this!
849
+ },
850
+ };
851
+ }
703
852
 
704
- // Just pass an array of middleware
705
- container({ middleware: [logger, devtools, persist] });
853
+ // Correct - declare dependency at setup time
854
+ setup({ get }) {
855
+ const [otherState, otherActions] = get(otherStore);
706
856
 
707
- // Conditional middleware
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
857
+ return {
858
+ doSomething: () => {
859
+ if (otherState.ready) {
860
+ // Use the reactive state captured during setup
861
+ }
862
+ },
863
+ };
864
+ }
865
+ ```
866
+
867
+ ### ❌ Don't return Promises from effects
711
868
 
712
- // Exclude middleware
713
- const noInternalLogging = applyExcept("_internal*", logger);
869
+ Effects must be synchronous. Use `ctx.safe()` for async:
870
+
871
+ ```ts
872
+ // ❌ Wrong - async effect
873
+ effect(async (ctx) => {
874
+ const data = await fetchData(); // Don't do this!
875
+ });
714
876
 
715
- // Combine them
716
- container({ middleware: [conditionalLogger, persistOnly, noInternalLogging] });
877
+ // Correct - use ctx.safe()
878
+ effect((ctx) => {
879
+ ctx.safe(fetchData()).then((data) => {
880
+ state.data = data;
881
+ });
882
+ });
717
883
  ```
718
884
 
719
- ### Middleware Signature
885
+ ### Use `pick()` for computed values from nested state
886
+
887
+ When reading nested state in selectors, use `pick()` for fine-grained reactivity:
720
888
 
721
889
  ```ts
722
- type StoreMiddleware = (
723
- spec: StoreSpec,
724
- next: (spec: StoreSpec) => StoreInstance
725
- ) => StoreInstance;
726
-
727
- // Example: logging middleware
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
- };
890
+ // Re-renders when profile object changes (coarse tracking)
891
+ const name = state.profile.name;
892
+
893
+ // Re-renders only when the actual name value changes (fine tracking)
894
+ const name = pick(() => state.profile.name);
895
+ const fullName = pick(() => `${state.profile.first} ${state.profile.last}`);
734
896
  ```
735
897
 
736
- ---
898
+ ### ✅ Use stale mode for SWR patterns
737
899
 
738
- ## Comparison
900
+ ```ts
901
+ // Fresh mode: data is undefined during loading
902
+ state: {
903
+ data: async.fresh<Data>(),
904
+ }
739
905
 
740
- | Feature | Storion | Zustand | Redux Toolkit | Jotai |
741
- | ------------------- | --------- | -------- | ------------- | --------- |
742
- | Bundle size | ~3KB | ~1KB | ~10KB | ~3KB |
743
- | Boilerplate | Minimal | Minimal | Moderate | Minimal |
744
- | Dependency tracking | Automatic | Manual | Manual | Automatic |
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 | 🚧 | ✅ | ✅ | ✅ |
906
+ // Stale mode: preserves previous data during loading (SWR pattern)
907
+ state: {
908
+ data: async.stale<Data>(initialData),
909
+ }
910
+ ```
751
911
 
752
912
  ---
753
913
 
@@ -755,57 +915,107 @@ const logger: StoreMiddleware = (spec, next) => {
755
915
 
756
916
  Storion is written in TypeScript and provides excellent type inference:
757
917
 
758
- ```tsx
759
- import { store, useStore } from "storion/react";
918
+ ```ts
919
+ // State and action types are inferred
920
+ const myStore = store({
921
+ name: "my-store",
922
+ state: { count: 0, name: "" },
923
+ setup({ state }) {
924
+ return {
925
+ inc: () => state.count++, // () => void
926
+ setName: (n: string) => (state.name = n), // (n: string) => string
927
+ };
928
+ },
929
+ });
760
930
 
761
- const todoStore = store({
762
- name: "todos",
931
+ // Using with explicit types when needed (unions, nullable)
932
+ interface MyState {
933
+ userId: string | null;
934
+ status: "idle" | "loading" | "ready";
935
+ }
936
+
937
+ const typedStore = store({
938
+ name: "typed",
763
939
  state: {
764
- items: [] as Todo[],
765
- filter: "all" as "all" | "active" | "done",
766
- },
940
+ userId: null as string | null,
941
+ status: "idle" as "idle" | "loading" | "ready",
942
+ } satisfies MyState,
767
943
  setup({ state }) {
768
944
  return {
769
- add: (text: string) => {
770
- /* ... */
771
- },
772
- toggle: (id: number) => {
773
- /* ... */
774
- },
775
- setFilter: (f: typeof state.filter) => {
776
- state.filter = f;
945
+ setUser: (id: string | null) => {
946
+ state.userId = id;
777
947
  },
778
948
  };
779
949
  },
780
950
  });
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
951
  ```
789
952
 
790
953
  ---
791
954
 
792
- ## Installation
955
+ ## Contributing
956
+
957
+ We welcome contributions! Here's how to get started:
958
+
959
+ ### Prerequisites
960
+
961
+ - Node.js 18+
962
+ - pnpm 8+
963
+
964
+ ### Setup
793
965
 
794
966
  ```bash
795
- # npm
796
- npm install storion
967
+ # Clone the repo
968
+ git clone https://github.com/linq2js/storion.git
969
+ cd storion
797
970
 
798
- # yarn
799
- yarn add storion
971
+ # Install dependencies
972
+ pnpm install
800
973
 
801
- # pnpm
802
- pnpm add storion
974
+ # Build the library
975
+ pnpm --filter storion build
976
+ ```
977
+
978
+ ### Development
979
+
980
+ ```bash
981
+ # Watch mode
982
+ pnpm --filter storion dev
983
+
984
+ # Run tests
985
+ pnpm --filter storion test
986
+
987
+ # Run tests with UI
988
+ pnpm --filter storion test:ui
989
+
990
+ # Type check
991
+ pnpm --filter storion build:check
992
+ ```
993
+
994
+ ### Code Style
995
+
996
+ - Prefer **type inference** over explicit interfaces (add types only for unions, nullable, discriminated unions)
997
+ - Keep examples **copy/paste runnable**
998
+ - Write tests for new features
999
+ - Follow existing patterns in the codebase
1000
+
1001
+ ### Commit Messages
1002
+
1003
+ Use [Conventional Commits](https://www.conventionalcommits.org/):
1004
+
1005
+ ```
1006
+ feat(core): add new feature
1007
+ fix(react): resolve hook issue
1008
+ docs: update README
1009
+ chore: bump dependencies
803
1010
  ```
804
1011
 
805
- ### Requirements
1012
+ ### Pull Requests
806
1013
 
807
- - React 18+ (for React integration)
808
- - TypeScript 4.7+ (recommended)
1014
+ 1. Fork the repo and create your branch from `main`
1015
+ 2. Add tests for new functionality
1016
+ 3. Ensure all tests pass
1017
+ 4. Update documentation as needed
1018
+ 5. Submit a PR with a clear description
809
1019
 
810
1020
  ---
811
1021
 
@@ -816,5 +1026,5 @@ MIT © [linq2js](https://github.com/linq2js)
816
1026
  ---
817
1027
 
818
1028
  <p align="center">
819
- <sub>Built with ❤️ for developers who want state management that just works.</sub>
1029
+ <sub>Built with ❤️ for the React community</sub>
820
1030
  </p>