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