@radio-garden/rematch 1.0.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/LICENSE +21 -0
- package/README.md +445 -0
- package/dist/index.d.ts +543 -0
- package/dist/index.js +287 -0
- package/dist/index.js.map +1 -0
- package/package.json +46 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Radio Garden
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
# @radio-garden/rematch
|
|
2
|
+
|
|
3
|
+
A lightweight, type-safe state management library built on Redux. Inspired by [Rematch](https://rematchjs.org/), with a simplified API and enhanced TypeScript support.
|
|
4
|
+
|
|
5
|
+
## Differences from Rematch
|
|
6
|
+
|
|
7
|
+
- **No plugins** - Selectors and other features are built-in, not separate packages
|
|
8
|
+
- **`store.selector` and `store.select`** - Two ways to access selectors: with state argument (for `useSelector`) or bound to current state
|
|
9
|
+
- **Auto-generated property selectors** - `selector.model.propertyName` is automatically created for each state property
|
|
10
|
+
- **`store.watchSelector`** - Built-in reactive state watching, also available inside effects
|
|
11
|
+
- **`store.createSelector`** - Built-in memoized selector creation using reselect
|
|
12
|
+
- **Selectors defined on models** - Define selectors directly in the model, with access to `createSelector` and cross-model selectors
|
|
13
|
+
- **Effects receive more utilities** - Effects factory receives `{ dispatch, getState, select, selector, watchSelector }`
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install @radio-garden/rematch redux reselect
|
|
19
|
+
# or
|
|
20
|
+
pnpm add @radio-garden/rematch redux reselect
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Quick Start
|
|
24
|
+
|
|
25
|
+
```typescript
|
|
26
|
+
import { createModel, createRematchStore, type Models } from '@radio-garden/rematch';
|
|
27
|
+
|
|
28
|
+
// Define your models interface
|
|
29
|
+
interface AppModels extends Models<AppModels> {
|
|
30
|
+
counter: typeof counter;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Create a model
|
|
34
|
+
const counter = createModel<{ count: number }, AppModels>()({
|
|
35
|
+
state: { count: 0 },
|
|
36
|
+
reducers: {
|
|
37
|
+
increment(state) {
|
|
38
|
+
return { count: state.count + 1 };
|
|
39
|
+
},
|
|
40
|
+
add(state, payload: number) {
|
|
41
|
+
return { count: state.count + payload };
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
effects: ({ dispatch }) => ({
|
|
45
|
+
async incrementAsync() {
|
|
46
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
47
|
+
dispatch.counter.increment();
|
|
48
|
+
}
|
|
49
|
+
}),
|
|
50
|
+
selectors: ({ selector }) => ({
|
|
51
|
+
doubled: state => selector.counter.count(state) * 2
|
|
52
|
+
})
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Create the store
|
|
56
|
+
const store = createRematchStore<AppModels>({
|
|
57
|
+
name: 'MyApp',
|
|
58
|
+
models: { counter }
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Use it
|
|
62
|
+
store.dispatch.counter.increment();
|
|
63
|
+
store.dispatch.counter.add(5);
|
|
64
|
+
await store.dispatch.counter.incrementAsync();
|
|
65
|
+
|
|
66
|
+
console.log(store.getState().counter.count); // 7
|
|
67
|
+
console.log(store.select.counter.doubled()); // 14
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## API
|
|
71
|
+
|
|
72
|
+
### `createRematchStore<TModels>(config)`
|
|
73
|
+
|
|
74
|
+
Creates a new store instance.
|
|
75
|
+
|
|
76
|
+
```typescript
|
|
77
|
+
const store = createRematchStore<AppModels>({
|
|
78
|
+
name: 'MyApp', // Optional store name
|
|
79
|
+
models: { counter, user }, // Your models
|
|
80
|
+
redux: { // Optional Redux configuration
|
|
81
|
+
middlewares: [], // Custom Redux middlewares
|
|
82
|
+
devtoolOptions: { disabled: false },
|
|
83
|
+
devtoolCompose: composeWithDevTools // For React Native
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### `createModel<TState, TModels>()(modelConfig)`
|
|
89
|
+
|
|
90
|
+
Creates a typed model definition.
|
|
91
|
+
|
|
92
|
+
```typescript
|
|
93
|
+
const user = createModel<{ name: string; age: number }, AppModels>()({
|
|
94
|
+
state: { name: '', age: 0 },
|
|
95
|
+
reducers: {
|
|
96
|
+
setName(state, name: string) {
|
|
97
|
+
return { ...state, name };
|
|
98
|
+
},
|
|
99
|
+
setAge(state, age: number) {
|
|
100
|
+
return { ...state, age };
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
effects: store => ({
|
|
104
|
+
async fetchUser(userId: string) {
|
|
105
|
+
const user = await api.getUser(userId);
|
|
106
|
+
this.setName(user.name);
|
|
107
|
+
this.setAge(user.age);
|
|
108
|
+
}
|
|
109
|
+
}),
|
|
110
|
+
selectors: ({ selector, createSelector }) => ({
|
|
111
|
+
// Simple selector
|
|
112
|
+
isAdult: state => selector.user.age(state) >= 18,
|
|
113
|
+
|
|
114
|
+
// Memoized selector using createSelector
|
|
115
|
+
fullInfo: createSelector(create =>
|
|
116
|
+
create(
|
|
117
|
+
[selector.user.name, selector.user.age],
|
|
118
|
+
(name, age) => `${name} (${age} years old)`
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
})
|
|
122
|
+
});
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Selectors can be defined as a plain object or a factory function:
|
|
126
|
+
|
|
127
|
+
```typescript
|
|
128
|
+
// Plain object (when you don't need cross-model selectors)
|
|
129
|
+
selectors: {
|
|
130
|
+
doubled(state): number {
|
|
131
|
+
return state.counter.count * 2;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Factory function (when you need access to other selectors or createSelector)
|
|
136
|
+
selectors: ({ selector, createSelector }) => ({
|
|
137
|
+
doubled(state): number {
|
|
138
|
+
return selector.counter.count(state) * 2;
|
|
139
|
+
}
|
|
140
|
+
})
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
The factory function receives:
|
|
144
|
+
- `selector` - Access selectors from any model
|
|
145
|
+
- `createSelector` - Create memoized selectors using reselect
|
|
146
|
+
- `getState` - Get current root state (useful for deriving types)
|
|
147
|
+
|
|
148
|
+
**Important:** Always add explicit return type annotations to selectors to avoid recursive type definitions:
|
|
149
|
+
|
|
150
|
+
```typescript
|
|
151
|
+
selectors: ({ selector }) => ({
|
|
152
|
+
// Explicit return types prevent TypeScript circular reference errors
|
|
153
|
+
tabState(state): TabState | undefined {
|
|
154
|
+
const { tab, tabs } = state.browser;
|
|
155
|
+
return tab ? tabs[tab] : undefined;
|
|
156
|
+
},
|
|
157
|
+
currentPage(state): Page | undefined {
|
|
158
|
+
return selector.browser.tabState(state)?.currentPage;
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
For complex memoized selectors, use a type helper to ensure proper typing:
|
|
164
|
+
|
|
165
|
+
```typescript
|
|
166
|
+
// Define a type helper for selectors
|
|
167
|
+
type CreateSelector<TRootState, TReturn> = (state: TRootState) => TReturn;
|
|
168
|
+
|
|
169
|
+
const browser = createModel<BrowserState, AppModels>()({
|
|
170
|
+
state: { /* ... */ },
|
|
171
|
+
selectors: ({ selector, createSelector, getState }) => {
|
|
172
|
+
// Derive RootState type from getState
|
|
173
|
+
type RootState = ReturnType<typeof getState>;
|
|
174
|
+
|
|
175
|
+
// Use the type helper for complex return types
|
|
176
|
+
const pages: CreateSelector<RootState, {
|
|
177
|
+
currentPage: Page;
|
|
178
|
+
previousPage: Page | undefined;
|
|
179
|
+
}> = createSelector(create =>
|
|
180
|
+
create(
|
|
181
|
+
[selector.browser.history, selector.browser.pageCache],
|
|
182
|
+
(history, pageCache) => ({
|
|
183
|
+
currentPage: pageCache[history.current],
|
|
184
|
+
previousPage: history.previous ? pageCache[history.previous] : undefined
|
|
185
|
+
})
|
|
186
|
+
)
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
return { pages };
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### Store Methods
|
|
195
|
+
|
|
196
|
+
#### `store.dispatch`
|
|
197
|
+
|
|
198
|
+
Dispatch reducers and effects:
|
|
199
|
+
|
|
200
|
+
```typescript
|
|
201
|
+
store.dispatch.counter.increment(); // No payload
|
|
202
|
+
store.dispatch.counter.add(5); // With payload
|
|
203
|
+
store.dispatch.counter.addWithMeta(5, { multiplier: 2 }); // With meta
|
|
204
|
+
await store.dispatch.user.fetchUser('123'); // Async effect
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
#### `store.selector`
|
|
208
|
+
|
|
209
|
+
Get selectors that require state as an argument. Useful for React hooks like `useSelector` or when you need to work with a specific state snapshot.
|
|
210
|
+
|
|
211
|
+
```typescript
|
|
212
|
+
const state = store.getState();
|
|
213
|
+
|
|
214
|
+
// Get entire model state
|
|
215
|
+
store.selector.counter(state); // { count: 5 }
|
|
216
|
+
store.selector.user(state); // { name: 'John', age: 25 }
|
|
217
|
+
|
|
218
|
+
// Auto-generated property selectors
|
|
219
|
+
store.selector.counter.count(state); // 5
|
|
220
|
+
store.selector.user.name(state); // 'John'
|
|
221
|
+
|
|
222
|
+
// Custom selectors defined in the model
|
|
223
|
+
store.selector.counter.doubled(state); // 10
|
|
224
|
+
store.selector.user.isAdult(state); // true
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Use with React Redux:
|
|
228
|
+
|
|
229
|
+
```typescript
|
|
230
|
+
import { useSelector } from 'react-redux';
|
|
231
|
+
|
|
232
|
+
function Counter() {
|
|
233
|
+
const count = useSelector(store.selector.counter.count);
|
|
234
|
+
const doubled = useSelector(store.selector.counter.doubled);
|
|
235
|
+
return <div>{count} (doubled: {doubled})</div>;
|
|
236
|
+
}
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
#### `store.select`
|
|
240
|
+
|
|
241
|
+
Get bound selectors that automatically use current state. Unlike `store.selector`, these don't require passing state as an argument.
|
|
242
|
+
|
|
243
|
+
```typescript
|
|
244
|
+
// Get entire model state
|
|
245
|
+
store.select.counter(); // { count: 5 }
|
|
246
|
+
store.select.user(); // { name: 'John', age: 25 }
|
|
247
|
+
|
|
248
|
+
// Auto-generated property selectors
|
|
249
|
+
store.select.counter.count(); // 5
|
|
250
|
+
store.select.user.name(); // 'John'
|
|
251
|
+
store.select.user.age(); // 25
|
|
252
|
+
|
|
253
|
+
// Custom selectors defined in the model
|
|
254
|
+
store.select.counter.doubled(); // 10
|
|
255
|
+
store.select.user.isAdult(); // true
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
This is useful when you need quick access to the current state without managing state references:
|
|
259
|
+
|
|
260
|
+
```typescript
|
|
261
|
+
// Inside an effect or anywhere with store access
|
|
262
|
+
if (store.select.user.isAdult()) {
|
|
263
|
+
console.log(`User ${store.select.user.name()} is an adult`);
|
|
264
|
+
}
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
#### `store.createSelector`
|
|
268
|
+
|
|
269
|
+
Create memoized selectors using reselect:
|
|
270
|
+
|
|
271
|
+
```typescript
|
|
272
|
+
const selectTotal = store.createSelector(create =>
|
|
273
|
+
create(
|
|
274
|
+
[state => state.counter.count, state => state.user.age],
|
|
275
|
+
(count, age) => count + age
|
|
276
|
+
)
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
selectTotal(store.getState()); // Memoized result
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
#### `store.watchSelector`
|
|
283
|
+
|
|
284
|
+
Watch for state changes:
|
|
285
|
+
|
|
286
|
+
```typescript
|
|
287
|
+
// Watch a single selector
|
|
288
|
+
const unsubscribe = store.watchSelector(
|
|
289
|
+
state => state.counter.count,
|
|
290
|
+
(newValue, oldValue) => {
|
|
291
|
+
console.log(`Count changed from ${oldValue} to ${newValue}`);
|
|
292
|
+
}
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
// Watch multiple selectors
|
|
296
|
+
store.watchSelector(
|
|
297
|
+
[state => state.counter.count, state => state.user.age],
|
|
298
|
+
([newCount, newAge], [oldCount, oldAge]) => {
|
|
299
|
+
console.log('Values changed');
|
|
300
|
+
}
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
// Trigger immediately with current value
|
|
304
|
+
store.watchSelector(
|
|
305
|
+
state => state.counter.count,
|
|
306
|
+
(value) => console.log('Current count:', value),
|
|
307
|
+
{ initial: true }
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
// Stop watching
|
|
311
|
+
unsubscribe();
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
#### `store.subscribe`
|
|
315
|
+
|
|
316
|
+
Low-level Redux subscription:
|
|
317
|
+
|
|
318
|
+
```typescript
|
|
319
|
+
const unsubscribe = store.subscribe(() => {
|
|
320
|
+
console.log('State changed:', store.getState());
|
|
321
|
+
});
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
#### `store.getState`
|
|
325
|
+
|
|
326
|
+
Get the current state:
|
|
327
|
+
|
|
328
|
+
```typescript
|
|
329
|
+
const state = store.getState();
|
|
330
|
+
// { counter: { count: 0 }, user: { name: '', age: 0 } }
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
## React Native Integration
|
|
334
|
+
|
|
335
|
+
For React Native with Flipper or Reactotron, pass a custom `devtoolCompose`:
|
|
336
|
+
|
|
337
|
+
```typescript
|
|
338
|
+
import { composeWithDevTools } from '@redux-devtools/extension';
|
|
339
|
+
|
|
340
|
+
const store = createRematchStore<AppModels>({
|
|
341
|
+
models: { counter },
|
|
342
|
+
redux: {
|
|
343
|
+
devtoolCompose: composeWithDevTools
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
To disable devtools:
|
|
349
|
+
|
|
350
|
+
```typescript
|
|
351
|
+
const store = createRematchStore<AppModels>({
|
|
352
|
+
models: { counter },
|
|
353
|
+
redux: {
|
|
354
|
+
devtoolOptions: { disabled: true }
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
## Effects
|
|
360
|
+
|
|
361
|
+
The effects factory receives the store object with these properties:
|
|
362
|
+
- `dispatch` - Dispatch actions to any model
|
|
363
|
+
- `getState` - Get current root state
|
|
364
|
+
- `select` - Bound selectors (no state argument needed)
|
|
365
|
+
- `selector` - Selectors requiring state argument
|
|
366
|
+
- `watchSelector` - Watch for state changes
|
|
367
|
+
|
|
368
|
+
You can use the store directly or destructure what you need:
|
|
369
|
+
|
|
370
|
+
```typescript
|
|
371
|
+
// Using store directly
|
|
372
|
+
effects: store => ({
|
|
373
|
+
async syncAll() {
|
|
374
|
+
await this.fetchData();
|
|
375
|
+
store.dispatch.otherModel.refresh();
|
|
376
|
+
}
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
// Destructuring
|
|
380
|
+
effects: ({ dispatch, select, watchSelector, selector }) => ({
|
|
381
|
+
async initialize() {
|
|
382
|
+
// Use select for quick state access
|
|
383
|
+
if (select.user.isAdult()) {
|
|
384
|
+
dispatch.features.enableAdultContent();
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Watch for state changes within effects
|
|
388
|
+
watchSelector(
|
|
389
|
+
[selector.user.name, selector.settings.theme],
|
|
390
|
+
([name, theme]) => {
|
|
391
|
+
console.log('User or theme changed');
|
|
392
|
+
},
|
|
393
|
+
{ initial: true }
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
})
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
### Effect Parameters
|
|
400
|
+
|
|
401
|
+
Each effect receives the payload as first argument and `rootState` as second:
|
|
402
|
+
|
|
403
|
+
```typescript
|
|
404
|
+
effects: ({ dispatch }) => ({
|
|
405
|
+
async updateIfNeeded(threshold: number, rootState) {
|
|
406
|
+
if (rootState.counter.count > threshold) {
|
|
407
|
+
dispatch.counter.reset();
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
})
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
### Effects with `this` Context
|
|
414
|
+
|
|
415
|
+
Inside effects, `this` is bound to the model's dispatch object:
|
|
416
|
+
|
|
417
|
+
```typescript
|
|
418
|
+
effects: {
|
|
419
|
+
async saveUser(userData: UserData) {
|
|
420
|
+
const saved = await api.saveUser(userData);
|
|
421
|
+
this.setName(saved.name); // Calls user.setName reducer
|
|
422
|
+
this.setAge(saved.age); // Calls user.setAge reducer
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
## TypeScript
|
|
428
|
+
|
|
429
|
+
The library is fully typed. Define your models interface for complete type inference:
|
|
430
|
+
|
|
431
|
+
```typescript
|
|
432
|
+
interface AppModels extends Models<AppModels> {
|
|
433
|
+
counter: typeof counter;
|
|
434
|
+
user: typeof user;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// All dispatch, selector, and select calls are fully typed
|
|
438
|
+
store.dispatch.counter.add(5); // payload must be number
|
|
439
|
+
store.select.user.name(); // returns string
|
|
440
|
+
store.selector.counter.doubled(state); // returns number
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
## License
|
|
444
|
+
|
|
445
|
+
MIT
|