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.
Files changed (88) hide show
  1. package/README.md +705 -573
  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/equality.d.ts +23 -3
  16. package/dist/core/equality.d.ts.map +1 -1
  17. package/dist/core/fnWrapper.d.ts +54 -0
  18. package/dist/core/fnWrapper.d.ts.map +1 -0
  19. package/dist/core/pick.d.ts +6 -6
  20. package/dist/core/pick.d.ts.map +1 -1
  21. package/dist/core/store.d.ts +8 -8
  22. package/dist/core/store.d.ts.map +1 -1
  23. package/dist/core/storeContext.d.ts +63 -0
  24. package/dist/core/storeContext.d.ts.map +1 -0
  25. package/dist/devtools/controller.d.ts +4 -0
  26. package/dist/devtools/controller.d.ts.map +1 -0
  27. package/dist/devtools/index.d.ts +16 -0
  28. package/dist/devtools/index.d.ts.map +1 -0
  29. package/dist/devtools/index.js +229 -0
  30. package/dist/devtools/middleware.d.ts +22 -0
  31. package/dist/devtools/middleware.d.ts.map +1 -0
  32. package/dist/devtools/types.d.ts +116 -0
  33. package/dist/devtools/types.d.ts.map +1 -0
  34. package/dist/devtools-panel/DevtoolsPanel.d.ts +17 -0
  35. package/dist/devtools-panel/DevtoolsPanel.d.ts.map +1 -0
  36. package/dist/devtools-panel/components/CompareModal.d.ts +10 -0
  37. package/dist/devtools-panel/components/CompareModal.d.ts.map +1 -0
  38. package/dist/devtools-panel/components/EventEntry.d.ts +14 -0
  39. package/dist/devtools-panel/components/EventEntry.d.ts.map +1 -0
  40. package/dist/devtools-panel/components/EventFilterBar.d.ts +10 -0
  41. package/dist/devtools-panel/components/EventFilterBar.d.ts.map +1 -0
  42. package/dist/devtools-panel/components/EventsTab.d.ts +15 -0
  43. package/dist/devtools-panel/components/EventsTab.d.ts.map +1 -0
  44. package/dist/devtools-panel/components/ResizeHandle.d.ts +8 -0
  45. package/dist/devtools-panel/components/ResizeHandle.d.ts.map +1 -0
  46. package/dist/devtools-panel/components/StoreEntry.d.ts +13 -0
  47. package/dist/devtools-panel/components/StoreEntry.d.ts.map +1 -0
  48. package/dist/devtools-panel/components/StoresTab.d.ts +12 -0
  49. package/dist/devtools-panel/components/StoresTab.d.ts.map +1 -0
  50. package/dist/devtools-panel/components/TabLayout.d.ts +48 -0
  51. package/dist/devtools-panel/components/TabLayout.d.ts.map +1 -0
  52. package/dist/devtools-panel/components/icons.d.ts +27 -0
  53. package/dist/devtools-panel/components/icons.d.ts.map +1 -0
  54. package/dist/devtools-panel/components/index.d.ts +15 -0
  55. package/dist/devtools-panel/components/index.d.ts.map +1 -0
  56. package/dist/devtools-panel/hooks.d.ts +23 -0
  57. package/dist/devtools-panel/hooks.d.ts.map +1 -0
  58. package/dist/devtools-panel/index.d.ts +25 -0
  59. package/dist/devtools-panel/index.d.ts.map +1 -0
  60. package/dist/devtools-panel/index.js +3326 -0
  61. package/dist/devtools-panel/mount.d.ts +41 -0
  62. package/dist/devtools-panel/mount.d.ts.map +1 -0
  63. package/dist/devtools-panel/styles.d.ts +50 -0
  64. package/dist/devtools-panel/styles.d.ts.map +1 -0
  65. package/dist/devtools-panel/types.d.ts +15 -0
  66. package/dist/devtools-panel/types.d.ts.map +1 -0
  67. package/dist/devtools-panel/utils.d.ts +21 -0
  68. package/dist/devtools-panel/utils.d.ts.map +1 -0
  69. package/dist/index.d.ts +6 -1
  70. package/dist/index.d.ts.map +1 -1
  71. package/dist/is.d.ts +69 -0
  72. package/dist/is.d.ts.map +1 -0
  73. package/dist/react/create.d.ts +1 -1
  74. package/dist/react/index.d.ts +1 -0
  75. package/dist/react/index.d.ts.map +1 -1
  76. package/dist/react/index.js +209 -33
  77. package/dist/react/useLocalStore.d.ts.map +1 -1
  78. package/dist/react/useStore.d.ts +2 -2
  79. package/dist/react/useStore.d.ts.map +1 -1
  80. package/dist/react/withStore.d.ts +140 -0
  81. package/dist/react/withStore.d.ts.map +1 -0
  82. package/dist/{index-rLf6DusB.js → store-XP2pujaJ.js} +537 -740
  83. package/dist/storion.js +740 -9
  84. package/dist/trigger.d.ts +40 -0
  85. package/dist/trigger.d.ts.map +1 -0
  86. package/dist/types.d.ts +516 -50
  87. package/dist/types.d.ts.map +1 -1
  88. package/package.json +13 -1
package/README.md CHANGED
@@ -1,753 +1,835 @@
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>
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
- **A tiny, type-safe reactive state management library with automatic dependency tracking.**
53
+ ## Features
4
54
 
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)
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, event listeners) when state changes, and properly clean up when the state changes again or the component unmounts.
221
319
 
222
- ### 🔗 Cross-Store Composition
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
- Stores can seamlessly depend on other stores:
322
+ ```ts
323
+ import { store, effect } from "storion";
225
324
 
226
- ```tsx
227
- import { store } from "storion/react";
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 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);
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
- checkout: () => {
238
- if (!userState.isLoggedIn) throw new Error("Must be logged in");
239
- // ... checkout logic
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
- ### 🎭 Reactive Effects
359
+ ### Effect with Safe Async
247
360
 
248
- Built-in effects that automatically track dependencies and clean up:
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
- ```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);
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
- return {};
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
- ### 📝 Local Stores (Component-Scoped)
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
- Perfect for forms, modals, wizards any component-local state:
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 { store, useStore } from "storion/react";
392
+ import { pick } from "storion";
276
393
 
277
- const formStore = store({
278
- name: "form",
279
- state: { email: "", password: "" },
280
- setup({ state }) {
281
- return {
282
- setEmail: (v: string) => {
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
- function LoginForm() {
293
- // Each component instance gets its own store!
294
- const [state, actions, { dirty, reset }] = useStore(formStore);
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
- ### 🔄 Immer-Style Updates
411
+ ### Async State Management
316
412
 
317
- Update complex nested state with simple mutations:
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
- ### ⚙️ Flexible Equality
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
- Configure how changes are detected per-property:
417
+ ```ts
418
+ import { store } from "storion";
419
+ import { async, type AsyncState } from "storion/async";
341
420
 
342
- ```tsx
343
- import { store } from "storion/react";
421
+ interface Product {
422
+ id: string;
423
+ name: string;
424
+ price: number;
425
+ }
344
426
 
345
- const userStore = store({
346
- name: "user",
427
+ export const productStore = store({
428
+ name: "products",
347
429
  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",
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({ state }) {
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
- ### 🎬 Action-Based Reactivity
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
- React to action dispatches, not just state changes:
474
+ useEffect(() => {
475
+ fetchList();
476
+ }, []);
367
477
 
368
- ```tsx
369
- import { effect } from "storion/react";
478
+ if (list.status === "pending" && !list.data?.length) {
479
+ return <Spinner />;
480
+ }
370
481
 
371
- effect(() => {
372
- const lastSave = saveAction.last();
373
- if (!lastSave) return;
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
- // Or subscribe directly
380
- instance.subscribe("@save", (event) => {
381
- console.log("Save called with:", event.next.args);
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
- ### 🧩 Mixins — Split & Reuse Store Logic
497
+ ### Dependency Injection
386
498
 
387
- Large stores can be split into mixins using `use()`. Each mixin only knows about its own state slice:
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
- ```tsx
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
- // Each mixin defines its OWN state shape
393
- interface UserState {
394
- users: User[];
395
- }
503
+ ```ts
504
+ import { container, type Resolver } from "storion";
396
505
 
397
- interface PostState {
398
- posts: Post[];
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
- interface NotificationState {
402
- notifications: Notification[];
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
- addUser: (user: User) => {
424
- state.users.push(user);
425
- notify(`User ${user.name} added`); // Use action from other mixin
516
+ async get(url) {
517
+ const res = await fetch(`${baseUrl}${url}`);
518
+ return res.json();
426
519
  },
427
- removeUser: (id: string) => {
428
- state.users = state.users.filter((u) => u.id !== id);
429
- notify(`User removed`);
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
- return {
446
- addPost: (post: Post) => {
447
- state.posts.push(post);
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
- // 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);
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
- ...userActions,
473
- ...postActions,
474
- ...notificationActions,
542
+ fetchUser: async (id: string) => {
543
+ return api.get(`/users/${id}`);
544
+ },
475
545
  };
476
546
  },
477
547
  });
478
548
  ```
479
549
 
480
- **Benefits:**
550
+ ### Middleware
481
551
 
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
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
- **Note:** `use()` runs the mixin fresh each call (no singleton). This enables parameterized mixins:
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
- const apiMixin = ({ state }, endpoint: string) => ({
491
- fetch: () => fetch(endpoint),
492
- });
557
+ import { container, compose, applyFor, applyExcept } from "storion";
493
558
 
494
- use(apiMixin, "/api/users"); // Different instances
495
- use(apiMixin, "/api/posts");
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
- If you need singleton per store, use `memoize` (e.g., from lodash):
573
+ const app = container({
574
+ middleware: compose(
575
+ // Apply logging to all stores starting with "user"
576
+ applyFor("user*", loggingMiddleware),
499
577
 
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
- );
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
- // Same store context same mixin instance
515
- // Different store context new mixin instance
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
- ### `store(options)` — Define a Store
594
+ ### Core (`storion`)
523
595
 
524
- ```ts
525
- import { store } from "storion";
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
- 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)
605
+ #### Store Options
537
606
 
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
- });
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
- ### `container(options?)` — Manage Store Instances
619
+ #### StoreContext (in setup)
553
620
 
554
621
  ```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);
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
- // Check if store exists
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
- // Clear all instances
569
- app.clear();
636
+ ### React (`storion/react`)
570
637
 
571
- // Global container
572
- import { container } from "storion";
573
- const global = container.global;
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
- ### `effect(fn, options?)` — Reactive Effects
647
+ #### useStore Selector
577
648
 
578
649
  ```ts
579
- import { effect } from "storion";
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
- const dispose = effect((ctx) => {
582
- // Runs immediately, re-runs when tracked values change
583
- console.log(state.count);
655
+ // Run once on mount
656
+ once(() => actions.init());
584
657
 
585
- // Register cleanup (runs before re-run and on dispose)
586
- ctx.onCleanup(() => {
587
- console.log("cleaning up");
588
- });
658
+ return { value: state.value, action: actions.doSomething };
589
659
  });
660
+ ```
590
661
 
591
- // Options
592
- effect(fn, {
593
- name: "myEffect", // For debugging
594
- onError: "keepAlive", // "failFast" | "keepAlive" | custom handler
595
- });
662
+ ### Async (`storion/async`)
596
663
 
597
- // Stop the effect
598
- dispose();
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
- ### `batch(fn)` — Batch Updates
677
+ #### AsyncState Types
602
678
 
603
679
  ```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
- });
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
- ### `untrack(fn)` — Read Without Tracking
689
+ ### Devtools (`storion/devtools`)
615
690
 
616
691
  ```ts
617
- import { untrack } from "storion";
692
+ import { devtools } from "storion/devtools";
618
693
 
619
- effect(() => {
620
- const tracked = state.count; // Creates dependency
621
- const untracked = untrack(() => state.other); // No dependency
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
- ### `pick(selector, equality?)` — Fine-Grained Tracking
703
+ ### Devtools Panel (`storion/devtools-panel`)
626
704
 
627
- ```ts
628
- import { pick } from "storion";
705
+ ```tsx
706
+ import { DevtoolsPanel } from "storion/devtools-panel";
629
707
 
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");
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
- ### Store Instance
719
+ ---
636
720
 
637
- ```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
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
- ### React Hooks
723
+ ### Don't directly mutate nested state or arrays
669
724
 
670
- ```tsx
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
- // Selector-based (with container)
674
- const { count } = useStore(({ resolve }) => {
675
- const [state, actions] = resolve(myStore);
676
- return { count: state.count };
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
- // Local store (no provider needed)
680
- const [state, actions, { dirty, reset }] = useStore(formStore);
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
- // Provider
683
- <StoreProvider container={app}>
684
- <App />
685
- </StoreProvider>;
761
+ ### ❌ Don't call `get()` inside actions
686
762
 
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
- });
763
+ `get()` is for declaring dependencies during setup, not runtime:
694
764
 
695
- // useCounter selector receives (state, actions)
696
- const { count } = useCounter((state, actions) => ({ count: state.count }));
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
- ### Middleware
775
+ // ✅ Correct - declare dependency at setup time
776
+ setup({ get }) {
777
+ const [otherState, otherActions] = get(otherStore);
700
778
 
701
- ```ts
702
- import { applyFor, applyExcept } from "storion";
779
+ return {
780
+ doSomething: () => {
781
+ if (otherState.ready) {
782
+ // Use the reactive state captured during setup
783
+ }
784
+ },
785
+ };
786
+ }
787
+ ```
703
788
 
704
- // Just pass an array of middleware
705
- container({ middleware: [logger, devtools, persist] });
789
+ ### Don't return Promises from effects
706
790
 
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
791
+ Effects must be synchronous. Use `ctx.safe()` for async:
711
792
 
712
- // Exclude middleware
713
- const noInternalLogging = applyExcept("_internal*", logger);
793
+ ```ts
794
+ // Wrong - async effect
795
+ effect(async (ctx) => {
796
+ const data = await fetchData(); // Don't do this!
797
+ });
714
798
 
715
- // Combine them
716
- container({ middleware: [conditionalLogger, persistOnly, noInternalLogging] });
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
- ### Middleware Signature
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
- 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
- };
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
- ## Comparison
822
+ ```ts
823
+ // Fresh mode: data is undefined during loading
824
+ state: {
825
+ data: async.fresh<Data>(),
826
+ }
739
827
 
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 | 🚧 | ✅ | ✅ | ✅ |
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
- ```tsx
759
- import { store, useStore } from "storion/react";
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
- const todoStore = store({
762
- name: "todos",
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
- items: [] as Todo[],
765
- filter: "all" as "all" | "active" | "done",
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
- add: (text: string) => {
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
- ## Installation
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
- # npm
796
- npm install storion
889
+ # Clone the repo
890
+ git clone https://github.com/linq2js/storion.git
891
+ cd storion
797
892
 
798
- # yarn
799
- yarn add storion
893
+ # Install dependencies
894
+ pnpm install
800
895
 
801
- # pnpm
802
- pnpm add storion
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
- ### Requirements
934
+ ### Pull Requests
806
935
 
807
- - React 18+ (for React integration)
808
- - TypeScript 4.7+ (recommended)
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 developers who want state management that just works.</sub>
951
+ <sub>Built with ❤️ for the React community</sub>
820
952
  </p>