storion 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +721 -456
  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,384 +1,589 @@
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
+ ```
2
50
 
3
- **A tiny, type-safe reactive state management library with automatic dependency tracking.**
51
+ ---
52
+
53
+ ## Features
54
+
55
+ - 🎯 **Auto-tracking** — Dependencies tracked automatically when you read state
56
+ - 🔒 **Type-safe** — Full TypeScript support with excellent inference
57
+ - ⚡ **Fine-grained updates** — Only re-render what actually changed
58
+ - 🧩 **Composable** — Mix stores, use DI, create derived values
59
+ - 🔄 **Reactive effects** — Side effects that automatically respond to state changes
60
+ - 📦 **Tiny footprint** — ~4KB minified + gzipped
61
+ - 🛠️ **DevTools** — Built-in devtools panel for debugging
62
+ - 🔌 **Middleware** — Extensible with conditional middleware patterns
63
+ - ⏳ **Async helpers** — First-class async state management with cancellation
64
+
65
+ ---
4
66
 
5
- [![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)
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
216
+ ## Usage
99
217
 
100
- ```
101
- ┌─────────────────────────────────────────────────────────────────┐
102
- │ Container │
103
- │ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
104
- │ │ Store A │ │ Store B │ │ Store C │ │
105
- │ │ ┌───────────┐ │ │ ┌───────────┐ │ │ ┌───────────┐ │ │
106
- │ │ │ State │◄─┼──┼──│ State │ │ │ │ State │ │ │
107
- │ │ └───────────┘ │ │ └───────────┘ │ │ └───────────┘ │ │
108
- │ │ ┌───────────┐ │ │ ┌───────────┐ │ │ ┌───────────┐ │ │
109
- │ │ │ Actions │──┼──┼─►│ Actions │ │ │ │ Actions │ │ │
110
- │ │ └───────────┘ │ │ └───────────┘ │ │ └───────────┘ │ │
111
- │ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
112
- │ ▲ ▲ ▲ │
113
- └────────────┼───────────────────┼───────────────────┼─────────────┘
114
- │ │ │
115
- ┌───────┴───────┐ ┌───────┴───────┐ ┌───────┴───────┐
116
- │ useStore │ │ Effect │ │ subscribe │
117
- │ (React Hook) │ │ (Reactive) │ │ (Vanilla JS) │
118
- └───────────────┘ └───────────────┘ └───────────────┘
119
- ```
120
-
121
- ### Core Concepts
218
+ ### Defining a Store
122
219
 
123
- | 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;
335
+
336
+ const ws = new WebSocket(`/ws?user=${state.userId}`);
337
+ state.syncStatus = "syncing";
228
338
 
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);
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()`
271
386
 
272
- Perfect for forms, modals, wizards any component-local state:
387
+ **The problem:** Your component re-renders when _any_ property of a nested object changes, even though you only use one field. For example, reading `state.profile.name` triggers re-renders when `profile.email` changes too.
388
+
389
+ **With Storion:** Wrap computed values in `pick()` to track the _result_ instead of the _path_. Re-renders only happen when the picked value actually changes.
273
390
 
274
391
  ```tsx
275
- import { 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;
394
+ function UserName() {
395
+ // Without pick: re-renders when ANY profile property changes
396
+ const { name } = useStore(({ get }) => {
397
+ const [state] = get(userStore);
398
+ return { name: state.profile.name };
399
+ });
400
+
401
+ // With pick: re-renders ONLY when profile.name changes
402
+ const { name } = useStore(({ get }) => {
403
+ const [state] = get(userStore);
404
+ return { name: pick(() => state.profile.name) };
405
+ });
406
+
407
+ return <h1>{name}</h1>;
408
+ }
409
+ ```
410
+
411
+ ### Async State Management
412
+
413
+ **The problem:** Every async operation needs loading, error, and success states. You write the same boilerplate: `isLoading`, `error`, `data`, plus handling race conditions, retries, and cancellation.
414
+
415
+ **With Storion:** The `async()` helper manages all async states automatically. Choose "fresh" mode (clear data while loading) or "stale" mode (keep previous data like SWR).
416
+
417
+ ```ts
418
+ import { store } from "storion";
419
+ import { async, type AsyncState } from "storion/async";
420
+
421
+ interface Product {
422
+ id: string;
423
+ name: string;
424
+ price: number;
425
+ }
426
+
427
+ export const productStore = store({
428
+ name: "products",
429
+ state: {
430
+ // Fresh mode: data is undefined during loading
431
+ featured: async.fresh<Product>(),
432
+ // Stale mode: preserves previous data during loading (SWR pattern)
433
+ list: async.stale<Product[]>([]),
434
+ },
435
+ setup({ focus }) {
436
+ const featuredActions = async<Product, "fresh", [string]>(
437
+ focus("featured"),
438
+ async (ctx, productId) => {
439
+ const res = await fetch(`/api/products/${productId}`, {
440
+ signal: ctx.signal,
441
+ });
442
+ return res.json();
287
443
  },
444
+ {
445
+ retry: { count: 3, delay: (attempt) => attempt * 1000 },
446
+ onError: (err) => console.error("Failed to fetch product:", err),
447
+ }
448
+ );
449
+
450
+ const listActions = async<Product[], "stale", []>(
451
+ focus("list"),
452
+ async () => {
453
+ const res = await fetch("/api/products");
454
+ return res.json();
455
+ }
456
+ );
457
+
458
+ return {
459
+ fetchFeatured: featuredActions.dispatch,
460
+ fetchList: listActions.dispatch,
461
+ refreshList: listActions.refresh,
462
+ cancelFeatured: featuredActions.cancel,
288
463
  };
289
464
  },
290
465
  });
291
466
 
292
- function LoginForm() {
293
- // Each component instance gets its own store!
294
- const [state, actions, { dirty, reset }] = useStore(formStore);
467
+ // In React - handle async states
468
+ function ProductList() {
469
+ const { list, fetchList } = useStore(({ get }) => {
470
+ const [state, actions] = get(productStore);
471
+ return { list: state.list, fetchList: actions.fetchList };
472
+ });
473
+
474
+ useEffect(() => {
475
+ fetchList();
476
+ }, []);
477
+
478
+ if (list.status === "pending" && !list.data?.length) {
479
+ return <Spinner />;
480
+ }
481
+
482
+ if (list.status === "error") {
483
+ return <Error message={list.error.message} />;
484
+ }
295
485
 
296
486
  return (
297
- <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>
487
+ <ul>
488
+ {list.data?.map((p) => (
489
+ <li key={p.id}>{p.name}</li>
490
+ ))}
491
+ {list.status === "pending" && <li>Loading more...</li>}
492
+ </ul>
311
493
  );
312
494
  }
313
495
  ```
314
496
 
315
- ### 🔄 Immer-Style Updates
497
+ ### Dependency Injection
316
498
 
317
- Update complex nested state with simple mutations:
499
+ **The problem:** Your stores need shared services (API clients, loggers, config) but you don't want to import singletons directly—it makes testing hard and creates tight coupling.
500
+
501
+ **With Storion:** The container acts as a DI container. Define factory functions and resolve them with `get()`. Services are cached as singletons automatically.
502
+
503
+ ```ts
504
+ import { container, type Resolver } from "storion";
505
+
506
+ // Define service factory
507
+ interface ApiService {
508
+ get<T>(url: string): Promise<T>;
509
+ post<T>(url: string, data: unknown): Promise<T>;
510
+ }
511
+
512
+ function createApiService(resolver: Resolver): ApiService {
513
+ const baseUrl = resolver.get(configFactory).apiUrl;
318
514
 
319
- ```tsx
320
- // In your store's setup function:
321
- setup({ state, update }) {
322
515
  return {
323
- addTodo: (text: string) => {
324
- update(draft => {
325
- draft.todos.push({ id: Date.now(), text, done: false });
326
- });
516
+ async get(url) {
517
+ const res = await fetch(`${baseUrl}${url}`);
518
+ return res.json();
327
519
  },
328
- toggleTodo: (id: number) => {
329
- update(draft => {
330
- const todo = draft.todos.find(t => t.id === id);
331
- if (todo) todo.done = !todo.done;
520
+ async post(url, data) {
521
+ const res = await fetch(`${baseUrl}${url}`, {
522
+ method: "POST",
523
+ body: JSON.stringify(data),
332
524
  });
525
+ return res.json();
333
526
  },
334
527
  };
335
528
  }
336
- ```
337
-
338
- ### ⚙️ Flexible Equality
339
529
 
340
- Configure how changes are detected per-property:
341
-
342
- ```tsx
343
- import { store } from "storion/react";
530
+ function configFactory(): { apiUrl: string } {
531
+ return { apiUrl: process.env.API_URL ?? "http://localhost:3000" };
532
+ }
344
533
 
534
+ // Use in store
345
535
  const userStore = store({
346
536
  name: "user",
347
- state: {
348
- 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
- /* ... */
537
+ state: { user: null },
538
+ setup({ get }) {
539
+ const api = get(createApiService); // Singleton, cached
540
+
541
+ return {
542
+ fetchUser: async (id: string) => {
543
+ return api.get(`/users/${id}`);
544
+ },
545
+ };
360
546
  },
361
547
  });
362
548
  ```
363
549
 
364
- ### 🎬 Action-Based Reactivity
550
+ ### Middleware
551
+
552
+ **The problem:** You need cross-cutting behavior (logging, persistence, devtools) applied to some or all stores, without modifying each store individually.
365
553
 
366
- React to action dispatches, not just state changes:
554
+ **With Storion:** Compose middleware and apply it conditionally using patterns like `"user*"` (startsWith), `"*Store"` (endsWith), or custom predicates.
367
555
 
368
- ```tsx
369
- import { effect } from "storion/react";
556
+ ```ts
557
+ import { container, compose, applyFor, applyExcept } from "storion";
558
+
559
+ // Logging middleware
560
+ const loggingMiddleware = (spec, next) => {
561
+ const instance = next(spec);
562
+ console.log(`Store created: ${spec.name}`);
563
+ return instance;
564
+ };
370
565
 
371
- effect(() => {
372
- const lastSave = saveAction.last();
373
- if (!lastSave) return;
566
+ // Persistence middleware
567
+ const persistMiddleware = (spec, next) => {
568
+ const instance = next(spec);
569
+ // Add persistence logic...
570
+ return instance;
571
+ };
374
572
 
375
- // Runs every time saveAction is dispatched
376
- showNotification(`Saved at ${new Date()}`);
377
- });
573
+ const app = container({
574
+ middleware: compose(
575
+ // Apply logging to all stores starting with "user"
576
+ applyFor("user*", loggingMiddleware),
378
577
 
379
- // Or subscribe directly
380
- instance.subscribe("@save", (event) => {
381
- console.log("Save called with:", event.next.args);
578
+ // Apply persistence except for cache stores
579
+ applyExcept("*Cache", persistMiddleware),
580
+
581
+ // Apply to specific stores
582
+ applyFor(["authStore", "settingsStore"], loggingMiddleware),
583
+
584
+ // Apply based on custom condition
585
+ applyFor((spec) => spec.options.meta?.persist === true, persistMiddleware)
586
+ ),
382
587
  });
383
588
  ```
384
589
 
@@ -386,235 +591,245 @@ instance.subscribe("@save", (event) => {
386
591
 
387
592
  ## API Reference
388
593
 
389
- ### `store(options)` — Define a Store
594
+ ### Core (`storion`)
390
595
 
391
- ```ts
392
- 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 |
393
604
 
394
- const myStore = store({
395
- name: "myStore", // Optional, auto-generated if omitted
396
- state: { count: 0 }, // Initial state (required)
397
- setup({ state, resolve, update, dirty, reset, use }) {
398
- // state - Mutable proxy, writes notify subscribers
399
- // resolve - Access other stores: [state, actions]
400
- // update - Immer-style or partial updates
401
- // dirty - Check if state modified: dirty() or dirty("prop")
402
- // reset - Reset to initial state
403
- // use - Apply mixins: use(mixin, ...args)
605
+ #### Store Options
404
606
 
405
- return {
406
- increment: () => state.count++,
407
- };
408
- },
409
- equality: "shallow", // Or per-prop: { count: "deep", default: "strict" }
410
- lifetime: "autoDispose", // "keepAlive" (default) | "autoDispose"
411
- meta: { persist: true }, // Custom metadata for middleware
412
- onDispatch: (event) => {}, // Called after each action
413
- onError: (error) => {}, // Called on effect/action errors
414
- normalize: (state) => ({}), // For dehydrate() serialization
415
- denormalize: (data) => ({}), // For hydrate() deserialization
416
- });
607
+ ```ts
608
+ interface StoreOptions<TState, TActions> {
609
+ name?: string; // Store name for debugging
610
+ state: TState; // Initial state
611
+ setup: (ctx: StoreContext) => TActions; // Setup function
612
+ lifetime?: "singleton" | "autoDispose"; // Instance lifetime
613
+ equality?: Equality | EqualityMap; // Custom equality for state
614
+ onDispatch?: (event: DispatchEvent) => void; // Action dispatch callback
615
+ onError?: (error: unknown) => void; // Error callback
616
+ }
417
617
  ```
418
618
 
419
- ### `container(options?)` — Manage Store Instances
619
+ #### StoreContext (in setup)
420
620
 
421
621
  ```ts
422
- import { container } from "storion";
423
-
424
- const app = container({
425
- middleware: [logger, devtools], // Applied to all stores
426
- defaultLifetime: "autoDispose", // Override default lifetime
427
- });
428
-
429
- // Get or create a store instance
430
- 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
+ ```
431
633
 
432
- // Check if store exists
433
- app.has(myStore); // boolean
634
+ > **Note:** `state` allows direct assignment only for first-level properties. Use `update()` for nested objects, arrays, or batch updates.
434
635
 
435
- // Clear all instances
436
- app.clear();
636
+ ### React (`storion/react`)
437
637
 
438
- // Global container
439
- import { container } from "storion";
440
- const global = container.global;
441
- ```
638
+ | Export | Description |
639
+ | -------------------------- | ----------------------------------------- |
640
+ | `StoreProvider` | Provides container to React tree |
641
+ | `useStore(selector)` | Hook to consume stores with selector |
642
+ | `useStore(spec)` | Hook for component-local store |
643
+ | `useContainer()` | Access container from context |
644
+ | `create(options)` | Create store + hook for single-store apps |
645
+ | `withStore(hook, render?)` | HOC pattern for store consumption |
442
646
 
443
- ### `effect(fn, options?)` — Reactive Effects
647
+ #### useStore Selector
444
648
 
445
649
  ```ts
446
- 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);
447
654
 
448
- const dispose = effect((ctx) => {
449
- // Runs immediately, re-runs when tracked values change
450
- console.log(state.count);
655
+ // Run once on mount
656
+ once(() => actions.init());
451
657
 
452
- // Register cleanup (runs before re-run and on dispose)
453
- ctx.onCleanup(() => {
454
- console.log("cleaning up");
455
- });
658
+ return { value: state.value, action: actions.doSomething };
456
659
  });
660
+ ```
457
661
 
458
- // Options
459
- effect(fn, {
460
- name: "myEffect", // For debugging
461
- onError: "keepAlive", // "failFast" | "keepAlive" | custom handler
462
- });
662
+ ### Async (`storion/async`)
463
663
 
464
- // Stop the effect
465
- dispose();
466
- ```
664
+ | Export | Description |
665
+ | --------------------------------- | ------------------------------------------- |
666
+ | `async(focus, handler, options?)` | Create async action |
667
+ | `async.fresh<T>()` | Create fresh mode initial state |
668
+ | `async.stale<T>(initial)` | Create stale mode initial state |
669
+ | `async.wait(state)` | Extract data or throw (Suspense-compatible) |
670
+ | `async.all(...states)` | Wait for all states to be ready |
671
+ | `async.any(...states)` | Get first ready state |
672
+ | `async.race(states)` | Race between states |
673
+ | `async.hasData(state)` | Check if state has data |
674
+ | `async.isLoading(state)` | Check if state is loading |
675
+ | `async.isError(state)` | Check if state has error |
467
676
 
468
- ### `batch(fn)` — Batch Updates
677
+ #### AsyncState Types
469
678
 
470
679
  ```ts
471
- import { batch } from "storion";
472
-
473
- // Multiple writes, single notification
474
- batch(() => {
475
- state.a = 1;
476
- state.b = 2;
477
- state.c = 3;
478
- });
680
+ interface AsyncState<T, M extends "fresh" | "stale"> {
681
+ status: "idle" | "pending" | "success" | "error";
682
+ mode: M;
683
+ data: M extends "stale" ? T : T | undefined;
684
+ error: Error | undefined;
685
+ timestamp: number | undefined;
686
+ }
479
687
  ```
480
688
 
481
- ### `untrack(fn)` — Read Without Tracking
689
+ ### Devtools (`storion/devtools`)
482
690
 
483
691
  ```ts
484
- import { untrack } from "storion";
692
+ import { devtools } from "storion/devtools";
485
693
 
486
- effect(() => {
487
- const tracked = state.count; // Creates dependency
488
- 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
+ }),
489
700
  });
490
701
  ```
491
702
 
492
- ### `pick(selector, equality?)` — Fine-Grained Tracking
703
+ ### Devtools Panel (`storion/devtools-panel`)
493
704
 
494
- ```ts
495
- import { pick } from "storion";
705
+ ```tsx
706
+ import { DevtoolsPanel } from "storion/devtools-panel";
496
707
 
497
- // Only re-renders when the RESULT changes
498
- const fullName = pick(() => `${state.first} ${state.last}`);
499
- 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
+ }
500
717
  ```
501
718
 
502
- ### Store Instance
719
+ ---
503
720
 
504
- ```ts
505
- const instance = container.get(myStore);
506
-
507
- // Properties
508
- instance.id; // "myStore:1"
509
- instance.spec; // The StoreSpec
510
- instance.state; // Readonly state proxy
511
- instance.actions; // Actions with reactive last()
512
- instance.deps; // Dependency instances
513
-
514
- // Subscribe
515
- instance.subscribe(() => {}); // All changes
516
- instance.subscribe("count", ({ next, prev }) => {}); // Specific prop
517
- instance.subscribe("@increment", (event) => {}); // Specific action
518
- instance.subscribe("@*", (event) => {}); // All actions
519
-
520
- // Lifecycle
521
- instance.onDispose(() => {});
522
- instance.dispose();
523
- instance.disposed(); // boolean
524
-
525
- // State management
526
- instance.dirty(); // Any prop modified?
527
- instance.dirty("count"); // Specific prop modified?
528
- instance.reset(); // Reset to initial state
529
-
530
- // Persistence
531
- instance.dehydrate(); // Get serializable state
532
- instance.hydrate(data); // Restore state (skips dirty props)
533
- ```
721
+ ## Edge Cases & Best Practices
534
722
 
535
- ### React Hooks
723
+ ### Don't directly mutate nested state or arrays
536
724
 
537
- ```tsx
538
- import { useStore, StoreProvider, create } from "storion/react";
725
+ Direct mutation only works for first-level properties. Use `update()` for nested objects and arrays:
539
726
 
540
- // Selector-based (with container)
541
- const { count } = useStore(({ resolve }) => {
542
- const [state, actions] = resolve(myStore);
543
- return { count: state.count };
544
- });
727
+ ```ts
728
+ // Wrong - nested mutation won't trigger reactivity
729
+ setup({ state }) {
730
+ return {
731
+ setName: (name: string) => {
732
+ state.profile.name = name; // Won't work!
733
+ },
734
+ addItem: (item: string) => {
735
+ state.items.push(item); // Won't work!
736
+ },
737
+ };
738
+ }
545
739
 
546
- // Local store (no provider needed)
547
- 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
+ ```
548
760
 
549
- // Provider
550
- <StoreProvider container={app}>
551
- <App />
552
- </StoreProvider>;
761
+ ### ❌ Don't call `get()` inside actions
553
762
 
554
- // Shorthand for single-store apps
555
- const [instance, useCounter] = create({
556
- state: { count: 0 },
557
- setup({ state }) {
558
- return { increment: () => state.count++ };
559
- },
560
- });
763
+ `get()` is for declaring dependencies during setup, not runtime:
561
764
 
562
- // useCounter selector receives (state, actions)
563
- const { count } = useCounter((state, actions) => ({ count: state.count }));
564
- ```
765
+ ```ts
766
+ // Wrong - calling get() inside action
767
+ setup({ get }) {
768
+ return {
769
+ doSomething: () => {
770
+ const [other] = get(otherStore); // Don't do this!
771
+ },
772
+ };
773
+ }
565
774
 
566
- ### Middleware
775
+ // ✅ Correct - declare dependency at setup time
776
+ setup({ get }) {
777
+ const [otherState, otherActions] = get(otherStore);
567
778
 
568
- ```ts
569
- 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
+ ```
570
788
 
571
- // Just pass an array of middleware
572
- container({ middleware: [logger, devtools, persist] });
789
+ ### Don't return Promises from effects
573
790
 
574
- // Conditional middleware
575
- const conditionalLogger = applyFor("user*", logger); // Wildcard match
576
- const multiMiddleware = applyFor(/Store$/, [logger, devtools]); // RegExp match
577
- const persistOnly = applyFor((spec) => spec.meta?.persist, persistMiddleware); // Predicate
791
+ Effects must be synchronous. Use `ctx.safe()` for async:
578
792
 
579
- // Exclude middleware
580
- 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
+ });
581
798
 
582
- // Combine them
583
- 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
+ });
584
805
  ```
585
806
 
586
- ### 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:
587
810
 
588
811
  ```ts
589
- type StoreMiddleware = (
590
- spec: StoreSpec,
591
- next: (spec: StoreSpec) => StoreInstance
592
- ) => StoreInstance;
593
-
594
- // Example: logging middleware
595
- const logger: StoreMiddleware = (spec, next) => {
596
- console.log(`Creating store: ${spec.name}`);
597
- const instance = next(spec); // Call next to create the instance
598
- console.log(`Created: ${instance.id}`);
599
- return instance;
600
- };
812
+ // Re-renders when profile object changes (coarse tracking)
813
+ const name = state.profile.name;
814
+
815
+ // Re-renders only when the actual name value changes (fine tracking)
816
+ const name = pick(() => state.profile.name);
817
+ const fullName = pick(() => `${state.profile.first} ${state.profile.last}`);
601
818
  ```
602
819
 
603
- ---
820
+ ### ✅ Use stale mode for SWR patterns
604
821
 
605
- ## Comparison
822
+ ```ts
823
+ // Fresh mode: data is undefined during loading
824
+ state: {
825
+ data: async.fresh<Data>(),
826
+ }
606
827
 
607
- | Feature | Storion | Zustand | Redux Toolkit | Jotai |
608
- | ------------------- | --------- | -------- | ------------- | --------- |
609
- | Bundle size | ~3KB | ~1KB | ~10KB | ~3KB |
610
- | Boilerplate | Minimal | Minimal | Moderate | Minimal |
611
- | Dependency tracking | Automatic | Manual | Manual | Automatic |
612
- | Cross-store deps | Built-in | Manual | Manual | Built-in |
613
- | TypeScript | Excellent | Good | Good | Excellent |
614
- | React Strict Mode | ✅ | ✅ | ✅ | ✅ |
615
- | Effects | Built-in | External | External | External |
616
- | Middleware | ✅ | ✅ | ✅ | Limited |
617
- | DevTools | 🚧 | ✅ | ✅ | ✅ |
828
+ // Stale mode: preserves previous data during loading (SWR pattern)
829
+ state: {
830
+ data: async.stale<Data>(initialData),
831
+ }
832
+ ```
618
833
 
619
834
  ---
620
835
 
@@ -622,57 +837,107 @@ const logger: StoreMiddleware = (spec, next) => {
622
837
 
623
838
  Storion is written in TypeScript and provides excellent type inference:
624
839
 
625
- ```tsx
626
- 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
+ });
627
852
 
628
- const todoStore = store({
629
- 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",
630
861
  state: {
631
- items: [] as Todo[],
632
- filter: "all" as "all" | "active" | "done",
633
- },
862
+ userId: null as string | null,
863
+ status: "idle" as "idle" | "loading" | "ready",
864
+ } satisfies MyState,
634
865
  setup({ state }) {
635
866
  return {
636
- add: (text: string) => {
637
- /* ... */
638
- },
639
- toggle: (id: number) => {
640
- /* ... */
641
- },
642
- setFilter: (f: typeof state.filter) => {
643
- state.filter = f;
867
+ setUser: (id: string | null) => {
868
+ state.userId = id;
644
869
  },
645
870
  };
646
871
  },
647
872
  });
648
-
649
- // Full inference - no generics needed!
650
- const { items, add } = useStore(({ resolve }) => {
651
- const [state, actions] = resolve(todoStore);
652
- return { items: state.items, add: actions.add };
653
- });
654
- // items: Todo[], add: (text: string) => void
655
873
  ```
656
874
 
657
875
  ---
658
876
 
659
- ## 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
660
887
 
661
888
  ```bash
662
- # npm
663
- npm install storion
889
+ # Clone the repo
890
+ git clone https://github.com/linq2js/storion.git
891
+ cd storion
664
892
 
665
- # yarn
666
- yarn add storion
893
+ # Install dependencies
894
+ pnpm install
667
895
 
668
- # pnpm
669
- 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
670
932
  ```
671
933
 
672
- ### Requirements
934
+ ### Pull Requests
673
935
 
674
- - React 18+ (for React integration)
675
- - 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
676
941
 
677
942
  ---
678
943
 
@@ -683,5 +948,5 @@ MIT © [linq2js](https://github.com/linq2js)
683
948
  ---
684
949
 
685
950
  <p align="center">
686
- <sub>Built with ❤️ for developers who want state management that just works.</sub>
951
+ <sub>Built with ❤️ for the React community</sub>
687
952
  </p>