@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 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