rexfect 0.0.7
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 +1756 -0
- package/dist/abortableContext.d.ts +3 -0
- package/dist/abortableContext.d.ts.map +1 -0
- package/dist/abortableContext.js +48 -0
- package/dist/abortableContext.js.map +1 -0
- package/dist/action.d.ts +64 -0
- package/dist/action.d.ts.map +1 -0
- package/dist/action.js +208 -0
- package/dist/action.js.map +1 -0
- package/dist/action.test.d.ts +2 -0
- package/dist/action.test.d.ts.map +1 -0
- package/dist/action.test.js +189 -0
- package/dist/action.test.js.map +1 -0
- package/dist/async/abortable-guard.d.ts +25 -0
- package/dist/async/abortable-guard.d.ts.map +1 -0
- package/dist/async/abortable-guard.js +33 -0
- package/dist/async/abortable-guard.js.map +1 -0
- package/dist/async/abortable.d.ts +331 -0
- package/dist/async/abortable.d.ts.map +1 -0
- package/dist/async/abortable.js +410 -0
- package/dist/async/abortable.js.map +1 -0
- package/dist/async/abortable.test.d.ts +2 -0
- package/dist/async/abortable.test.d.ts.map +1 -0
- package/dist/async/abortable.test.js +535 -0
- package/dist/async/abortable.test.js.map +1 -0
- package/dist/async/abortable.typeCheck.d.ts +8 -0
- package/dist/async/abortable.typeCheck.d.ts.map +1 -0
- package/dist/async/abortable.typeCheck.js +138 -0
- package/dist/async/abortable.typeCheck.js.map +1 -0
- package/dist/async/async.d.ts +18 -0
- package/dist/async/async.d.ts.map +1 -0
- package/dist/async/async.js +20 -0
- package/dist/async/async.js.map +1 -0
- package/dist/async/index.d.ts +15 -0
- package/dist/async/index.d.ts.map +1 -0
- package/dist/async/index.js +13 -0
- package/dist/async/index.js.map +1 -0
- package/dist/async/loadable.d.ts +7 -0
- package/dist/async/loadable.d.ts.map +1 -0
- package/dist/async/loadable.js +52 -0
- package/dist/async/loadable.js.map +1 -0
- package/dist/async/loadable.test.d.ts +2 -0
- package/dist/async/loadable.test.d.ts.map +1 -0
- package/dist/async/loadable.test.js +322 -0
- package/dist/async/loadable.test.js.map +1 -0
- package/dist/async/promiseCache.d.ts +14 -0
- package/dist/async/promiseCache.d.ts.map +1 -0
- package/dist/async/promiseCache.js +29 -0
- package/dist/async/promiseCache.js.map +1 -0
- package/dist/async/read.d.ts +120 -0
- package/dist/async/read.d.ts.map +1 -0
- package/dist/async/read.js +286 -0
- package/dist/async/read.js.map +1 -0
- package/dist/async/read.test.d.ts +2 -0
- package/dist/async/read.test.d.ts.map +1 -0
- package/dist/async/read.test.js +419 -0
- package/dist/async/read.test.js.map +1 -0
- package/dist/async/read.typeCheck.d.ts +6 -0
- package/dist/async/read.typeCheck.d.ts.map +1 -0
- package/dist/async/read.typeCheck.js +101 -0
- package/dist/async/read.typeCheck.js.map +1 -0
- package/dist/async/safe.d.ts +230 -0
- package/dist/async/safe.d.ts.map +1 -0
- package/dist/async/safe.js +247 -0
- package/dist/async/safe.js.map +1 -0
- package/dist/async/safe.test.d.ts +2 -0
- package/dist/async/safe.test.d.ts.map +1 -0
- package/dist/async/safe.test.js +447 -0
- package/dist/async/safe.test.js.map +1 -0
- package/dist/async/utils.d.ts +17 -0
- package/dist/async/utils.d.ts.map +1 -0
- package/dist/async/utils.js +38 -0
- package/dist/async/utils.js.map +1 -0
- package/dist/async/wait.d.ts +120 -0
- package/dist/async/wait.d.ts.map +1 -0
- package/dist/async/wait.js +112 -0
- package/dist/async/wait.js.map +1 -0
- package/dist/async/wait.test.d.ts +2 -0
- package/dist/async/wait.test.d.ts.map +1 -0
- package/dist/async/wait.test.js +122 -0
- package/dist/async/wait.test.js.map +1 -0
- package/dist/async/wait.typeCheck.d.ts +6 -0
- package/dist/async/wait.typeCheck.d.ts.map +1 -0
- package/dist/async/wait.typeCheck.js +104 -0
- package/dist/async/wait.typeCheck.js.map +1 -0
- package/dist/atom.d.ts +46 -0
- package/dist/atom.d.ts.map +1 -0
- package/dist/atom.js +86 -0
- package/dist/atom.js.map +1 -0
- package/dist/atom.test.d.ts +2 -0
- package/dist/atom.test.d.ts.map +1 -0
- package/dist/atom.test.js +75 -0
- package/dist/atom.test.js.map +1 -0
- package/dist/batch.d.ts +15 -0
- package/dist/batch.d.ts.map +1 -0
- package/dist/batch.js +45 -0
- package/dist/batch.js.map +1 -0
- package/dist/defer.d.ts +56 -0
- package/dist/defer.d.ts.map +1 -0
- package/dist/defer.js +49 -0
- package/dist/defer.js.map +1 -0
- package/dist/effect.d.ts +91 -0
- package/dist/effect.d.ts.map +1 -0
- package/dist/effect.js +311 -0
- package/dist/effect.js.map +1 -0
- package/dist/effect.test.d.ts +2 -0
- package/dist/effect.test.d.ts.map +1 -0
- package/dist/effect.test.js +123 -0
- package/dist/effect.test.js.map +1 -0
- package/dist/emitter.d.ts +129 -0
- package/dist/emitter.d.ts.map +1 -0
- package/dist/emitter.js +164 -0
- package/dist/emitter.js.map +1 -0
- package/dist/emitter.test.d.ts +2 -0
- package/dist/emitter.test.d.ts.map +1 -0
- package/dist/emitter.test.js +259 -0
- package/dist/emitter.test.js.map +1 -0
- package/dist/equality.d.ts +66 -0
- package/dist/equality.d.ts.map +1 -0
- package/dist/equality.js +145 -0
- package/dist/equality.js.map +1 -0
- package/dist/event.d.ts +18 -0
- package/dist/event.d.ts.map +1 -0
- package/dist/event.js +166 -0
- package/dist/event.js.map +1 -0
- package/dist/event.test.d.ts +2 -0
- package/dist/event.test.d.ts.map +1 -0
- package/dist/event.test.js +167 -0
- package/dist/event.test.js.map +1 -0
- package/dist/hooks.d.ts +152 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +122 -0
- package/dist/hooks.js.map +1 -0
- package/dist/hooks.test.d.ts +2 -0
- package/dist/hooks.test.d.ts.map +1 -0
- package/dist/hooks.test.js +99 -0
- package/dist/hooks.test.js.map +1 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +35 -0
- package/dist/index.js.map +1 -0
- package/dist/isPromiseLike.d.ts +10 -0
- package/dist/isPromiseLike.d.ts.map +1 -0
- package/dist/isPromiseLike.js +15 -0
- package/dist/isPromiseLike.js.map +1 -0
- package/dist/pick.d.ts +22 -0
- package/dist/pick.d.ts.map +1 -0
- package/dist/pick.js +46 -0
- package/dist/pick.js.map +1 -0
- package/dist/react/index.d.ts +8 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +8 -0
- package/dist/react/index.js.map +1 -0
- package/dist/react/useRx.d.ts +14 -0
- package/dist/react/useRx.d.ts.map +1 -0
- package/dist/react/useRx.js +110 -0
- package/dist/react/useRx.js.map +1 -0
- package/dist/react/useRx.test.d.ts +2 -0
- package/dist/react/useRx.test.d.ts.map +1 -0
- package/dist/react/useRx.test.js +457 -0
- package/dist/react/useRx.test.js.map +1 -0
- package/dist/strictModeTest.d.ts +11 -0
- package/dist/strictModeTest.d.ts.map +1 -0
- package/dist/strictModeTest.js +41 -0
- package/dist/strictModeTest.js.map +1 -0
- package/dist/types.d.ts +606 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +5 -0
- package/dist/types.js.map +1 -0
- package/dist/untrack.d.ts +14 -0
- package/dist/untrack.d.ts.map +1 -0
- package/dist/untrack.js +17 -0
- package/dist/untrack.js.map +1 -0
- package/dist/utils/withUse.d.ts +10 -0
- package/dist/utils/withUse.d.ts.map +1 -0
- package/dist/utils/withUse.js +21 -0
- package/dist/utils/withUse.js.map +1 -0
- package/dist/utils/withUse.test.d.ts +2 -0
- package/dist/utils/withUse.test.d.ts.map +1 -0
- package/dist/utils/withUse.test.js +233 -0
- package/dist/utils/withUse.test.js.map +1 -0
- package/dist/utils.d.ts +7 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +7 -0
- package/dist/utils.js.map +1 -0
- package/dist/utils.test.d.ts +2 -0
- package/dist/utils.test.d.ts.map +1 -0
- package/dist/utils.test.js +119 -0
- package/dist/utils.test.js.map +1 -0
- package/package.json +64 -0
package/README.md
ADDED
|
@@ -0,0 +1,1756 @@
|
|
|
1
|
+
# rexfect
|
|
2
|
+
|
|
3
|
+
Minimal reactive state management for JavaScript/TypeScript.
|
|
4
|
+
|
|
5
|
+
## Introduction
|
|
6
|
+
|
|
7
|
+
rexfect provides simple, predictable reactive state management with a minimal API surface. Unlike larger state management solutions, rexfect gives you just a few core primitives that compose together to handle any state management scenario:
|
|
8
|
+
|
|
9
|
+
- **Atoms** for reactive state storage
|
|
10
|
+
- **Effects** for synchronous reactions to state changes
|
|
11
|
+
- **Actions** for async operations and business logic workflows
|
|
12
|
+
|
|
13
|
+
This guide covers the most common use cases and patterns. Whether you're building a small widget or a large application, these patterns will help you write maintainable, reactive code.
|
|
14
|
+
|
|
15
|
+
## Comparison with Other Tools
|
|
16
|
+
|
|
17
|
+
### Boilerplate Comparison
|
|
18
|
+
|
|
19
|
+
rexfect is designed to minimize boilerplate while maintaining type safety. Here's how a simple counter compares across libraries:
|
|
20
|
+
|
|
21
|
+
**rexfect:**
|
|
22
|
+
|
|
23
|
+
```typescript
|
|
24
|
+
import { atom } from "rexfect";
|
|
25
|
+
import { useRx } from "rexfect/react";
|
|
26
|
+
|
|
27
|
+
const [count, setCount] = atom(0);
|
|
28
|
+
|
|
29
|
+
function Counter() {
|
|
30
|
+
const value = useRx(() => count());
|
|
31
|
+
return <button onClick={() => setCount(count() + 1)}>{value}</button>;
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
**Redux Toolkit:**
|
|
36
|
+
|
|
37
|
+
```typescript
|
|
38
|
+
import { createSlice, configureStore } from "@reduxjs/toolkit";
|
|
39
|
+
import { Provider, useSelector, useDispatch } from "react-redux";
|
|
40
|
+
|
|
41
|
+
const counterSlice = createSlice({
|
|
42
|
+
name: "counter",
|
|
43
|
+
initialState: { value: 0 },
|
|
44
|
+
reducers: {
|
|
45
|
+
increment: (state) => {
|
|
46
|
+
state.value += 1;
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const store = configureStore({ reducer: { counter: counterSlice.reducer } });
|
|
52
|
+
const { increment } = counterSlice.actions;
|
|
53
|
+
|
|
54
|
+
function Counter() {
|
|
55
|
+
const value = useSelector((state) => state.counter.value);
|
|
56
|
+
const dispatch = useDispatch();
|
|
57
|
+
return <button onClick={() => dispatch(increment())}>{value}</button>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Must wrap app in <Provider store={store}>
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
**Zustand:**
|
|
64
|
+
|
|
65
|
+
```typescript
|
|
66
|
+
import { create } from "zustand";
|
|
67
|
+
|
|
68
|
+
const useStore = create((set) => ({
|
|
69
|
+
count: 0,
|
|
70
|
+
increment: () => set((state) => ({ count: state.count + 1 })),
|
|
71
|
+
}));
|
|
72
|
+
|
|
73
|
+
function Counter() {
|
|
74
|
+
const count = useStore((state) => state.count);
|
|
75
|
+
const increment = useStore((state) => state.increment);
|
|
76
|
+
return <button onClick={increment}>{count}</button>;
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
**Jotai:**
|
|
81
|
+
|
|
82
|
+
```typescript
|
|
83
|
+
import { atom, useAtom } from "jotai";
|
|
84
|
+
|
|
85
|
+
const countAtom = atom(0);
|
|
86
|
+
|
|
87
|
+
function Counter() {
|
|
88
|
+
const [count, setCount] = useAtom(countAtom);
|
|
89
|
+
return <button onClick={() => setCount((c) => c + 1)}>{count}</button>;
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Feature Comparison
|
|
94
|
+
|
|
95
|
+
| Feature | rexfect | Redux | Zustand | Jotai | MobX |
|
|
96
|
+
| -------------------------- | -------------- | ---------- | ------- | ------- | ------- |
|
|
97
|
+
| **Boilerplate** | Minimal | High | Low | Minimal | Low |
|
|
98
|
+
| **Bundle Size** | ~3KB | ~10KB+ | ~2KB | ~5KB | ~15KB |
|
|
99
|
+
| **TypeScript** | First-class | Good | Good | Good | Good |
|
|
100
|
+
| **Conditional Reactivity** | ✅ | ❌ | ❌ | ❌ | ✅ |
|
|
101
|
+
| **React Suspense** | ✅ Built-in | ❌ | ❌ | ✅ | ❌ |
|
|
102
|
+
| **Async Cancellation** | ✅ Built-in | Manual | Manual | Manual | Manual |
|
|
103
|
+
| **Actions System** | ✅ First-class | Middleware | Manual | Manual | Actions |
|
|
104
|
+
| **DevTools** | Planned | ✅ | ✅ | ✅ | ✅ |
|
|
105
|
+
|
|
106
|
+
### Conditional Reactivity
|
|
107
|
+
|
|
108
|
+
Most libraries require you to select all dependencies upfront. rexfect tracks dependencies dynamically, so conditional reads just work:
|
|
109
|
+
|
|
110
|
+
**rexfect - Conditional reads are automatic:**
|
|
111
|
+
|
|
112
|
+
```typescript
|
|
113
|
+
function UserDisplay() {
|
|
114
|
+
const display = useRx(() => {
|
|
115
|
+
// Only subscribes to lastName when showFull is true
|
|
116
|
+
// When showFull becomes false, lastName is automatically untracked
|
|
117
|
+
if (showFull()) {
|
|
118
|
+
return `${firstName()} ${lastName()}`;
|
|
119
|
+
}
|
|
120
|
+
return firstName();
|
|
121
|
+
});
|
|
122
|
+
return <span>{display}</span>;
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
**Other libraries - Must handle manually:**
|
|
127
|
+
|
|
128
|
+
```typescript
|
|
129
|
+
// Zustand: Selector always subscribes to all selected state
|
|
130
|
+
const useUserStore = create((set) => ({
|
|
131
|
+
showFull: true,
|
|
132
|
+
firstName: "Ada",
|
|
133
|
+
lastName: "Lovelace",
|
|
134
|
+
}));
|
|
135
|
+
|
|
136
|
+
function UserDisplay() {
|
|
137
|
+
// Option 1: Single selector - re-renders on ANY of these changes
|
|
138
|
+
const { showFull, firstName, lastName } = useUserStore((state) => ({
|
|
139
|
+
showFull: state.showFull,
|
|
140
|
+
firstName: state.firstName,
|
|
141
|
+
lastName: state.lastName, // Always tracked, even when showFull is false!
|
|
142
|
+
}));
|
|
143
|
+
|
|
144
|
+
return <span>{showFull ? `${firstName} ${lastName}` : firstName}</span>;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Redux: Same issue with useSelector
|
|
148
|
+
function UserDisplay() {
|
|
149
|
+
const { showFull, firstName, lastName } = useSelector((state) => ({
|
|
150
|
+
showFull: state.user.showFull,
|
|
151
|
+
firstName: state.user.firstName,
|
|
152
|
+
lastName: state.user.lastName, // Always tracked!
|
|
153
|
+
}));
|
|
154
|
+
|
|
155
|
+
return <span>{showFull ? `${firstName} ${lastName}` : firstName}</span>;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Jotai: Derived atom reads all dependencies upfront
|
|
159
|
+
const displayAtom = atom((get) => {
|
|
160
|
+
const showFull = get(showFullAtom);
|
|
161
|
+
const first = get(firstNameAtom);
|
|
162
|
+
const last = get(lastNameAtom); // Always read, even when showFull is false
|
|
163
|
+
return showFull ? `${first} ${last}` : first;
|
|
164
|
+
});
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### Suspense Support
|
|
168
|
+
|
|
169
|
+
rexfect has first-class Suspense support with `read()` and `loadable()`:
|
|
170
|
+
|
|
171
|
+
**rexfect:**
|
|
172
|
+
|
|
173
|
+
```typescript
|
|
174
|
+
import { read } from "rexfect/async";
|
|
175
|
+
|
|
176
|
+
function UserProfile() {
|
|
177
|
+
// Automatically suspends until resolved
|
|
178
|
+
// Works with any Signal<Promise<T>>
|
|
179
|
+
const user = useRx(() => read(userPromise));
|
|
180
|
+
return <h1>{user.name}</h1>;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Or use loadable() for manual loading states
|
|
184
|
+
function UserProfileManual() {
|
|
185
|
+
const state = useRx(() => loadable(userPromise));
|
|
186
|
+
if (state?.loading) return <Spinner />;
|
|
187
|
+
if (state?.error) return <Error error={state.error} />;
|
|
188
|
+
return <h1>{state?.data?.name}</h1>;
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
**Redux/Zustand:** No built-in Suspense support. Requires manual loading state management or additional libraries.
|
|
193
|
+
|
|
194
|
+
**Jotai:** Has Suspense support but requires specific async atoms:
|
|
195
|
+
|
|
196
|
+
```typescript
|
|
197
|
+
// Must use special async atom pattern
|
|
198
|
+
const userAtom = atom(async () => {
|
|
199
|
+
const response = await fetch("/api/user");
|
|
200
|
+
return response.json();
|
|
201
|
+
});
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
### The Power of Actions
|
|
205
|
+
|
|
206
|
+
rexfect's `action()` is a unique feature that handles async workflows elegantly.
|
|
207
|
+
|
|
208
|
+
#### Key Characteristics
|
|
209
|
+
|
|
210
|
+
Actions are more powerful than traditional functions because they are:
|
|
211
|
+
|
|
212
|
+
1. **Listenable** - Subscribe to dispatches with `.on()`
|
|
213
|
+
|
|
214
|
+
- Multiple listeners can react to the same dispatch
|
|
215
|
+
- Perfect for side effects like analytics, logging, or state updates
|
|
216
|
+
- Supports async listeners
|
|
217
|
+
|
|
218
|
+
2. **Awaitable** - Actions implement `PromiseLike` interface
|
|
219
|
+
|
|
220
|
+
- `await action` waits for the next dispatch
|
|
221
|
+
- `.latest()` gets the last value immediately or waits for next
|
|
222
|
+
- Enables elegant async coordination patterns
|
|
223
|
+
|
|
224
|
+
3. **Simple** - No context, no lifecycle complexity
|
|
225
|
+
- Handlers are simple functions that receive the payload
|
|
226
|
+
- For complex cancellation/lifecycle needs, use `abortable()` utility
|
|
227
|
+
|
|
228
|
+
#### Actions vs Traditional Functions
|
|
229
|
+
|
|
230
|
+
| Feature | Action | Traditional Function |
|
|
231
|
+
| ---------------------- | ------------------------------------ | ---------------------------------------- |
|
|
232
|
+
| **Multiple Listeners** | ✅ `.on()` for side effects | ❌ Must call multiple functions manually |
|
|
233
|
+
| **Awaitable** | ✅ `await action` waits for dispatch | ❌ Must wrap in Promise manually |
|
|
234
|
+
| **Sealed (One-time)** | ✅ `{ sealed: true }` option | ❌ Manual flag checking |
|
|
235
|
+
| **Late Subscribers** | ✅ Auto-called with last value | ❌ Manual synchronization |
|
|
236
|
+
| **Type Safety** | ✅ Full TypeScript inference | ✅ Same (functions are typed) |
|
|
237
|
+
| **Cancellation** | ✅ Use `abortable()` wrapper | ❌ Manual `AbortController` setup |
|
|
238
|
+
|
|
239
|
+
**Example Comparison:**
|
|
240
|
+
|
|
241
|
+
```typescript
|
|
242
|
+
// ❌ Traditional function - manual cancellation, no listeners, not awaitable
|
|
243
|
+
let abortController: AbortController | null = null;
|
|
244
|
+
|
|
245
|
+
function search(query: string) {
|
|
246
|
+
abortController?.abort();
|
|
247
|
+
abortController = new AbortController();
|
|
248
|
+
|
|
249
|
+
return fetch(`/api/search?q=${query}`, {
|
|
250
|
+
signal: abortController.signal,
|
|
251
|
+
}).then((r) => r.json());
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Must manually track listeners
|
|
255
|
+
const listeners: Array<(query: string) => void> = [];
|
|
256
|
+
function onSearch(callback: (query: string) => void) {
|
|
257
|
+
listeners.push(callback);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Must wrap in Promise to await
|
|
261
|
+
const promise = new Promise((resolve) => {
|
|
262
|
+
const result = search("hello");
|
|
263
|
+
listeners.forEach((cb) => cb("hello"));
|
|
264
|
+
resolve(result);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// ✅ Action - simple and powerful
|
|
268
|
+
const search = action(async (query: string) => {
|
|
269
|
+
const res = await fetch(`/api/search?q=${query}`);
|
|
270
|
+
return res.json();
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// Listeners subscribe easily
|
|
274
|
+
search.on((query) => {
|
|
275
|
+
analytics.track("search", { query });
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// Await directly
|
|
279
|
+
const results = await search("hello");
|
|
280
|
+
|
|
281
|
+
// For cancellation, wrap with abortable()
|
|
282
|
+
import { abortable } from "rexfect/async";
|
|
283
|
+
const cancellableSearch = abortable(async ({ signal }, query: string) => {
|
|
284
|
+
const res = await fetch(`/api/search?q=${query}`, { signal });
|
|
285
|
+
return res.json();
|
|
286
|
+
});
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
**Naming Convention:**
|
|
290
|
+
|
|
291
|
+
- **Void actions** (notifications): Use `on` prefix → `onClick`, `onLogin`, `onSearch`
|
|
292
|
+
- **Actions with handlers** (commands): Use verbs → `addToCart`, `submitForm`, `fetchUser`
|
|
293
|
+
|
|
294
|
+
**Thenable Actions - Await the next dispatch:**
|
|
295
|
+
|
|
296
|
+
```typescript
|
|
297
|
+
const onUserLogin = action<User>();
|
|
298
|
+
|
|
299
|
+
// In one part of your app
|
|
300
|
+
async function waitForLogin() {
|
|
301
|
+
const user = await onUserLogin; // Waits for next dispatch
|
|
302
|
+
console.log("User logged in:", user);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// In another part
|
|
306
|
+
onUserLogin(currentUser); // Resolves the waiting promise
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
**Actions with Handlers - Return results:**
|
|
310
|
+
|
|
311
|
+
```typescript
|
|
312
|
+
// Simple sync handler
|
|
313
|
+
const addToCart = action((item: CartItem) => {
|
|
314
|
+
const newCart = [...cart(), item];
|
|
315
|
+
setCart(newCart);
|
|
316
|
+
return newCart.length;
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
const count = addToCart({ id: "123", qty: 1 }); // count = new cart length
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
**Note:** Action handlers are automatically wrapped in `batch()`, so multiple atom updates in sync handlers are automatically batched. Only use explicit `batch()` if you need to batch updates that happen in async operations (after `await`).
|
|
323
|
+
|
|
324
|
+
**Async Handlers:**
|
|
325
|
+
|
|
326
|
+
```typescript
|
|
327
|
+
// Simple async handler - no context needed
|
|
328
|
+
const search = action(async (query: string) => {
|
|
329
|
+
const res = await fetch(`/api/search?q=${query}`);
|
|
330
|
+
return res.json();
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
const results = await search("hello");
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
**For cancellation, use `abortable()`:**
|
|
337
|
+
|
|
338
|
+
```typescript
|
|
339
|
+
import { abortable } from "rexfect/async";
|
|
340
|
+
|
|
341
|
+
// Wrap with abortable for cancellation support
|
|
342
|
+
const cancellableSearch = abortable(
|
|
343
|
+
async ({ signal, abortPrev }, query: string) => {
|
|
344
|
+
abortPrev(); // Cancel previous search
|
|
345
|
+
|
|
346
|
+
const res = await fetch(`/api/search?q=${query}`, {
|
|
347
|
+
signal, // Pass signal to fetch
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
return res.json();
|
|
351
|
+
}
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
// Rapid calls - only last one completes
|
|
355
|
+
cancellableSearch("h");
|
|
356
|
+
cancellableSearch("he");
|
|
357
|
+
cancellableSearch("hel");
|
|
358
|
+
const results = await cancellableSearch("hello"); // Only this completes
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
**Or use listeners for side effects:**
|
|
362
|
+
|
|
363
|
+
```typescript
|
|
364
|
+
// Void action with listener for side effects
|
|
365
|
+
const onSearch = action<string>();
|
|
366
|
+
|
|
367
|
+
onSearch.on(async (query) => {
|
|
368
|
+
const results = await fetch(`/api/search?q=${query}`);
|
|
369
|
+
const data = await results.json();
|
|
370
|
+
setResults(data);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
onSearch("react");
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
**Sealed Actions - One-time initialization:**
|
|
377
|
+
|
|
378
|
+
```typescript
|
|
379
|
+
const onAppReady = action<Config>({ sealed: true });
|
|
380
|
+
|
|
381
|
+
// Automatically seals after first dispatch
|
|
382
|
+
onAppReady(config);
|
|
383
|
+
onAppReady(newConfig); // No-op, safely ignored
|
|
384
|
+
|
|
385
|
+
// Late subscribers get the sealed value immediately
|
|
386
|
+
onAppReady.on((config) => {
|
|
387
|
+
// Called immediately with original config
|
|
388
|
+
});
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
**Compare to other libraries:**
|
|
392
|
+
|
|
393
|
+
| Pattern | rexfect | Redux | Zustand | Jotai |
|
|
394
|
+
| --------------------- | ------------------ | ---------------- | ---------------------- | ----------- |
|
|
395
|
+
| Await action dispatch | `await action` | N/A | N/A | N/A |
|
|
396
|
+
| Cancel previous async | `abortable()` | Write middleware | Manual AbortController | Manual |
|
|
397
|
+
| One-time init | `{ sealed: true }` | Check flags | Check flags | Check flags |
|
|
398
|
+
| Late subscribers | Auto-called | Manual sync | Manual sync | Manual sync |
|
|
399
|
+
|
|
400
|
+
### When to Choose rexfect
|
|
401
|
+
|
|
402
|
+
**Choose rexfect if you want:**
|
|
403
|
+
|
|
404
|
+
- Minimal boilerplate with maximum type safety
|
|
405
|
+
- Conditional reactivity without selector gymnastics
|
|
406
|
+
- First-class async handling with cancellation
|
|
407
|
+
- Built-in Suspense support
|
|
408
|
+
- Action-driven architecture for complex workflows
|
|
409
|
+
- Fine-grained reactivity with `pick()`
|
|
410
|
+
|
|
411
|
+
**Consider Redux if you need:**
|
|
412
|
+
|
|
413
|
+
- Time-travel debugging
|
|
414
|
+
- Large ecosystem of middleware
|
|
415
|
+
- Strict unidirectional data flow enforcement
|
|
416
|
+
- Team familiarity with Redux patterns
|
|
417
|
+
|
|
418
|
+
**Consider Zustand if you need:**
|
|
419
|
+
|
|
420
|
+
- Minimal API similar to rexfect but more established
|
|
421
|
+
- Easy migration from Redux
|
|
422
|
+
- DevTools support today
|
|
423
|
+
|
|
424
|
+
**Consider Jotai if you need:**
|
|
425
|
+
|
|
426
|
+
- Atom-based model with React-first design
|
|
427
|
+
- Strong Suspense integration
|
|
428
|
+
- Atom-in-atom composition patterns
|
|
429
|
+
|
|
430
|
+
## Mental Model: How It All Fits Together
|
|
431
|
+
|
|
432
|
+
Understanding the relationship between atoms, effects, and events is key to using rexfect effectively.
|
|
433
|
+
|
|
434
|
+
### The Data Flow
|
|
435
|
+
|
|
436
|
+
```
|
|
437
|
+
┌─────────────────────────────────────────────────────────────────────┐
|
|
438
|
+
│ APPLICATION │
|
|
439
|
+
├─────────────────────────────────────────────────────────────────────┤
|
|
440
|
+
│ │
|
|
441
|
+
│ ┌─────────┐ ┌─────────┐ ┌─────────────────┐ │
|
|
442
|
+
│ │ ATOMS │◄────────│ EVENTS │◄────────│ COMPONENTS │ │
|
|
443
|
+
│ │ (State) │ │ (Async) │ │ (UI Layer) │ │
|
|
444
|
+
│ └────┬────┘ └────▲────┘ └────────┬────────┘ │
|
|
445
|
+
│ │ │ │ │
|
|
446
|
+
│ │ ┌─────────┐ │ │ │
|
|
447
|
+
│ └───►│ EFFECTS │────┘ │ │
|
|
448
|
+
│ │ (Sync) │ │ │
|
|
449
|
+
│ └─────────┘ │ │
|
|
450
|
+
│ │ │
|
|
451
|
+
│ ┌─────────┐ │ │
|
|
452
|
+
│ │ ATOMS │◄─────────────────────────────────────┘ │
|
|
453
|
+
│ │ (Read) │ Components read atoms via useRx() │
|
|
454
|
+
│ └─────────┘ │
|
|
455
|
+
│ │
|
|
456
|
+
└─────────────────────────────────────────────────────────────────────┘
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
### The Three Primitives
|
|
460
|
+
|
|
461
|
+
| Primitive | Purpose | Sync/Async | Typical Use |
|
|
462
|
+
| ----------- | ---------------- | ------------------- | -------------------------------------------------------- |
|
|
463
|
+
| **Atoms** | Store state | - | Hold your application data |
|
|
464
|
+
| **Effects** | React to changes | **Sync only** | Logging, localStorage, derived updates, dispatch actions |
|
|
465
|
+
| **Actions** | Handle workflows | **Async supported** | API calls, business logic, user interactions |
|
|
466
|
+
|
|
467
|
+
### How They Work Together
|
|
468
|
+
|
|
469
|
+
**1. Atoms hold your state:**
|
|
470
|
+
|
|
471
|
+
```typescript
|
|
472
|
+
const [user, setUser] = atom<User | null>(null);
|
|
473
|
+
const [isLoading, setIsLoading] = atom(false);
|
|
474
|
+
const [error, setError] = atom<Error | null>(null);
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
**2. Actions handle async operations and business logic:**
|
|
478
|
+
|
|
479
|
+
```typescript
|
|
480
|
+
// Action with handler for login
|
|
481
|
+
const login = action(
|
|
482
|
+
async (credentials: { email: string; password: string }) => {
|
|
483
|
+
setIsLoading(true);
|
|
484
|
+
setError(null);
|
|
485
|
+
|
|
486
|
+
try {
|
|
487
|
+
const user = await api.login(credentials);
|
|
488
|
+
setUser(user);
|
|
489
|
+
return user;
|
|
490
|
+
} catch (err) {
|
|
491
|
+
setError(err as Error);
|
|
492
|
+
throw err;
|
|
493
|
+
} finally {
|
|
494
|
+
setIsLoading(false);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
);
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
**3. Effects react to atom changes (sync only):**
|
|
501
|
+
|
|
502
|
+
```typescript
|
|
503
|
+
// Log when user changes
|
|
504
|
+
effect(() => {
|
|
505
|
+
const currentUser = user();
|
|
506
|
+
if (currentUser) {
|
|
507
|
+
console.log("User logged in:", currentUser.email);
|
|
508
|
+
}
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
// Sync to localStorage
|
|
512
|
+
effect((ctx) => {
|
|
513
|
+
const currentUser = user();
|
|
514
|
+
if (ctx.nth > 0) {
|
|
515
|
+
// Skip first run
|
|
516
|
+
localStorage.setItem("user", JSON.stringify(currentUser));
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
// Dispatch actions based on state changes
|
|
521
|
+
effect(() => {
|
|
522
|
+
const currentUser = user();
|
|
523
|
+
if (currentUser && !currentUser.profileComplete) {
|
|
524
|
+
onShowProfileWizard(); // Dispatch an action
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
**4. Components read atoms and dispatch actions:**
|
|
530
|
+
|
|
531
|
+
```typescript
|
|
532
|
+
function LoginForm() {
|
|
533
|
+
const loading = useRx(() => isLoading());
|
|
534
|
+
const loginError = useRx(() => error());
|
|
535
|
+
|
|
536
|
+
const handleSubmit = (e: FormEvent) => {
|
|
537
|
+
e.preventDefault();
|
|
538
|
+
login({ email, password }); // Dispatch action
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
return (
|
|
542
|
+
<form onSubmit={handleSubmit}>
|
|
543
|
+
{loginError && <p className="error">{loginError.message}</p>}
|
|
544
|
+
{/* ... form fields ... */}
|
|
545
|
+
<button disabled={loading}>{loading ? "Logging in..." : "Login"}</button>
|
|
546
|
+
</form>
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function UserProfile() {
|
|
551
|
+
const currentUser = useRx(() => user());
|
|
552
|
+
|
|
553
|
+
if (!currentUser) return <LoginForm />;
|
|
554
|
+
|
|
555
|
+
return <h1>Welcome, {currentUser.name}!</h1>;
|
|
556
|
+
}
|
|
557
|
+
```
|
|
558
|
+
|
|
559
|
+
### The Key Insight
|
|
560
|
+
|
|
561
|
+
Think of it as a unidirectional flow:
|
|
562
|
+
|
|
563
|
+
1. **User interactions** → dispatch **Actions**
|
|
564
|
+
2. **Actions** → perform async work → update **Atoms**
|
|
565
|
+
3. **Atoms** change → trigger **Effects** (sync reactions)
|
|
566
|
+
4. **Effects** can → dispatch more **Actions** (for async follow-ups)
|
|
567
|
+
5. **Components** → read **Atoms** → render UI
|
|
568
|
+
|
|
569
|
+
```
|
|
570
|
+
User Interaction ──► Action ──► Atom Update ──► Effect ──► Action (optional)
|
|
571
|
+
│
|
|
572
|
+
▼
|
|
573
|
+
Component Re-render
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
### Why This Separation Matters
|
|
577
|
+
|
|
578
|
+
| Concern | Handled By | Why |
|
|
579
|
+
| -------------------- | ------------------ | ----------------------------------------------- |
|
|
580
|
+
| **State storage** | Atoms | Single source of truth, fine-grained reactivity |
|
|
581
|
+
| **Sync reactions** | Effects | Predictable, debuggable, no race conditions |
|
|
582
|
+
| **Async operations** | Actions | Cancellation, error handling, workflow control |
|
|
583
|
+
| **UI rendering** | Components + useRx | Automatic subscriptions, conditional tracking |
|
|
584
|
+
|
|
585
|
+
This separation keeps your code organized:
|
|
586
|
+
|
|
587
|
+
- **Atoms** are just data - no logic
|
|
588
|
+
- **Effects** are synchronous and predictable - easy to debug
|
|
589
|
+
- **Actions** handle all the messy async stuff - with proper cancellation
|
|
590
|
+
- **Components** just render - no business logic
|
|
591
|
+
|
|
592
|
+
## Installation
|
|
593
|
+
|
|
594
|
+
```bash
|
|
595
|
+
# npm
|
|
596
|
+
npm install rexfect
|
|
597
|
+
|
|
598
|
+
# yarn
|
|
599
|
+
yarn add rexfect
|
|
600
|
+
|
|
601
|
+
# pnpm
|
|
602
|
+
pnpm add rexfect
|
|
603
|
+
```
|
|
604
|
+
|
|
605
|
+
## Store Setup
|
|
606
|
+
|
|
607
|
+
Every rexfect application needs to set up state using atoms. This is simpler than Redux - no store configuration, no reducers, just atoms.
|
|
608
|
+
|
|
609
|
+
### Creating Your First Store
|
|
610
|
+
|
|
611
|
+
The simplest way to create state is with the `atom()` function. Unlike Redux, there's no central store to configure:
|
|
612
|
+
|
|
613
|
+
```typescript
|
|
614
|
+
import { atom } from "rexfect";
|
|
615
|
+
|
|
616
|
+
// Create reactive state - returns [getter, setter] tuple
|
|
617
|
+
// Similar to React's useState, but works outside of components
|
|
618
|
+
const [count, setCount] = atom(0);
|
|
619
|
+
|
|
620
|
+
// Read the current value by calling the signal
|
|
621
|
+
console.log(count()); // 0
|
|
622
|
+
|
|
623
|
+
// Update the value with the setter
|
|
624
|
+
setCount(5);
|
|
625
|
+
console.log(count()); // 5
|
|
626
|
+
```
|
|
627
|
+
|
|
628
|
+
### Organizing State in Modules
|
|
629
|
+
|
|
630
|
+
For larger applications, organize related atoms together in modules:
|
|
631
|
+
|
|
632
|
+
```typescript
|
|
633
|
+
// store/user.ts
|
|
634
|
+
import { atom } from "rexfect";
|
|
635
|
+
|
|
636
|
+
// User state
|
|
637
|
+
export const [currentUser, setCurrentUser] = atom<User | null>(null);
|
|
638
|
+
export const [isAuthenticated, setIsAuthenticated] = atom(false);
|
|
639
|
+
|
|
640
|
+
// Preferences state
|
|
641
|
+
export const [theme, setTheme] = atom<"light" | "dark">("light");
|
|
642
|
+
export const [language, setLanguage] = atom("en");
|
|
643
|
+
```
|
|
644
|
+
|
|
645
|
+
```typescript
|
|
646
|
+
// store/cart.ts
|
|
647
|
+
import { atom } from "rexfect";
|
|
648
|
+
|
|
649
|
+
interface CartItem {
|
|
650
|
+
productId: string;
|
|
651
|
+
quantity: number;
|
|
652
|
+
price: number;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Shopping cart state
|
|
656
|
+
export const [cartItems, setCartItems] = atom<CartItem[]>([]);
|
|
657
|
+
export const [isCartOpen, setIsCartOpen] = atom(false);
|
|
658
|
+
|
|
659
|
+
// Helper functions that work with the atoms
|
|
660
|
+
export function addToCart(item: CartItem) {
|
|
661
|
+
setCartItems([...cartItems(), item]);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
export function removeFromCart(productId: string) {
|
|
665
|
+
setCartItems(cartItems().filter((item) => item.productId !== productId));
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
export function getCartTotal(): number {
|
|
669
|
+
return cartItems().reduce((sum, item) => sum + item.price * item.quantity, 0);
|
|
670
|
+
}
|
|
671
|
+
```
|
|
672
|
+
|
|
673
|
+
### Configuring Equality for Objects
|
|
674
|
+
|
|
675
|
+
When atoms hold objects or arrays, you'll want to configure equality to avoid unnecessary updates:
|
|
676
|
+
|
|
677
|
+
```typescript
|
|
678
|
+
import { atom } from "rexfect";
|
|
679
|
+
|
|
680
|
+
// Problem: Without equality config, setting the same object triggers updates
|
|
681
|
+
const [user, setUser] = atom({ name: "Ada", age: 30 });
|
|
682
|
+
setUser({ name: "Ada", age: 30 }); // This triggers subscribers (different reference)
|
|
683
|
+
|
|
684
|
+
// Solution: Use shallow equality for simple objects
|
|
685
|
+
const [user, setUser] = atom(
|
|
686
|
+
{ name: "Ada", age: 30 },
|
|
687
|
+
{ equals: "shallow" } // Only notifies if properties actually changed
|
|
688
|
+
);
|
|
689
|
+
setUser({ name: "Ada", age: 30 }); // No notification (same values)
|
|
690
|
+
|
|
691
|
+
// For nested objects, use deeper equality
|
|
692
|
+
const [config, setConfig] = atom(
|
|
693
|
+
{
|
|
694
|
+
ui: { theme: "dark", fontSize: 14 },
|
|
695
|
+
api: { baseUrl: "https://api.example.com", timeout: 5000 },
|
|
696
|
+
},
|
|
697
|
+
{ equals: "shallow2" } // Compares 2 levels deep
|
|
698
|
+
);
|
|
699
|
+
|
|
700
|
+
// For complex nested structures, use deep equality
|
|
701
|
+
const [appState, setAppState] = atom(deeplyNestedObject, { equals: "deep" });
|
|
702
|
+
```
|
|
703
|
+
|
|
704
|
+
**Available equality strategies:**
|
|
705
|
+
|
|
706
|
+
| Strategy | Use Case | Performance |
|
|
707
|
+
| ------------ | ------------------------------------- | ----------- |
|
|
708
|
+
| `"strict"` | Primitives, immutable references | Fastest |
|
|
709
|
+
| `"shallow"` | Simple objects, arrays | Fast |
|
|
710
|
+
| `"shallow2"` | Objects containing simple objects | Medium |
|
|
711
|
+
| `"shallow3"` | 3 levels of nesting | Medium |
|
|
712
|
+
| `"deep"` | Deeply nested structures | Slower |
|
|
713
|
+
| `(a, b) =>…` | Custom comparison (e.g., by ID field) | Depends |
|
|
714
|
+
|
|
715
|
+
## Writing Effects
|
|
716
|
+
|
|
717
|
+
Effects are synchronous reactions that run whenever their dependencies change. They're perfect for:
|
|
718
|
+
|
|
719
|
+
- Logging and debugging
|
|
720
|
+
- Syncing state to localStorage
|
|
721
|
+
- Updating DOM elements
|
|
722
|
+
- Triggering side effects
|
|
723
|
+
|
|
724
|
+
### Basic Effect Pattern
|
|
725
|
+
|
|
726
|
+
```typescript
|
|
727
|
+
import { atom, effect } from "rexfect";
|
|
728
|
+
|
|
729
|
+
const [firstName, setFirstName] = atom("Ada");
|
|
730
|
+
const [lastName, setLastName] = atom("Lovelace");
|
|
731
|
+
|
|
732
|
+
// Create an effect - it runs immediately, then re-runs when dependencies change
|
|
733
|
+
// Dependencies are automatically tracked: any atom() called inside is a dependency
|
|
734
|
+
const dispose = effect(() => {
|
|
735
|
+
// Reading firstName() and lastName() creates dependencies
|
|
736
|
+
console.log(`Hello, ${firstName()} ${lastName()}!`);
|
|
737
|
+
});
|
|
738
|
+
// Output: "Hello, Ada Lovelace!"
|
|
739
|
+
|
|
740
|
+
// Update firstName - effect re-runs automatically
|
|
741
|
+
setFirstName("Grace");
|
|
742
|
+
// Output: "Hello, Grace Lovelace!"
|
|
743
|
+
|
|
744
|
+
// When done, dispose the effect to stop it from running
|
|
745
|
+
dispose();
|
|
746
|
+
|
|
747
|
+
// Now updates won't trigger the effect
|
|
748
|
+
setFirstName("Margaret"); // No output
|
|
749
|
+
```
|
|
750
|
+
|
|
751
|
+
### Effect Context and Error Handling
|
|
752
|
+
|
|
753
|
+
Effects receive a context object with useful utilities:
|
|
754
|
+
|
|
755
|
+
```typescript
|
|
756
|
+
import { atom, effect } from "rexfect";
|
|
757
|
+
|
|
758
|
+
const [data, setData] = atom<string | null>(null);
|
|
759
|
+
|
|
760
|
+
const dispose = effect(
|
|
761
|
+
(ctx) => {
|
|
762
|
+
// ctx.nth: How many times the effect has run (0 on first run)
|
|
763
|
+
console.log(`Effect run #${ctx.nth}`);
|
|
764
|
+
|
|
765
|
+
// ctx.name: Effect name (from options) for debugging
|
|
766
|
+
console.log(`Effect name: ${ctx.name}`);
|
|
767
|
+
|
|
768
|
+
const value = data();
|
|
769
|
+
|
|
770
|
+
// ctx.onError: Register error handler for this effect run
|
|
771
|
+
ctx.onError((error) => {
|
|
772
|
+
console.error("Effect failed:", error);
|
|
773
|
+
// Handle error gracefully instead of crashing
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
if (value === "bad") {
|
|
777
|
+
throw new Error("Invalid data!");
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
console.log("Data:", value);
|
|
781
|
+
},
|
|
782
|
+
{ name: "dataLogger" }
|
|
783
|
+
);
|
|
784
|
+
```
|
|
785
|
+
|
|
786
|
+
### Syncing State to localStorage
|
|
787
|
+
|
|
788
|
+
A common pattern is persisting state to localStorage:
|
|
789
|
+
|
|
790
|
+
```typescript
|
|
791
|
+
import { atom, effect } from "rexfect";
|
|
792
|
+
|
|
793
|
+
// Create the atom with initial value from localStorage
|
|
794
|
+
const [theme, setTheme] = atom<"light" | "dark">(
|
|
795
|
+
(localStorage.getItem("theme") as "light" | "dark") || "light"
|
|
796
|
+
);
|
|
797
|
+
|
|
798
|
+
// Sync to localStorage whenever theme changes
|
|
799
|
+
effect((ctx) => {
|
|
800
|
+
const currentTheme = theme();
|
|
801
|
+
|
|
802
|
+
// Skip localStorage on first run (we just read from it)
|
|
803
|
+
if (ctx.nth === 0) return;
|
|
804
|
+
|
|
805
|
+
localStorage.setItem("theme", currentTheme);
|
|
806
|
+
console.log(`Theme saved: ${currentTheme}`);
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
// Now theme changes are automatically persisted
|
|
810
|
+
setTheme("dark"); // Saves to localStorage
|
|
811
|
+
```
|
|
812
|
+
|
|
813
|
+
### ⚠️ Important: Effects Must Be Synchronous
|
|
814
|
+
|
|
815
|
+
Effects are designed for synchronous operations only. If you need async operations, use Actions instead:
|
|
816
|
+
|
|
817
|
+
```typescript
|
|
818
|
+
import { atom, effect, action } from "rexfect";
|
|
819
|
+
|
|
820
|
+
// ❌ WRONG: Don't use async in effects
|
|
821
|
+
effect(async () => {
|
|
822
|
+
const data = await fetchData(); // This will throw an error!
|
|
823
|
+
setData(data);
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
// ✅ CORRECT: Use actions for async operations
|
|
827
|
+
const fetchData = action(async (_, ctx) => {
|
|
828
|
+
ctx.abortPrev();
|
|
829
|
+
const data = await fetchDataFromApi();
|
|
830
|
+
setData(data);
|
|
831
|
+
return data;
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
// Dispatch the action to trigger the async operation
|
|
835
|
+
fetchData();
|
|
836
|
+
```
|
|
837
|
+
|
|
838
|
+
## Batching Updates
|
|
839
|
+
|
|
840
|
+
When updating multiple atoms, use `batch()` to ensure effects only run once.
|
|
841
|
+
|
|
842
|
+
**Important:** Action handlers are automatically wrapped in `batch()`, so you don't need to wrap sync code inside action handlers. Only use explicit `batch()` for:
|
|
843
|
+
|
|
844
|
+
- Updates outside of actions
|
|
845
|
+
- Updates that happen after `await` in async action handlers
|
|
846
|
+
|
|
847
|
+
```typescript
|
|
848
|
+
import { atom, effect, batch, action } from "rexfect";
|
|
849
|
+
|
|
850
|
+
const [x, setX] = atom(0);
|
|
851
|
+
const [y, setY] = atom(0);
|
|
852
|
+
const [z, setZ] = atom(0);
|
|
853
|
+
|
|
854
|
+
// This effect depends on all three atoms
|
|
855
|
+
effect(() => {
|
|
856
|
+
console.log(`Position: (${x()}, ${y()}, ${z()})`);
|
|
857
|
+
});
|
|
858
|
+
// Output: "Position: (0, 0, 0)"
|
|
859
|
+
|
|
860
|
+
// ❌ WITHOUT batching - effect runs 3 times
|
|
861
|
+
setX(1); // Output: "Position: (1, 0, 0)"
|
|
862
|
+
setY(2); // Output: "Position: (1, 2, 0)"
|
|
863
|
+
setZ(3); // Output: "Position: (1, 2, 3)"
|
|
864
|
+
|
|
865
|
+
// ✅ WITH batching - effect runs once with final values
|
|
866
|
+
batch(() => {
|
|
867
|
+
setX(10);
|
|
868
|
+
setY(20);
|
|
869
|
+
setZ(30);
|
|
870
|
+
});
|
|
871
|
+
// Output: "Position: (10, 20, 30)" (just once!)
|
|
872
|
+
|
|
873
|
+
// ✅ Action handlers are automatically batched - no need for explicit batch()
|
|
874
|
+
const updatePosition = action((coords: { x: number; y: number; z: number }) => {
|
|
875
|
+
setX(coords.x); // These are automatically batched
|
|
876
|
+
setY(coords.y);
|
|
877
|
+
setZ(coords.z);
|
|
878
|
+
});
|
|
879
|
+
updatePosition({ x: 100, y: 200, z: 300 });
|
|
880
|
+
// Output: "Position: (100, 200, 300)" (just once!)
|
|
881
|
+
|
|
882
|
+
// ✅ For async handlers, only wrap updates after await
|
|
883
|
+
const fetchAndUpdate = action(async (_, ctx) => {
|
|
884
|
+
const data = await fetchData(); // Async operation
|
|
885
|
+
|
|
886
|
+
// Wrap updates after await in batch()
|
|
887
|
+
batch(() => {
|
|
888
|
+
setX(data.x);
|
|
889
|
+
setY(data.y);
|
|
890
|
+
setZ(data.z);
|
|
891
|
+
});
|
|
892
|
+
});
|
|
893
|
+
```
|
|
894
|
+
|
|
895
|
+
## Handling Async Logic with Actions
|
|
896
|
+
|
|
897
|
+
Actions are the primary way to handle async operations in rexfect. Unlike effects, actions support async handlers and provide cancellation utilities.
|
|
898
|
+
|
|
899
|
+
### Basic Action Pattern
|
|
900
|
+
|
|
901
|
+
```typescript
|
|
902
|
+
import { action } from "rexfect";
|
|
903
|
+
|
|
904
|
+
// Action with handler - executes logic and returns result
|
|
905
|
+
const loginUser = action(
|
|
906
|
+
async (payload: { userId: string; timestamp: Date }) => {
|
|
907
|
+
console.log(`User ${payload.userId} logging in...`);
|
|
908
|
+
|
|
909
|
+
// Perform async operations
|
|
910
|
+
const profile = await fetchUserProfile(payload.userId);
|
|
911
|
+
console.log("Profile loaded:", profile);
|
|
912
|
+
|
|
913
|
+
return profile;
|
|
914
|
+
}
|
|
915
|
+
);
|
|
916
|
+
|
|
917
|
+
// Dispatch the action and get result
|
|
918
|
+
const profile = await loginUser({ userId: "123", timestamp: new Date() });
|
|
919
|
+
```
|
|
920
|
+
|
|
921
|
+
### Actions with Handlers
|
|
922
|
+
|
|
923
|
+
Actions can have a handler that returns a result. Handlers are simple functions:
|
|
924
|
+
|
|
925
|
+
```typescript
|
|
926
|
+
import { action, atom } from "rexfect";
|
|
927
|
+
|
|
928
|
+
const [cart, setCart] = atom<CartItem[]>([]);
|
|
929
|
+
|
|
930
|
+
// Simple sync handler
|
|
931
|
+
const addToCart = action((item: CartItem) => {
|
|
932
|
+
const newCart = [...cart(), item];
|
|
933
|
+
setCart(newCart);
|
|
934
|
+
return newCart.length;
|
|
935
|
+
});
|
|
936
|
+
|
|
937
|
+
const cartCount = addToCart({ id: "123", qty: 1 });
|
|
938
|
+
|
|
939
|
+
// Async handler
|
|
940
|
+
const fetchProduct = action(async (id: string) => {
|
|
941
|
+
const res = await fetch(`/api/products/${id}`);
|
|
942
|
+
return res.json();
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
const product = await fetchProduct("123");
|
|
946
|
+
```
|
|
947
|
+
|
|
948
|
+
### Cancellation with `abortable()`
|
|
949
|
+
|
|
950
|
+
For cancellation support, wrap your handler with `abortable()`:
|
|
951
|
+
|
|
952
|
+
```typescript
|
|
953
|
+
import { action } from "rexfect";
|
|
954
|
+
import { abortable } from "rexfect/async";
|
|
955
|
+
|
|
956
|
+
// Wrap with abortable for cancellation support
|
|
957
|
+
const search = abortable(async ({ signal, abortPrev }, query: string) => {
|
|
958
|
+
// abortPrev(): Cancel the previous dispatch
|
|
959
|
+
// Call at start for "latest only" patterns
|
|
960
|
+
abortPrev();
|
|
961
|
+
|
|
962
|
+
// signal: AbortSignal for cancellation
|
|
963
|
+
// Pass to fetch, axios, etc.
|
|
964
|
+
const response = await fetch(`/api/search?q=${query}`, {
|
|
965
|
+
signal,
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
return response.json();
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
// Rapid dispatches: only the last one completes
|
|
972
|
+
search("r");
|
|
973
|
+
search("re");
|
|
974
|
+
search("rea");
|
|
975
|
+
search("reac");
|
|
976
|
+
const results = await search("react"); // Only this one completes
|
|
977
|
+
```
|
|
978
|
+
|
|
979
|
+
### Actions are Thenable
|
|
980
|
+
|
|
981
|
+
Actions implement the Promise interface, so you can await them:
|
|
982
|
+
|
|
983
|
+
```typescript
|
|
984
|
+
import { action } from "rexfect";
|
|
985
|
+
|
|
986
|
+
const onUserReady = action<User>();
|
|
987
|
+
|
|
988
|
+
// Await the NEXT dispatch
|
|
989
|
+
async function waitForUser() {
|
|
990
|
+
const user = await onUserReady;
|
|
991
|
+
console.log("User is ready:", user);
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// Or use .latest() to get the last value OR wait for next
|
|
995
|
+
async function getUser() {
|
|
996
|
+
const user = await onUserReady.latest();
|
|
997
|
+
return user;
|
|
998
|
+
}
|
|
999
|
+
```
|
|
1000
|
+
|
|
1001
|
+
### Sealed Actions for One-Time Initialization
|
|
1002
|
+
|
|
1003
|
+
Use sealed actions for initialization that should only happen once:
|
|
1004
|
+
|
|
1005
|
+
```typescript
|
|
1006
|
+
import { action } from "rexfect";
|
|
1007
|
+
|
|
1008
|
+
// Sealed action with handler - auto-seals after first dispatch
|
|
1009
|
+
const initApp = action(
|
|
1010
|
+
async (config: { version: string }) => {
|
|
1011
|
+
console.log(`App v${config.version} initializing...`);
|
|
1012
|
+
await initializeServices();
|
|
1013
|
+
return { initialized: true, version: config.version };
|
|
1014
|
+
},
|
|
1015
|
+
{ sealed: true }
|
|
1016
|
+
);
|
|
1017
|
+
|
|
1018
|
+
// First call executes handler and seals
|
|
1019
|
+
const result = await initApp({ version: "1.0.0" });
|
|
1020
|
+
// Output: "App v1.0.0 initializing..."
|
|
1021
|
+
|
|
1022
|
+
// Subsequent calls still execute handler but listeners don't fire
|
|
1023
|
+
await initApp({ version: "2.0.0" }); // Handler runs, but sealed
|
|
1024
|
+
|
|
1025
|
+
// Check action state
|
|
1026
|
+
console.log(initApp.sealed()); // true
|
|
1027
|
+
console.log(initApp.fired()); // true
|
|
1028
|
+
|
|
1029
|
+
// Late subscribers on sealed actions fire immediately with last payload
|
|
1030
|
+
initApp.on((config) => {
|
|
1031
|
+
console.log("Late subscriber:", config.version);
|
|
1032
|
+
});
|
|
1033
|
+
// Output: "Late subscriber: 1.0.0"
|
|
1034
|
+
```
|
|
1035
|
+
|
|
1036
|
+
### Real-World Example: Search with Cancellation
|
|
1037
|
+
|
|
1038
|
+
```typescript
|
|
1039
|
+
import { atom, action } from "rexfect";
|
|
1040
|
+
import { abortable } from "rexfect/async";
|
|
1041
|
+
|
|
1042
|
+
// State
|
|
1043
|
+
const [searchQuery, setSearchQuery] = atom("");
|
|
1044
|
+
const [searchResults, setSearchResults] = atom<Result[]>([]);
|
|
1045
|
+
const [isSearching, setIsSearching] = atom(false);
|
|
1046
|
+
const [searchError, setSearchError] = atom<Error | null>(null);
|
|
1047
|
+
|
|
1048
|
+
// Wrap with abortable for cancellation support
|
|
1049
|
+
const search = abortable(async ({ signal, abortPrev }, query: string) => {
|
|
1050
|
+
// Cancel any in-flight search
|
|
1051
|
+
abortPrev();
|
|
1052
|
+
|
|
1053
|
+
// Reset error state
|
|
1054
|
+
setSearchError(null);
|
|
1055
|
+
setIsSearching(true);
|
|
1056
|
+
|
|
1057
|
+
try {
|
|
1058
|
+
// Make API call with cancellation support
|
|
1059
|
+
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
|
|
1060
|
+
signal,
|
|
1061
|
+
});
|
|
1062
|
+
|
|
1063
|
+
const data = await response.json();
|
|
1064
|
+
|
|
1065
|
+
// Only update state if not aborted
|
|
1066
|
+
setSearchResults(data.results);
|
|
1067
|
+
return data.results;
|
|
1068
|
+
} catch (error) {
|
|
1069
|
+
// Don't set error if it was just an abort
|
|
1070
|
+
if (error instanceof Error && error.name !== "AbortError") {
|
|
1071
|
+
setSearchError(error);
|
|
1072
|
+
}
|
|
1073
|
+
throw error;
|
|
1074
|
+
} finally {
|
|
1075
|
+
setIsSearching(false);
|
|
1076
|
+
}
|
|
1077
|
+
});
|
|
1078
|
+
|
|
1079
|
+
// UI handler
|
|
1080
|
+
function handleSearchInput(query: string) {
|
|
1081
|
+
setSearchQuery(query);
|
|
1082
|
+
search(query);
|
|
1083
|
+
}
|
|
1084
|
+
```
|
|
1085
|
+
|
|
1086
|
+
## Async UI Patterns
|
|
1087
|
+
|
|
1088
|
+
For React and other UI frameworks, rexfect provides utilities for handling async state in the UI layer.
|
|
1089
|
+
|
|
1090
|
+
### Loading States with `loadable()`
|
|
1091
|
+
|
|
1092
|
+
Extract loading, data, and error states from Promise-valued atoms:
|
|
1093
|
+
|
|
1094
|
+
```typescript
|
|
1095
|
+
import { atom, effect } from "rexfect";
|
|
1096
|
+
import { loadable } from "rexfect/async";
|
|
1097
|
+
|
|
1098
|
+
// Create an atom that holds a Promise (or null for "not started")
|
|
1099
|
+
const [userPromise, setUserPromise] = atom<Promise<User> | null>(null);
|
|
1100
|
+
|
|
1101
|
+
// React to loading states
|
|
1102
|
+
effect(() => {
|
|
1103
|
+
const state = loadable(userPromise);
|
|
1104
|
+
|
|
1105
|
+
// null means we haven't started loading yet
|
|
1106
|
+
if (state === null) {
|
|
1107
|
+
console.log("Idle - click to load");
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
// state.loading: true while Promise is pending
|
|
1112
|
+
if (state.loading) {
|
|
1113
|
+
console.log("Loading user...");
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
// state.error: set if Promise rejected
|
|
1118
|
+
if (state.error) {
|
|
1119
|
+
console.error("Failed to load:", state.error);
|
|
1120
|
+
return;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
// state.data: the resolved value
|
|
1124
|
+
console.log("User loaded:", state.data);
|
|
1125
|
+
});
|
|
1126
|
+
|
|
1127
|
+
// Start loading by setting a Promise
|
|
1128
|
+
function loadUser(id: string) {
|
|
1129
|
+
setUserPromise(fetch(`/api/users/${id}`).then((r) => r.json()));
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
// Example usage
|
|
1133
|
+
loadUser("123");
|
|
1134
|
+
```
|
|
1135
|
+
|
|
1136
|
+
### React Suspense with `read()`
|
|
1137
|
+
|
|
1138
|
+
For React Suspense integration, use `read()` to suspend until data is ready:
|
|
1139
|
+
|
|
1140
|
+
```typescript
|
|
1141
|
+
import { atom } from "rexfect";
|
|
1142
|
+
import { useRx } from "rexfect/react";
|
|
1143
|
+
import { read } from "rexfect/async";
|
|
1144
|
+
import { Suspense } from "react";
|
|
1145
|
+
|
|
1146
|
+
// Create atom holding a Promise
|
|
1147
|
+
const [userPromise] = atom(fetchUser("123"));
|
|
1148
|
+
|
|
1149
|
+
function UserProfile() {
|
|
1150
|
+
// read() throws the Promise (suspends) until resolved
|
|
1151
|
+
// When resolved, returns the value directly
|
|
1152
|
+
const user = useRx(() => read(userPromise));
|
|
1153
|
+
|
|
1154
|
+
// This only renders after user is loaded!
|
|
1155
|
+
return (
|
|
1156
|
+
<div>
|
|
1157
|
+
<h1>{user.name}</h1>
|
|
1158
|
+
<p>{user.email}</p>
|
|
1159
|
+
</div>
|
|
1160
|
+
);
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
// Wrap in Suspense boundary to handle loading state
|
|
1164
|
+
function App() {
|
|
1165
|
+
return (
|
|
1166
|
+
<Suspense fallback={<div>Loading user...</div>}>
|
|
1167
|
+
<UserProfile />
|
|
1168
|
+
</Suspense>
|
|
1169
|
+
);
|
|
1170
|
+
}
|
|
1171
|
+
```
|
|
1172
|
+
|
|
1173
|
+
### Multiple Async Dependencies
|
|
1174
|
+
|
|
1175
|
+
Use `read.all()` for components that depend on multiple async values:
|
|
1176
|
+
|
|
1177
|
+
```typescript
|
|
1178
|
+
import { read } from "rexfect/async";
|
|
1179
|
+
import { useRx } from "rexfect/react";
|
|
1180
|
+
|
|
1181
|
+
function Dashboard() {
|
|
1182
|
+
// Suspends until BOTH promises resolve
|
|
1183
|
+
const { user, settings, notifications } = useRx(() =>
|
|
1184
|
+
read.all({
|
|
1185
|
+
user: userSignal,
|
|
1186
|
+
settings: settingsSignal,
|
|
1187
|
+
notifications: notificationsSignal,
|
|
1188
|
+
})
|
|
1189
|
+
);
|
|
1190
|
+
|
|
1191
|
+
return (
|
|
1192
|
+
<div>
|
|
1193
|
+
<h1>Welcome, {user.name}!</h1>
|
|
1194
|
+
<p>Theme: {settings.theme}</p>
|
|
1195
|
+
<p>Notifications: {notifications.length}</p>
|
|
1196
|
+
</div>
|
|
1197
|
+
);
|
|
1198
|
+
}
|
|
1199
|
+
```
|
|
1200
|
+
|
|
1201
|
+
### Using `wait` for Async Workflows
|
|
1202
|
+
|
|
1203
|
+
The `wait` utilities are for use with `await` in action handlers:
|
|
1204
|
+
|
|
1205
|
+
```typescript
|
|
1206
|
+
import { action } from "rexfect";
|
|
1207
|
+
import { wait } from "rexfect/async";
|
|
1208
|
+
|
|
1209
|
+
// Initialize with multiple async dependencies
|
|
1210
|
+
const initialize = action(async (_, ctx) => {
|
|
1211
|
+
ctx.abortPrev();
|
|
1212
|
+
|
|
1213
|
+
// wait.all(): Wait for multiple promises/actions
|
|
1214
|
+
const { user, config } = await wait.all({
|
|
1215
|
+
user: fetchUser(),
|
|
1216
|
+
config: fetchConfig(),
|
|
1217
|
+
});
|
|
1218
|
+
|
|
1219
|
+
console.log("Initialized with:", { user, config });
|
|
1220
|
+
return { user, config };
|
|
1221
|
+
});
|
|
1222
|
+
|
|
1223
|
+
// Racing multiple operations with timeout
|
|
1224
|
+
const fetchWithTimeout = action(async (_, ctx) => {
|
|
1225
|
+
ctx.abortPrev();
|
|
1226
|
+
|
|
1227
|
+
// wait.race(): Returns [winner, value] for objects
|
|
1228
|
+
const [winner, value] = await wait.race({
|
|
1229
|
+
data: fetchData(),
|
|
1230
|
+
timeout: delay(5000),
|
|
1231
|
+
});
|
|
1232
|
+
|
|
1233
|
+
if (winner === "timeout") {
|
|
1234
|
+
throw new Error("Request timed out!");
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
return value;
|
|
1238
|
+
});
|
|
1239
|
+
|
|
1240
|
+
// Get first success, ignore failures
|
|
1241
|
+
const fetchWithFallback = action(async (_, ctx) => {
|
|
1242
|
+
ctx.abortPrev();
|
|
1243
|
+
|
|
1244
|
+
// wait.any(): Returns first successful result
|
|
1245
|
+
const [winner, value] = await wait.any({
|
|
1246
|
+
primary: fetchFromPrimary(),
|
|
1247
|
+
backup: fetchFromBackup(),
|
|
1248
|
+
});
|
|
1249
|
+
|
|
1250
|
+
console.log(`Got data from ${winner}`);
|
|
1251
|
+
return value;
|
|
1252
|
+
});
|
|
1253
|
+
```
|
|
1254
|
+
|
|
1255
|
+
## React Integration
|
|
1256
|
+
|
|
1257
|
+
### The `useRx` Hook
|
|
1258
|
+
|
|
1259
|
+
The `useRx` hook lets you use reactive values in React components. Unlike other libraries:
|
|
1260
|
+
|
|
1261
|
+
- Works with **multiple atoms** in a single call
|
|
1262
|
+
- Supports **conditional** atom reads
|
|
1263
|
+
- Detects changes on **atoms themselves**, not selector results
|
|
1264
|
+
|
|
1265
|
+
```typescript
|
|
1266
|
+
import { atom } from "rexfect";
|
|
1267
|
+
import { useRx } from "rexfect/react";
|
|
1268
|
+
|
|
1269
|
+
// Define atoms at module level (outside components)
|
|
1270
|
+
const [firstName, setFirstName] = atom("Ada");
|
|
1271
|
+
const [lastName, setLastName] = atom("Lovelace");
|
|
1272
|
+
const [showFullName, setShowFullName] = atom(true);
|
|
1273
|
+
|
|
1274
|
+
function UserGreeting() {
|
|
1275
|
+
// useRx tracks ALL atoms read inside the callback
|
|
1276
|
+
// Component re-renders when ANY of them change
|
|
1277
|
+
const greeting = useRx(() => {
|
|
1278
|
+
const first = firstName();
|
|
1279
|
+
|
|
1280
|
+
// Conditional reads are fully supported!
|
|
1281
|
+
// lastName is only tracked when showFullName is true
|
|
1282
|
+
if (showFullName()) {
|
|
1283
|
+
return `Hello, ${first} ${lastName()}!`;
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
return `Hello, ${first}!`;
|
|
1287
|
+
});
|
|
1288
|
+
|
|
1289
|
+
return <h1>{greeting}</h1>;
|
|
1290
|
+
}
|
|
1291
|
+
```
|
|
1292
|
+
|
|
1293
|
+
### Working with Complex State
|
|
1294
|
+
|
|
1295
|
+
```typescript
|
|
1296
|
+
import { atom } from "rexfect";
|
|
1297
|
+
import { useRx } from "rexfect/react";
|
|
1298
|
+
|
|
1299
|
+
interface Todo {
|
|
1300
|
+
id: string;
|
|
1301
|
+
text: string;
|
|
1302
|
+
completed: boolean;
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
const [todos, setTodos] = atom<Todo[]>([]);
|
|
1306
|
+
const [filter, setFilter] = atom<"all" | "active" | "completed">("all");
|
|
1307
|
+
|
|
1308
|
+
function TodoList() {
|
|
1309
|
+
// Computed values work naturally
|
|
1310
|
+
const filteredTodos = useRx(() => {
|
|
1311
|
+
const all = todos();
|
|
1312
|
+
const currentFilter = filter();
|
|
1313
|
+
|
|
1314
|
+
switch (currentFilter) {
|
|
1315
|
+
case "active":
|
|
1316
|
+
return all.filter((t) => !t.completed);
|
|
1317
|
+
case "completed":
|
|
1318
|
+
return all.filter((t) => t.completed);
|
|
1319
|
+
default:
|
|
1320
|
+
return all;
|
|
1321
|
+
}
|
|
1322
|
+
});
|
|
1323
|
+
|
|
1324
|
+
const stats = useRx(() => ({
|
|
1325
|
+
total: todos().length,
|
|
1326
|
+
completed: todos().filter((t) => t.completed).length,
|
|
1327
|
+
active: todos().filter((t) => !t.completed).length,
|
|
1328
|
+
}));
|
|
1329
|
+
|
|
1330
|
+
return (
|
|
1331
|
+
<div>
|
|
1332
|
+
<p>
|
|
1333
|
+
{stats.active} active, {stats.completed} completed
|
|
1334
|
+
</p>
|
|
1335
|
+
<ul>
|
|
1336
|
+
{filteredTodos.map((todo) => (
|
|
1337
|
+
<li key={todo.id}>{todo.text}</li>
|
|
1338
|
+
))}
|
|
1339
|
+
</ul>
|
|
1340
|
+
</div>
|
|
1341
|
+
);
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
function FilterButtons() {
|
|
1345
|
+
// Each component subscribes to just what it needs
|
|
1346
|
+
const currentFilter = useRx(() => filter());
|
|
1347
|
+
|
|
1348
|
+
return (
|
|
1349
|
+
<div>
|
|
1350
|
+
<button
|
|
1351
|
+
className={currentFilter === "all" ? "active" : ""}
|
|
1352
|
+
onClick={() => setFilter("all")}
|
|
1353
|
+
>
|
|
1354
|
+
All
|
|
1355
|
+
</button>
|
|
1356
|
+
<button
|
|
1357
|
+
className={currentFilter === "active" ? "active" : ""}
|
|
1358
|
+
onClick={() => setFilter("active")}
|
|
1359
|
+
>
|
|
1360
|
+
Active
|
|
1361
|
+
</button>
|
|
1362
|
+
<button
|
|
1363
|
+
className={currentFilter === "completed" ? "active" : ""}
|
|
1364
|
+
onClick={() => setFilter("completed")}
|
|
1365
|
+
>
|
|
1366
|
+
Completed
|
|
1367
|
+
</button>
|
|
1368
|
+
</div>
|
|
1369
|
+
);
|
|
1370
|
+
}
|
|
1371
|
+
```
|
|
1372
|
+
|
|
1373
|
+
### StrictMode Compatibility
|
|
1374
|
+
|
|
1375
|
+
`useRx` is fully compatible with React StrictMode. It properly handles the double-invocation of effects that StrictMode uses to detect side effects.
|
|
1376
|
+
|
|
1377
|
+
## Fine-Grained Reactivity with `pick()`
|
|
1378
|
+
|
|
1379
|
+
The `pick()` function enables fine-grained subscriptions to specific properties. This prevents unnecessary re-renders when unrelated properties change.
|
|
1380
|
+
|
|
1381
|
+
### Basic Usage
|
|
1382
|
+
|
|
1383
|
+
```typescript
|
|
1384
|
+
import { atom, effect, pick } from "rexfect";
|
|
1385
|
+
|
|
1386
|
+
const [user, setUser] = atom({
|
|
1387
|
+
name: "Ada Lovelace",
|
|
1388
|
+
email: "ada@example.com",
|
|
1389
|
+
visits: 0,
|
|
1390
|
+
lastActive: new Date(),
|
|
1391
|
+
});
|
|
1392
|
+
|
|
1393
|
+
// ❌ WITHOUT pick() - re-runs on ANY property change
|
|
1394
|
+
effect(() => {
|
|
1395
|
+
const u = user(); // Tracks entire object
|
|
1396
|
+
console.log("User name:", u.name);
|
|
1397
|
+
});
|
|
1398
|
+
|
|
1399
|
+
// ✅ WITH pick() - only re-runs when name changes
|
|
1400
|
+
effect(() => {
|
|
1401
|
+
// pick() creates a fine-grained subscription to just the selected value
|
|
1402
|
+
const name = pick(() => user().name);
|
|
1403
|
+
console.log("User name (optimized):", name);
|
|
1404
|
+
});
|
|
1405
|
+
|
|
1406
|
+
// This triggers the first effect but NOT the second
|
|
1407
|
+
setUser({ ...user(), visits: user().visits + 1 });
|
|
1408
|
+
|
|
1409
|
+
// This triggers BOTH effects
|
|
1410
|
+
setUser({ ...user(), name: "Grace Hopper" });
|
|
1411
|
+
```
|
|
1412
|
+
|
|
1413
|
+
### Using pick() with React
|
|
1414
|
+
|
|
1415
|
+
```typescript
|
|
1416
|
+
import { atom } from "rexfect";
|
|
1417
|
+
import { useRx, pick } from "rexfect/react";
|
|
1418
|
+
|
|
1419
|
+
const [user, setUser] = atom({
|
|
1420
|
+
name: "Ada Lovelace",
|
|
1421
|
+
email: "ada@example.com",
|
|
1422
|
+
avatar: "/avatars/ada.png",
|
|
1423
|
+
stats: { posts: 42, followers: 1000 },
|
|
1424
|
+
});
|
|
1425
|
+
|
|
1426
|
+
// This component only re-renders when name changes
|
|
1427
|
+
function UserName() {
|
|
1428
|
+
const name = useRx(() => pick(() => user().name));
|
|
1429
|
+
return <h1>{name}</h1>;
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
// This component only re-renders when email changes
|
|
1433
|
+
function UserEmail() {
|
|
1434
|
+
const email = useRx(() => pick(() => user().email));
|
|
1435
|
+
return <p>{email}</p>;
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
// This component only re-renders when stats changes
|
|
1439
|
+
// Using shallow equality for the stats object
|
|
1440
|
+
function UserStats() {
|
|
1441
|
+
const stats = useRx(() => pick(() => user().stats, "shallow"));
|
|
1442
|
+
return (
|
|
1443
|
+
<p>
|
|
1444
|
+
{stats.posts} posts, {stats.followers} followers
|
|
1445
|
+
</p>
|
|
1446
|
+
);
|
|
1447
|
+
}
|
|
1448
|
+
```
|
|
1449
|
+
|
|
1450
|
+
### Custom Equality with pick()
|
|
1451
|
+
|
|
1452
|
+
For object properties, you can specify how to compare values:
|
|
1453
|
+
|
|
1454
|
+
```typescript
|
|
1455
|
+
import { atom, effect, pick } from "rexfect";
|
|
1456
|
+
|
|
1457
|
+
const [state, setState] = atom({
|
|
1458
|
+
user: {
|
|
1459
|
+
profile: { name: "Ada", avatar: "👩💻" },
|
|
1460
|
+
settings: { theme: "dark", notifications: true },
|
|
1461
|
+
},
|
|
1462
|
+
stats: { pageViews: 0 },
|
|
1463
|
+
});
|
|
1464
|
+
|
|
1465
|
+
effect(() => {
|
|
1466
|
+
// Use "shallow" equality for the profile object
|
|
1467
|
+
// Only re-runs if profile properties actually changed
|
|
1468
|
+
const profile = pick(() => state().user.profile, "shallow");
|
|
1469
|
+
console.log("Profile:", profile);
|
|
1470
|
+
});
|
|
1471
|
+
|
|
1472
|
+
effect(() => {
|
|
1473
|
+
// Use custom equality - compare by specific field
|
|
1474
|
+
const theme = pick(
|
|
1475
|
+
() => state().user.settings,
|
|
1476
|
+
(a, b) => a.theme === b.theme // Only care about theme changes
|
|
1477
|
+
);
|
|
1478
|
+
console.log("Settings (theme only):", theme);
|
|
1479
|
+
});
|
|
1480
|
+
```
|
|
1481
|
+
|
|
1482
|
+
## Reading Without Tracking
|
|
1483
|
+
|
|
1484
|
+
Use `untrack()` to read atoms without creating dependencies:
|
|
1485
|
+
|
|
1486
|
+
```typescript
|
|
1487
|
+
import { atom, effect, untrack } from "rexfect";
|
|
1488
|
+
|
|
1489
|
+
const [count, setCount] = atom(0);
|
|
1490
|
+
const [multiplier, setMultiplier] = atom(2);
|
|
1491
|
+
|
|
1492
|
+
effect(() => {
|
|
1493
|
+
const c = count(); // Tracked - changes trigger re-run
|
|
1494
|
+
|
|
1495
|
+
// multiplier is read but NOT tracked
|
|
1496
|
+
// Changes to multiplier won't trigger this effect
|
|
1497
|
+
const m = untrack(() => multiplier());
|
|
1498
|
+
|
|
1499
|
+
console.log(`${c} x ${m} = ${c * m}`);
|
|
1500
|
+
});
|
|
1501
|
+
|
|
1502
|
+
setCount(5); // Effect runs: "5 x 2 = 10"
|
|
1503
|
+
setMultiplier(3); // Effect does NOT run (untracked)
|
|
1504
|
+
setCount(6); // Effect runs: "6 x 3 = 18" (uses current multiplier)
|
|
1505
|
+
```
|
|
1506
|
+
|
|
1507
|
+
## TypeScript Support
|
|
1508
|
+
|
|
1509
|
+
rexfect is written in TypeScript and provides full type inference:
|
|
1510
|
+
|
|
1511
|
+
```typescript
|
|
1512
|
+
import { atom, effect, action, pick } from "rexfect";
|
|
1513
|
+
|
|
1514
|
+
// Types are inferred from initial value
|
|
1515
|
+
const [count, setCount] = atom(0);
|
|
1516
|
+
// count: Signal<number>
|
|
1517
|
+
// setCount: Setter<number>
|
|
1518
|
+
|
|
1519
|
+
// Explicit generic for complex types
|
|
1520
|
+
const [user, setUser] = atom<User | null>(null);
|
|
1521
|
+
// user: Signal<User | null>
|
|
1522
|
+
// setUser: Setter<User | null>
|
|
1523
|
+
|
|
1524
|
+
// Actions with payload types (use "on" prefix for void actions)
|
|
1525
|
+
const onClick = action<MouseEvent>();
|
|
1526
|
+
// onClick: Action<MouseEvent, void>
|
|
1527
|
+
|
|
1528
|
+
// Void actions (no payload needed)
|
|
1529
|
+
const onReset = action();
|
|
1530
|
+
// onReset: Action<void, void>
|
|
1531
|
+
|
|
1532
|
+
// Actions with handlers (use verb naming)
|
|
1533
|
+
const addItem = action((item: Item) => items.push(item));
|
|
1534
|
+
// addItem: Action<Item, number>
|
|
1535
|
+
|
|
1536
|
+
// pick() infers the selected type
|
|
1537
|
+
const [state] = atom({ user: { name: "Ada" }, count: 0 });
|
|
1538
|
+
effect(() => {
|
|
1539
|
+
const name = pick(() => state().user.name);
|
|
1540
|
+
// name: string (inferred!)
|
|
1541
|
+
});
|
|
1542
|
+
```
|
|
1543
|
+
|
|
1544
|
+
## API Reference
|
|
1545
|
+
|
|
1546
|
+
### Core Functions
|
|
1547
|
+
|
|
1548
|
+
#### `atom(initialValue, options?)`
|
|
1549
|
+
|
|
1550
|
+
Creates a reactive state container.
|
|
1551
|
+
|
|
1552
|
+
```typescript
|
|
1553
|
+
function atom<T>(
|
|
1554
|
+
initialValue: T,
|
|
1555
|
+
options?: {
|
|
1556
|
+
equals?: Equality<T>; // "strict" | "shallow" | "shallow2" | "shallow3" | "deep" | (a, b) => boolean
|
|
1557
|
+
key?: string; // For debugging
|
|
1558
|
+
}
|
|
1559
|
+
): [Signal<T>, Setter<T>];
|
|
1560
|
+
```
|
|
1561
|
+
|
|
1562
|
+
**Returns:** `[signal, setter]` tuple
|
|
1563
|
+
|
|
1564
|
+
- `signal()` - Call to get current value (tracks in reactive contexts)
|
|
1565
|
+
- `signal.on(listener)` - Subscribe to changes, returns unsubscribe function
|
|
1566
|
+
- `setter(value)` - Update the value
|
|
1567
|
+
|
|
1568
|
+
#### `effect(fn, options?)`
|
|
1569
|
+
|
|
1570
|
+
Creates a synchronous reactive effect that re-runs when dependencies change.
|
|
1571
|
+
|
|
1572
|
+
```typescript
|
|
1573
|
+
function effect(
|
|
1574
|
+
fn: (ctx: EffectContext) => void,
|
|
1575
|
+
options?: { name?: string }
|
|
1576
|
+
): VoidFunction; // Returns dispose function
|
|
1577
|
+
|
|
1578
|
+
interface EffectContext {
|
|
1579
|
+
nth: number; // Run count (0 on first run)
|
|
1580
|
+
name?: string; // Effect name from options
|
|
1581
|
+
onError(handler: (error: unknown) => void): void;
|
|
1582
|
+
use<R>(plugin: (ctx: EffectContext) => R): R;
|
|
1583
|
+
}
|
|
1584
|
+
```
|
|
1585
|
+
|
|
1586
|
+
#### `action(options?)` or `action(handler, options?)`
|
|
1587
|
+
|
|
1588
|
+
Creates an action dispatcher for async operations and workflows.
|
|
1589
|
+
|
|
1590
|
+
**Naming Convention:**
|
|
1591
|
+
|
|
1592
|
+
- Void actions (no handler): Use `on` prefix → `onClick`, `onSubmit`
|
|
1593
|
+
- Actions with handlers: Use verbs → `addItem`, `fetchUser`
|
|
1594
|
+
|
|
1595
|
+
```typescript
|
|
1596
|
+
// Void action (event-like)
|
|
1597
|
+
function action<T = void>(options?: ActionOptions): Action<T, void>;
|
|
1598
|
+
|
|
1599
|
+
// Action with handler (receives ActionContext)
|
|
1600
|
+
function action<T, R>(
|
|
1601
|
+
handler: (payload: T, ctx: ActionContext) => R,
|
|
1602
|
+
options?: ActionOptions
|
|
1603
|
+
): Action<T, R>;
|
|
1604
|
+
|
|
1605
|
+
interface ActionOptions {
|
|
1606
|
+
sealed?: boolean; // Auto-seal after first dispatch
|
|
1607
|
+
key?: string; // For debugging
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
interface Action<T, R = void> {
|
|
1611
|
+
(payload: T): R; // Dispatch and return result
|
|
1612
|
+
on(listener: (payload: T) => void | Promise<void>): VoidFunction;
|
|
1613
|
+
sealed(): boolean; // Is the action sealed?
|
|
1614
|
+
fired(): boolean; // Has the action been dispatched?
|
|
1615
|
+
latest(): Promise<T>; // Get last payload or wait for next
|
|
1616
|
+
then(...): Promise<T>; // Thenable - await next dispatch
|
|
1617
|
+
}
|
|
1618
|
+
```
|
|
1619
|
+
|
|
1620
|
+
#### `batch(fn)`
|
|
1621
|
+
|
|
1622
|
+
Groups multiple state updates into a single reactive cycle.
|
|
1623
|
+
|
|
1624
|
+
```typescript
|
|
1625
|
+
function batch<T>(fn: () => T): T;
|
|
1626
|
+
```
|
|
1627
|
+
|
|
1628
|
+
#### `untrack(fn)`
|
|
1629
|
+
|
|
1630
|
+
Reads atoms without creating dependencies.
|
|
1631
|
+
|
|
1632
|
+
```typescript
|
|
1633
|
+
function untrack<T>(fn: () => T): T;
|
|
1634
|
+
```
|
|
1635
|
+
|
|
1636
|
+
#### `pick(fn, equals?)`
|
|
1637
|
+
|
|
1638
|
+
Fine-grained selector for subscribing to specific properties.
|
|
1639
|
+
|
|
1640
|
+
```typescript
|
|
1641
|
+
function pick<T>(
|
|
1642
|
+
fn: () => T,
|
|
1643
|
+
equals?: Equality<T> // Default: "strict"
|
|
1644
|
+
): T;
|
|
1645
|
+
```
|
|
1646
|
+
|
|
1647
|
+
**Note:** Only works inside reactive contexts (`effect()`, `useRx()`).
|
|
1648
|
+
|
|
1649
|
+
---
|
|
1650
|
+
|
|
1651
|
+
### Async Utilities (`rexfect/async`)
|
|
1652
|
+
|
|
1653
|
+
#### `loadable(signal)`
|
|
1654
|
+
|
|
1655
|
+
Extracts loading state from a Promise-valued signal.
|
|
1656
|
+
|
|
1657
|
+
```typescript
|
|
1658
|
+
function loadable<T>(
|
|
1659
|
+
signal: Signal<Promise<T> | null | undefined>
|
|
1660
|
+
): LoadableResult<T> | null;
|
|
1661
|
+
|
|
1662
|
+
interface LoadableResult<T> {
|
|
1663
|
+
loading: boolean;
|
|
1664
|
+
data: T | undefined;
|
|
1665
|
+
error: unknown | undefined;
|
|
1666
|
+
}
|
|
1667
|
+
```
|
|
1668
|
+
|
|
1669
|
+
**Returns:** `null` if signal value is null/undefined, otherwise `LoadableResult`.
|
|
1670
|
+
|
|
1671
|
+
#### `read(signal)`
|
|
1672
|
+
|
|
1673
|
+
Suspense-compatible reading from a Promise signal. Throws the Promise (suspends) until resolved.
|
|
1674
|
+
|
|
1675
|
+
```typescript
|
|
1676
|
+
function read<T>(signal: Signal<Promise<T>>): T;
|
|
1677
|
+
|
|
1678
|
+
// Combinators
|
|
1679
|
+
read.all(signals): { ... } // Read all, suspend until done
|
|
1680
|
+
read.race(signals): ... // Read first to settle
|
|
1681
|
+
read.any(signals): ... // Read first to succeed
|
|
1682
|
+
read.settled(signals): ... // Read all (no throw on rejection)
|
|
1683
|
+
```
|
|
1684
|
+
|
|
1685
|
+
**Note:** For use in React components with Suspense only.
|
|
1686
|
+
|
|
1687
|
+
#### `wait(input)`
|
|
1688
|
+
|
|
1689
|
+
Promise wrapper for any PromiseLike (including Actions).
|
|
1690
|
+
|
|
1691
|
+
```typescript
|
|
1692
|
+
function wait<T>(input: PromiseLike<T>): Promise<T>;
|
|
1693
|
+
|
|
1694
|
+
// Combinators - work with arrays or objects
|
|
1695
|
+
wait.all([p1, p2]): Promise<[T1, T2]>
|
|
1696
|
+
wait.all({ a: p1, b: p2 }): Promise<{ a: T1, b: T2 }>
|
|
1697
|
+
|
|
1698
|
+
wait.race([p1, p2]): Promise<T1 | T2>
|
|
1699
|
+
wait.race({ a: p1, b: p2 }): Promise<["a" | "b", T]> // [winner, value]
|
|
1700
|
+
|
|
1701
|
+
wait.any([p1, p2]): Promise<T1 | T2>
|
|
1702
|
+
wait.any({ a: p1, b: p2 }): Promise<["a" | "b", T]>
|
|
1703
|
+
|
|
1704
|
+
wait.settled([p1, p2]): Promise<PromiseSettledResult[]>
|
|
1705
|
+
wait.settled({ a: p1, b: p2 }): Promise<{ a: PromiseSettledResult, ... }>
|
|
1706
|
+
```
|
|
1707
|
+
|
|
1708
|
+
---
|
|
1709
|
+
|
|
1710
|
+
### React Bindings (`rexfect/react`)
|
|
1711
|
+
|
|
1712
|
+
#### `useRx(fn)`
|
|
1713
|
+
|
|
1714
|
+
React hook for reactive computations. Tracks all atoms read during the callback and re-renders when they change.
|
|
1715
|
+
|
|
1716
|
+
```typescript
|
|
1717
|
+
function useRx<T>(fn: () => T): T;
|
|
1718
|
+
```
|
|
1719
|
+
|
|
1720
|
+
**Features:**
|
|
1721
|
+
|
|
1722
|
+
- Works with multiple atoms
|
|
1723
|
+
- Supports conditional reads
|
|
1724
|
+
- StrictMode compatible
|
|
1725
|
+
- Works with `pick()` for fine-grained subscriptions
|
|
1726
|
+
|
|
1727
|
+
---
|
|
1728
|
+
|
|
1729
|
+
### Equality Utilities
|
|
1730
|
+
|
|
1731
|
+
Pre-built equality functions for atom configuration:
|
|
1732
|
+
|
|
1733
|
+
```typescript
|
|
1734
|
+
import {
|
|
1735
|
+
strictEqual, // Object.is (default)
|
|
1736
|
+
shallowEqual, // 1-level object/array compare
|
|
1737
|
+
shallow2Equal, // 2-level compare
|
|
1738
|
+
shallow3Equal, // 3-level compare
|
|
1739
|
+
deepEqual, // Full recursive compare (lodash isEqual)
|
|
1740
|
+
} from "rexfect";
|
|
1741
|
+
|
|
1742
|
+
// Usage
|
|
1743
|
+
const [user, setUser] = atom(userData, { equals: shallowEqual });
|
|
1744
|
+
```
|
|
1745
|
+
|
|
1746
|
+
## Documentation
|
|
1747
|
+
|
|
1748
|
+
For more detailed information, see:
|
|
1749
|
+
|
|
1750
|
+
- [API Reference](./docs/API.md) - Complete API documentation
|
|
1751
|
+
- [Core Concepts](./docs/CONCEPTS.md) - Deep dive into how rexfect works
|
|
1752
|
+
- [Design Document](./docs/DESIGN.md) - Architecture and design decisions
|
|
1753
|
+
|
|
1754
|
+
## License
|
|
1755
|
+
|
|
1756
|
+
MIT
|