state-decorator 6.8.0-beta.1 → 6.10.0-beta.1
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 +299 -383
- package/impl.d.ts +113 -2
- package/impl.es.js +31 -1
- package/impl.es.js.map +1 -1
- package/impl.js +31 -1
- package/impl.js.map +1 -1
- package/index.d.ts +36 -55
- package/index.es.js +16 -1
- package/index.es.js.map +1 -1
- package/index.js +16 -1
- package/index.js.map +1 -1
- package/middlewares.d.ts +14 -0
- package/middlewares.es.js +1 -1
- package/middlewares.es.js.map +1 -1
- package/middlewares.js +1 -1
- package/middlewares.js.map +1 -1
- package/package.json +2 -2
- package/shallow.d.ts +14 -0
- package/test.d.ts +30 -3
- package/test.es.js +1 -1
- package/test.es.js.map +1 -1
- package/test.js +1 -1
- package/test.js.map +1 -1
- package/types.d.ts +29 -0
- package/v5.d.ts +14 -0
- package/v5.es.js +1 -1
- package/v5.es.js.map +1 -1
- package/v5.js +1 -1
- package/v5.js.map +1 -1
- package/v5_test.d.ts +14 -0
- package/v5_test.es.js +1 -1
- package/v5_test.es.js.map +1 -1
- package/v5_test.js +1 -1
- package/v5_test.js.map +1 -1
- package/v5_types.d.ts +14 -0
package/README.md
CHANGED
|
@@ -9,13 +9,14 @@ The StateDecorator is a set of Reacts hook that manages a complex component stat
|
|
|
9
9
|
# Features
|
|
10
10
|
|
|
11
11
|
- Deterministic state changes (clear separation of effects and side effects)
|
|
12
|
-
- Ease asynchronous actions state changes (loading states, success & error handlers, parallel actions management, optimistic updates...)
|
|
12
|
+
- Ease asynchronous actions state changes (loading / error states, success & error handlers, parallel actions management, optimistic updates...)
|
|
13
13
|
- Easily and efficiently share slices of state.
|
|
14
|
-
- Easily testable (
|
|
14
|
+
- Easily testable (uses pure functions, utility functions provided)
|
|
15
15
|
- Easily update state from or react to props changes
|
|
16
|
-
- Ease debugging (trace state changes
|
|
16
|
+
- Ease debugging (trace state changes)
|
|
17
17
|
- Improve code conciseness (no boiler plate code)
|
|
18
|
-
-
|
|
18
|
+
- Enforce separation of container components and presentation components.
|
|
19
|
+
- Strongly typed
|
|
19
20
|
|
|
20
21
|
# 🏎️ V6: The Store
|
|
21
22
|
|
|
@@ -138,12 +139,12 @@ export function App(props: Props) => {
|
|
|
138
139
|
|
|
139
140
|
# React hooks
|
|
140
141
|
|
|
141
|
-
| Hook
|
|
142
|
-
|
|
|
143
|
-
| useLocalStore
|
|
144
|
-
|
|
|
145
|
-
|
|
|
146
|
-
| useStoreSlice
|
|
142
|
+
| Hook | Purpose | Component refreshed on store change | Store is destroyed on unmount |
|
|
143
|
+
| ------------- | ------------------------------------------------------ | ----------------------------------- | ----------------------------- |
|
|
144
|
+
| useLocalStore | Create a store and binds it to the react component | Y | Y |
|
|
145
|
+
| useStore | Binds an existing store to a react component (sharing) | Y | Y |
|
|
146
|
+
| useBindStore | Bind an existing store to a react component (sharing) | N | Y |
|
|
147
|
+
| useStoreSlice | Binds a store slice to a react component (sharing) | If slice has changed only | N |
|
|
147
148
|
|
|
148
149
|
# Initial state
|
|
149
150
|
|
|
@@ -236,12 +237,12 @@ const actions: StoreActions<State, Actions> = {
|
|
|
236
237
|
|
|
237
238
|
## Recipes
|
|
238
239
|
|
|
239
|
-
- If the effects function returns
|
|
240
|
+
- If the effects function returns `null`, the side effects, if any, are not executed when the action is called.
|
|
240
241
|
- If not effects function is defined by side effects function is provided, the side effects will be executed when the action is called.
|
|
241
242
|
|
|
242
243
|
## Cancel action
|
|
243
244
|
|
|
244
|
-
To cancel effects/side effects just return
|
|
245
|
+
To cancel effects/side effects just return `null` instead of new state in **effects**.
|
|
245
246
|
|
|
246
247
|
# Asynchronous actions
|
|
247
248
|
|
|
@@ -280,16 +281,36 @@ const actions: StoreActions<State, Actions> = {
|
|
|
280
281
|
};
|
|
281
282
|
```
|
|
282
283
|
|
|
283
|
-
- If
|
|
284
|
+
- If `null` is returned by **getPromise**, the action is not executed. It allows to cancel an action depending on state or props etc.
|
|
284
285
|
|
|
285
286
|
- **getGetPromise** is a shortcut that sets the conflict policy to **ConflictPolicy.REUSE** and **retryCount** to 3.
|
|
286
287
|
|
|
287
|
-
- To cancel any effects just return
|
|
288
|
+
- To cancel any effects just return `null` instead of new state in **xxxEffects**.
|
|
288
289
|
|
|
289
290
|
## Asynchronous action lifecycle
|
|
290
291
|
|
|
291
292
|
<!--  -->
|
|
292
293
|
|
|
294
|
+
```mermaid
|
|
295
|
+
graph TD
|
|
296
|
+
A(Call action) --> preEffects
|
|
297
|
+
preEffects --> optimisticEffects
|
|
298
|
+
optimisticEffects --> getPromise
|
|
299
|
+
getPromise --> promiseSuccess{Success?}
|
|
300
|
+
promiseSuccess -->|Yes| effects[effects]
|
|
301
|
+
effects --> notifySuccess[notifySuccess]
|
|
302
|
+
notifySuccess --> sideEffects[sideEffects]
|
|
303
|
+
sideEffects --> promiseResolved[Promise is resolved]
|
|
304
|
+
promiseResolved --> Z(End)
|
|
305
|
+
promiseSuccess -->|No| errorEffects[errorEffects]
|
|
306
|
+
errorEffects --> notifyError[notifyError]
|
|
307
|
+
notifyError --> errSideEffetcs[errorSideEffects]
|
|
308
|
+
errSideEffetcs --> errorManaged{Error managed?}
|
|
309
|
+
errorManaged --> |Yes| promiseResolved
|
|
310
|
+
errorManaged --> |No| promiseRejected
|
|
311
|
+
promiseRejected --> Z
|
|
312
|
+
```
|
|
313
|
+
|
|
293
314
|
- check if a previous call to this action is ongoing,
|
|
294
315
|
- if yes, check if the conflict policy is parallel,
|
|
295
316
|
- if yes, let the flow continue.
|
|
@@ -307,6 +328,7 @@ const actions: StoreActions<State, Actions> = {
|
|
|
307
328
|
- **errorEffects**: change state from promise arguments and returned error.
|
|
308
329
|
- if **notifyError** is set, call it with **getErrorMessage()**.
|
|
309
330
|
- **errorSideEffects**: trigger a side effect with no change on state.
|
|
331
|
+
- Global **asyncErrorHandler** is called (with a flag indicating that the error was managed or not, see [Error management](#ErrorManagement) for more details).
|
|
310
332
|
|
|
311
333
|
- If a conflicting action is stored, process it.
|
|
312
334
|
|
|
@@ -315,9 +337,10 @@ const actions: StoreActions<State, Actions> = {
|
|
|
315
337
|
The loading state of asynchronous actions is automatically computed.
|
|
316
338
|
|
|
317
339
|
```typescript
|
|
318
|
-
const { isLoading, loading } = useLocalStore(getInitialState, actionsImpl);
|
|
340
|
+
const { isLoading, loading, loadingMap } = useLocalStore(getInitialState, actionsImpl);
|
|
319
341
|
|
|
320
342
|
// loading is set to true is at least one asynchronous action is ongoing.
|
|
343
|
+
// loadingMap is action name to boolean or undefined map
|
|
321
344
|
|
|
322
345
|
// isLoading is a function that accepts 1+ arguments
|
|
323
346
|
// It returns true, if at least one of the specified actions is loading.
|
|
@@ -335,9 +358,38 @@ A notification function can be called when the asynchronous action succeeded or
|
|
|
335
358
|
1. _getErrorMessage_ (message built from the error and action context).
|
|
336
359
|
2. _getSuccessMessage_ (message built from the action context)
|
|
337
360
|
|
|
361
|
+
```typescript
|
|
362
|
+
// global notification functions
|
|
363
|
+
setGlobalConfig({
|
|
364
|
+
notifySuccess: (msg) => console.log(msg),
|
|
365
|
+
notifyError: (msg) => console.log(msg),
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
// local notification functions
|
|
369
|
+
const store = useLocalStore(getInitialState, actionsImpl, props, {
|
|
370
|
+
notifySuccess: (msg) => console.log(msg),
|
|
371
|
+
notifyError: (msg) => console.log(msg),
|
|
372
|
+
});
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
```typescript
|
|
376
|
+
// action implementation
|
|
377
|
+
const actions: StoreActions<State, Actions> = {
|
|
378
|
+
loadList: {
|
|
379
|
+
getPromise: () => new Promise((_, reject) => setTimeout(reject, 500, new Error('Too bad'))),
|
|
380
|
+
getSuccessMessage: () => 'success!',
|
|
381
|
+
getErrorMessage: ({ err }) => `Error: ${err.message}`,
|
|
382
|
+
},
|
|
383
|
+
};
|
|
384
|
+
```
|
|
385
|
+
|
|
338
386
|
## <a name="ErrorManagement"></a>Error management
|
|
339
387
|
|
|
340
|
-
|
|
388
|
+
### Effects and side effects
|
|
389
|
+
|
|
390
|
+
When an asynchronous action fails, if the state needs to be updated, set the **errorEffects** property of the asynchronous action.
|
|
391
|
+
|
|
392
|
+
If some actions need to be triggered, set **errorSideEffects**.
|
|
341
393
|
|
|
342
394
|
```typescript
|
|
343
395
|
const actions: StoreActions<State, Actions> = {
|
|
@@ -352,7 +404,40 @@ const actions: StoreActions<State, Actions> = {
|
|
|
352
404
|
};
|
|
353
405
|
```
|
|
354
406
|
|
|
355
|
-
|
|
407
|
+
### Promise result
|
|
408
|
+
|
|
409
|
+
If **getPromise** is failing and the error is _managed_, the action promise will be returned as **resolved** to prevent other error managements (console traces…) to be executed, otherwise it will be returned as **rejected**.
|
|
410
|
+
|
|
411
|
+
An error is _managed_ if:
|
|
412
|
+
|
|
413
|
+
- **errorEffects** is defined and is not returning `null`.
|
|
414
|
+
- or **errorSideEffects** is defined,
|
|
415
|
+
- or **notifyError** is defined and **getErrorMessage** is not returning `null`,
|
|
416
|
+
- or **isErrorManaged** is set to `true`.
|
|
417
|
+
|
|
418
|
+
If the error is managed but you need to have the promise to be rejected, set **rejectPromiseOnError** to `true`.
|
|
419
|
+
|
|
420
|
+
Global **asyncErrorHandler** is called for each error with this _managed_ flag for global error management (logging).
|
|
421
|
+
|
|
422
|
+
### Error state
|
|
423
|
+
|
|
424
|
+
In order to display UI after an error, the last error of each asynchronous action is available on **errorMap**.
|
|
425
|
+
|
|
426
|
+
```typescript
|
|
427
|
+
const { errorMap } = useLocalStore(getInitialState, actionsImpl);
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
if (errorMap.action1)) {
|
|
431
|
+
// show retry action
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (errorMap.action1 instanceof MyCustomError){
|
|
435
|
+
// show retry action with custom error handling
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
Recipe: When only using the **errorMap** to manage errors (ie. there's no effects nor message and so on), the error is considered _not managed_ resulting in a rejected promise and global **asyncErrorHandler** called with _managed_ flag set to false. In that case, set **isErrorManaged** to `true` to specify that the error is _managed_ externally.
|
|
356
441
|
|
|
357
442
|
## <a name="ConflictingActions"></a>Conflicting actions
|
|
358
443
|
|
|
@@ -491,235 +576,64 @@ When a hook that creates / binds a store is mounted, the **onMount** option is c
|
|
|
491
576
|
```typescript
|
|
492
577
|
import { createStore } from 'state-decorator';
|
|
493
578
|
|
|
494
|
-
// global store
|
|
495
579
|
const store = createStore(getInitialState, actionsImpl, {
|
|
496
580
|
onMount: ({ a }) => {
|
|
497
581
|
a.loadList;
|
|
498
582
|
},
|
|
499
583
|
});
|
|
500
584
|
|
|
501
|
-
|
|
502
|
-
```
|
|
503
|
-
|
|
504
|
-
```typescript
|
|
505
|
-
import { createStore } from 'state-decorator';
|
|
506
|
-
|
|
507
|
-
// local store
|
|
585
|
+
// onMount is called when this component is mounted
|
|
508
586
|
function Container(p: ContainerProps) {
|
|
509
|
-
|
|
510
|
-
onMount: ({ a }) => {
|
|
511
|
-
a.loadList;
|
|
512
|
-
},
|
|
513
|
-
});
|
|
587
|
+
useBindStore(store, p);
|
|
514
588
|
}
|
|
515
589
|
```
|
|
516
590
|
|
|
517
591
|
If an initial action is launched each time a property changes, consider using the **onMount** flag of the **onPropsChange** entry.
|
|
518
592
|
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
There are two ways to share state and actions:
|
|
522
|
-
|
|
523
|
-
- Using regular props.
|
|
524
|
-
- Using state slices.
|
|
525
|
-
|
|
526
|
-
There are two types of stores:
|
|
527
|
-
|
|
528
|
-
- Global store:
|
|
529
|
-
|
|
530
|
-
- store is created as an instance and it is exported from the JS module.
|
|
531
|
-
- it can be imported in any other JS module.
|
|
532
|
-
- store lifespan is equal to the application one.
|
|
533
|
-
|
|
534
|
-
- Local store:
|
|
535
|
-
- store is created by a hook and is bound to a React component (each instance of this component owns its own store).
|
|
536
|
-
- action and props can be shared using props or sharing the store itself (see example below)
|
|
537
|
-
- store is destroyed when its React component is unmounted.
|
|
538
|
-
|
|
539
|
-
## Using props
|
|
540
|
-
|
|
541
|
-
- Bind a React component to make store state / actions available on React component.
|
|
542
|
-
- Any change in the store (state, loading actions, ...) will trigger a refresh of the bound React component.
|
|
543
|
-
- Pass state / actions using regular React props.
|
|
544
|
-
- Use React.memo to prevent unecessary React re-renders if needed.
|
|
593
|
+
## State sharing and slices
|
|
545
594
|
|
|
546
|
-
|
|
595
|
+
- Declare a store and bind it to a component using _useStore_ or _useBindStore_ hooks.
|
|
596
|
+
- Deeper in the tree component, use _useStoreSlice_ to get a store slice.
|
|
597
|
+
- Component using slices will be refreshed only if their store slice changes.
|
|
547
598
|
|
|
548
599
|
```typescript
|
|
549
|
-
// Declare typings & actions as above
|
|
550
|
-
|
|
551
|
-
// Create a store in a dedicated file, named GlobalStore.ts for example
|
|
552
|
-
export const store = createStore(getInitialState, userAppActions);
|
|
553
|
-
|
|
554
|
-
//----------------------
|
|
555
|
-
|
|
556
|
-
// In another file
|
|
557
|
-
import React, { memo } from 'react';
|
|
558
|
-
import { useStore } from 'state-decorator';
|
|
559
|
-
import store from './GlobalStore';
|
|
560
|
-
|
|
561
|
-
// Bind to react component
|
|
562
|
-
// Each time the store state changes, a re-render is done.
|
|
563
|
-
export function Container(props: Props) {
|
|
564
|
-
const { state, actions } = useStore(store, props);
|
|
565
|
-
return (
|
|
566
|
-
<div>
|
|
567
|
-
<Title text={state.title} />
|
|
568
|
-
<Subtitle text={state.subtitle} />
|
|
569
|
-
</div>
|
|
570
|
-
);
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
// Simple presentation components
|
|
574
|
-
// Use memo to prevent re-render if another part of the state has changed than "title"
|
|
575
|
-
const Title = memo(function Title(props: { title: string }) {
|
|
576
|
-
return <div>{props.text}</div>;
|
|
577
|
-
});
|
|
578
|
-
|
|
579
|
-
const Subtitle = memo(function Subtitle(props: { subtitle: string }) {
|
|
580
|
-
return <div>{props.subtitle}</div>;
|
|
581
|
-
});
|
|
582
|
-
```
|
|
583
|
-
|
|
584
|
-
### Local Store
|
|
585
|
-
|
|
586
|
-
```typescript
|
|
587
|
-
import React, { memo } from 'react';
|
|
588
|
-
import { useLocalStore } from 'state-decorator';
|
|
589
|
-
|
|
590
|
-
// Declare typings & actions as above
|
|
591
|
-
|
|
592
|
-
// Create and bind to react component
|
|
593
|
-
// Each time the store state changes, a re-render is done.
|
|
594
|
-
export function Container(props: Props) {
|
|
595
|
-
const { state, actions } = useLocalStore(getInitialState, userAppActions, props);
|
|
596
|
-
return (
|
|
597
|
-
<div>
|
|
598
|
-
<Title text={state.title} />
|
|
599
|
-
<Subtitle text={state.subtitle} />
|
|
600
|
-
</div>
|
|
601
|
-
);
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
// Simple presentation components
|
|
605
|
-
// Use memo to prevent re-render if another part of the state has changed than "title"
|
|
606
|
-
const Title = memo(function Title(props: { title: string }) {
|
|
607
|
-
return <div>{props.title}</div>;
|
|
608
|
-
});
|
|
609
|
-
|
|
610
|
-
const Subtitle = memo(function Subtitle(props: { subtitle: string }) {
|
|
611
|
-
return <div>{props.subtitle}</div>;
|
|
612
|
-
});
|
|
613
|
-
```
|
|
614
|
-
|
|
615
|
-
## State slices
|
|
616
|
-
|
|
617
|
-
Using React props implies useless re-renders and optionally memoization.
|
|
618
|
-
|
|
619
|
-
To prevent this, React provides [context](https://en.reactjs.org/docs/context.html) hooks.
|
|
620
|
-
|
|
621
|
-
The problem is that if the context contains a complex state with lots of props, if **any** of these props is changing, **all** components that uses this context will be re-rendered.
|
|
622
|
-
|
|
623
|
-
To overcome this problem, the StateDecorator provides a _useStoreSlice_ and _useStoreContextSlice_ hook that allow to define and extracts a slice of the state and trigger a re-render of the React component only if slice has changed.
|
|
624
|
-
|
|
625
|
-
[](https://codesandbox.io/s/slices-v6-eg471?file=/src/SliceView.tsx)
|
|
626
|
-
|
|
627
|
-
## Global Store
|
|
628
|
-
|
|
629
|
-
```typescript
|
|
630
|
-
// Declare typings & actions as above
|
|
631
|
-
|
|
632
|
-
// Create a store in a dedicated file, named GlobalStore.ts for example
|
|
633
|
-
export const store = createStore(getInitialState, userAppActions);
|
|
634
|
-
|
|
635
|
-
//----------------------
|
|
636
|
-
|
|
637
|
-
import React, { memo } from 'react';
|
|
638
|
-
import { useStoreSlice } from 'state-decorator';
|
|
639
|
-
import store from './GlobalStore';
|
|
640
|
-
|
|
641
|
-
export function Container(props: Props) {
|
|
642
|
-
return (
|
|
643
|
-
<div>
|
|
644
|
-
<Title />
|
|
645
|
-
<Subtitle />
|
|
646
|
-
</div>
|
|
647
|
-
);
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
function Title() {
|
|
651
|
-
// extracts the "title" slice
|
|
652
|
-
// component is refresh only if title is changed in the store
|
|
653
|
-
const { title } = useStoreSlice(store, ['title']);
|
|
654
|
-
return <div>{title}</div>;
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
function Subtitle(props: { subtitle: string }) {
|
|
658
|
-
const { subtitle } = useStoreSlice(store, ['subtitle']);
|
|
659
|
-
return <div>{subtitle}</div>;
|
|
660
|
-
}
|
|
661
|
-
```
|
|
662
|
-
|
|
663
|
-
## Local Store
|
|
664
|
-
|
|
665
|
-
```typescript
|
|
666
|
-
// ------- stores/MyStore.ts --------
|
|
667
|
-
|
|
668
600
|
import React from 'react';
|
|
669
|
-
import {
|
|
601
|
+
import { useStore, useStoreSlice, StoreActions } from 'state-decorator';
|
|
670
602
|
|
|
671
603
|
// Declare typings & actions as above
|
|
672
604
|
|
|
673
|
-
// Create a store
|
|
674
|
-
|
|
675
|
-
type StoreContextProps = StoreApi<State, Actions, Props>;
|
|
605
|
+
// Create a store
|
|
676
606
|
|
|
677
|
-
export const
|
|
607
|
+
export const store = createStore(getInitialState, userAppActions);
|
|
678
608
|
|
|
679
|
-
//
|
|
680
|
-
// But this context is NOT refreshed if the store state is changed
|
|
681
|
-
export function StoreContextProvider(p: { children: any; propIn: string }) {
|
|
682
|
-
const { children, ...props } = p;
|
|
683
|
-
const store = useGetLocalStore(getInitialState, actionsImpl, props);
|
|
684
|
-
return <StoreContext.Provider value={store}>{children}</StoreContext.Provider>;
|
|
685
|
-
}
|
|
609
|
+
// Bind to react component
|
|
686
610
|
|
|
687
|
-
|
|
611
|
+
export function Container(prop: Props) {
|
|
612
|
+
const { state, actions } = useStore(store, props);
|
|
688
613
|
|
|
689
|
-
|
|
614
|
+
// or
|
|
615
|
+
// useBindStore(store, props);
|
|
616
|
+
// if you are not interested in getting the state here
|
|
690
617
|
|
|
691
|
-
|
|
692
|
-
export function Container(props: Props) {
|
|
693
|
-
return (
|
|
694
|
-
<StoreContextProvider {...props}>
|
|
695
|
-
<SubComponent />
|
|
696
|
-
<SubComponent2 />
|
|
697
|
-
</StoreContextProvider>
|
|
698
|
-
);
|
|
618
|
+
return <div />;
|
|
699
619
|
}
|
|
700
620
|
|
|
701
|
-
//
|
|
702
|
-
|
|
703
|
-
import { StoreContext } from 'stores/MyStore';
|
|
704
|
-
|
|
705
|
-
function Title() {
|
|
706
|
-
// extracts the "title" slice
|
|
707
|
-
// component is refresh only if title is changed in the store
|
|
708
|
-
const { title } = useStoreContextSlice(StoreContext, ['title']);
|
|
709
|
-
|
|
710
|
-
// same as
|
|
711
|
-
// const store = useContext(StoreContext)
|
|
712
|
-
// const { title } = useStoreSlice(store, ['title']);
|
|
621
|
+
// Components deeper in the component tree will be refreshed if, and only if,
|
|
622
|
+
// slice is changed (here: text property)
|
|
713
623
|
|
|
714
|
-
|
|
624
|
+
export function SubComponent2() {
|
|
625
|
+
const s = useStoreSlice(store, ['text']);
|
|
626
|
+
return <div>{s.text}</div>;
|
|
715
627
|
}
|
|
716
628
|
|
|
717
|
-
function
|
|
718
|
-
const
|
|
719
|
-
return <div>{
|
|
629
|
+
export function SubComponent() {
|
|
630
|
+
const s = useStoreSlice(store, (s) => ({ text: s.text }));
|
|
631
|
+
return <div>{s.text}</div>;
|
|
720
632
|
}
|
|
721
633
|
```
|
|
722
634
|
|
|
635
|
+
[](https://codesandbox.io/s/slices-v6-eg471?file=/src/SliceView.tsx)
|
|
636
|
+
|
|
723
637
|
# Global configuration
|
|
724
638
|
|
|
725
639
|
Overrides configuration to set properties that will be used by all stores.
|
|
@@ -1101,172 +1015,6 @@ describe('Todo', () => {
|
|
|
1101
1015
|
});
|
|
1102
1016
|
});
|
|
1103
1017
|
|
|
1104
|
-
it('onSetNewTitle', async () => {
|
|
1105
|
-
// always return a promise whatever if the action is asynchronous or not
|
|
1106
|
-
const { state, prevState } = await store.getAction('onSetNewTitle').call('new Title');
|
|
1107
|
-
|
|
1108
|
-
// previous state is available for comparison
|
|
1109
|
-
expect(prevState.newTitle).toEqual('');
|
|
1110
|
-
expect(state.newTitle).toEqual('new Title');
|
|
1111
|
-
});
|
|
1112
|
-
|
|
1113
|
-
it('onCreate', async () => {
|
|
1114
|
-
const {
|
|
1115
|
-
state: { todoIds, todoMap, todos },
|
|
1116
|
-
} = await store
|
|
1117
|
-
// set partial state and return new store, source store is untouched
|
|
1118
|
-
.setPartialState({
|
|
1119
|
-
newTitle: 'new todo',
|
|
1120
|
-
})
|
|
1121
|
-
// get a mock action that can be shared too !
|
|
1122
|
-
.getAction('onCreate')
|
|
1123
|
-
// Invokes action. It has no side effect on state of mock action
|
|
1124
|
-
.call();
|
|
1125
|
-
|
|
1126
|
-
// assert state
|
|
1127
|
-
expect(todoIds).toEqual(['id', 'id2', 'id3']);
|
|
1128
|
-
expect(todoMap).toEqual({
|
|
1129
|
-
id: {
|
|
1130
|
-
id: 'id',
|
|
1131
|
-
completed: true,
|
|
1132
|
-
title: 'todo',
|
|
1133
|
-
},
|
|
1134
|
-
id2: {
|
|
1135
|
-
id: 'id2',
|
|
1136
|
-
completed: false,
|
|
1137
|
-
title: 'todo 2',
|
|
1138
|
-
},
|
|
1139
|
-
id3: {
|
|
1140
|
-
id: 'id3',
|
|
1141
|
-
completed: false,
|
|
1142
|
-
title: 'new todo',
|
|
1143
|
-
},
|
|
1144
|
-
});
|
|
1145
|
-
|
|
1146
|
-
// assert derived state
|
|
1147
|
-
//
|
|
1148
|
-
// export const todoOptions: StoreOptions<State, Actions, Props, DerivedState> = {
|
|
1149
|
-
// derivedState: {
|
|
1150
|
-
// todos: {
|
|
1151
|
-
// getDeps: ({ s }) => [s.todoIds, s.todoMap],
|
|
1152
|
-
// get: ({ s }) => s.todoIds.map((id) => s.todoMap[id]),
|
|
1153
|
-
// },
|
|
1154
|
-
// },
|
|
1155
|
-
// };
|
|
1156
|
-
|
|
1157
|
-
expect(todos).toEqual([
|
|
1158
|
-
{
|
|
1159
|
-
id: 'id',
|
|
1160
|
-
completed: true,
|
|
1161
|
-
title: 'todo',
|
|
1162
|
-
},
|
|
1163
|
-
{
|
|
1164
|
-
id: 'id2',
|
|
1165
|
-
completed: false,
|
|
1166
|
-
title: 'todo 2',
|
|
1167
|
-
},
|
|
1168
|
-
{
|
|
1169
|
-
id: 'id3',
|
|
1170
|
-
completed: false,
|
|
1171
|
-
title: 'new todo',
|
|
1172
|
-
},
|
|
1173
|
-
]);
|
|
1174
|
-
});
|
|
1175
|
-
|
|
1176
|
-
describe('onToggle', () => {
|
|
1177
|
-
// this mock action is shared in two tests but they can run in parallel
|
|
1178
|
-
const onToggle = store.getAction('onToggle');
|
|
1179
|
-
|
|
1180
|
-
it('toggle false => true', async () => {
|
|
1181
|
-
// arguments of call() depend on the Store action signature, use auto completion!
|
|
1182
|
-
const {
|
|
1183
|
-
state: { todoIds, todoMap },
|
|
1184
|
-
actions,
|
|
1185
|
-
} = await onToggle.call('id2');
|
|
1186
|
-
|
|
1187
|
-
expect(todoIds).toEqual(['id', 'id2']);
|
|
1188
|
-
expect(todoMap).toEqual({
|
|
1189
|
-
id: {
|
|
1190
|
-
id: 'id',
|
|
1191
|
-
completed: true,
|
|
1192
|
-
title: 'todo',
|
|
1193
|
-
},
|
|
1194
|
-
id2: {
|
|
1195
|
-
id: 'id2',
|
|
1196
|
-
completed: true,
|
|
1197
|
-
title: 'todo 2',
|
|
1198
|
-
},
|
|
1199
|
-
});
|
|
1200
|
-
|
|
1201
|
-
// if there are some side effects calling store actions, you can test them
|
|
1202
|
-
// as mock actions are injected instead of real actions.
|
|
1203
|
-
expect(actions.updateRemoteList).toHaveBeenCalled();
|
|
1204
|
-
});
|
|
1205
|
-
|
|
1206
|
-
it('toggle true => false', async () => {
|
|
1207
|
-
const {
|
|
1208
|
-
state: { todoIds, todoMap },
|
|
1209
|
-
actions,
|
|
1210
|
-
} = await onToggle.call('id');
|
|
1211
|
-
|
|
1212
|
-
expect(todoIds).toEqual(['id', 'id2']);
|
|
1213
|
-
expect(todoMap).toEqual({
|
|
1214
|
-
id: {
|
|
1215
|
-
id: 'id',
|
|
1216
|
-
completed: false,
|
|
1217
|
-
title: 'todo',
|
|
1218
|
-
},
|
|
1219
|
-
id2: {
|
|
1220
|
-
id: 'id2',
|
|
1221
|
-
completed: false,
|
|
1222
|
-
title: 'todo 2',
|
|
1223
|
-
},
|
|
1224
|
-
});
|
|
1225
|
-
|
|
1226
|
-
// if there are some side effects calling store actions, you can test them
|
|
1227
|
-
// as mock actions are injected instead of real actions.
|
|
1228
|
-
expect(actions.updateRemoteList).toHaveBeenCalled();
|
|
1229
|
-
});
|
|
1230
|
-
});
|
|
1231
|
-
|
|
1232
|
-
// Another store / action
|
|
1233
|
-
describe('loadRemoteList', () => {
|
|
1234
|
-
// action is shared
|
|
1235
|
-
const action = store.getAction('loadRemoteList');
|
|
1236
|
-
|
|
1237
|
-
it('success', async () => {
|
|
1238
|
-
const { state } = await action
|
|
1239
|
-
// override default promise result
|
|
1240
|
-
.promiseResolves([
|
|
1241
|
-
{
|
|
1242
|
-
id: 'item1',
|
|
1243
|
-
title: 'item title',
|
|
1244
|
-
completed: true,
|
|
1245
|
-
},
|
|
1246
|
-
])
|
|
1247
|
-
.call();
|
|
1248
|
-
|
|
1249
|
-
expect(state.error).toBeFalsy();
|
|
1250
|
-
expect(state.todoIds).toEqual(['item1']);
|
|
1251
|
-
expect(state.todoMap).toEqual({
|
|
1252
|
-
item1: {
|
|
1253
|
-
id: 'item1',
|
|
1254
|
-
title: 'item title',
|
|
1255
|
-
completed: true,
|
|
1256
|
-
},
|
|
1257
|
-
});
|
|
1258
|
-
});
|
|
1259
|
-
|
|
1260
|
-
it('error', async () => {
|
|
1261
|
-
const { state } = await action
|
|
1262
|
-
// override default promise result with error
|
|
1263
|
-
.promiseRejects(new Error('boom'))
|
|
1264
|
-
.call();
|
|
1265
|
-
|
|
1266
|
-
expect(state.error).toBeTruthy();
|
|
1267
|
-
});
|
|
1268
|
-
});
|
|
1269
|
-
|
|
1270
1018
|
// Test effects and side effect when props are changing
|
|
1271
1019
|
//
|
|
1272
1020
|
// Here:
|
|
@@ -1316,6 +1064,174 @@ describe('Todo', () => {
|
|
|
1316
1064
|
expect(actions.loadRemoteList).not.toHaveBeenCalled();
|
|
1317
1065
|
});
|
|
1318
1066
|
});
|
|
1067
|
+
|
|
1068
|
+
describe('actions', () => {
|
|
1069
|
+
it('onSetNewTitle', async () => {
|
|
1070
|
+
// always return a promise whatever if the action is asynchronous or not
|
|
1071
|
+
const { state, prevState } = await store.getAction('onSetNewTitle').call('new Title');
|
|
1072
|
+
|
|
1073
|
+
// previous state is available for comparison
|
|
1074
|
+
expect(prevState.newTitle).toEqual('');
|
|
1075
|
+
expect(state.newTitle).toEqual('new Title');
|
|
1076
|
+
});
|
|
1077
|
+
|
|
1078
|
+
it('onCreate', async () => {
|
|
1079
|
+
const {
|
|
1080
|
+
state: { todoIds, todoMap, todos },
|
|
1081
|
+
} = await store
|
|
1082
|
+
// set partial state and return new store, source store is untouched
|
|
1083
|
+
.setPartialState({
|
|
1084
|
+
newTitle: 'new todo',
|
|
1085
|
+
})
|
|
1086
|
+
// get a mock action that can be shared too !
|
|
1087
|
+
.getAction('onCreate')
|
|
1088
|
+
// Invokes action. It has no side effect on state of mock action
|
|
1089
|
+
.call();
|
|
1090
|
+
|
|
1091
|
+
// assert state
|
|
1092
|
+
expect(todoIds).toEqual(['id', 'id2', 'id3']);
|
|
1093
|
+
expect(todoMap).toEqual({
|
|
1094
|
+
id: {
|
|
1095
|
+
id: 'id',
|
|
1096
|
+
completed: true,
|
|
1097
|
+
title: 'todo',
|
|
1098
|
+
},
|
|
1099
|
+
id2: {
|
|
1100
|
+
id: 'id2',
|
|
1101
|
+
completed: false,
|
|
1102
|
+
title: 'todo 2',
|
|
1103
|
+
},
|
|
1104
|
+
id3: {
|
|
1105
|
+
id: 'id3',
|
|
1106
|
+
completed: false,
|
|
1107
|
+
title: 'new todo',
|
|
1108
|
+
},
|
|
1109
|
+
});
|
|
1110
|
+
|
|
1111
|
+
// assert derived state
|
|
1112
|
+
//
|
|
1113
|
+
// export const todoOptions: StoreOptions<State, Actions, Props, DerivedState> = {
|
|
1114
|
+
// derivedState: {
|
|
1115
|
+
// todos: {
|
|
1116
|
+
// getDeps: ({ s }) => [s.todoIds, s.todoMap],
|
|
1117
|
+
// get: ({ s }) => s.todoIds.map((id) => s.todoMap[id]),
|
|
1118
|
+
// },
|
|
1119
|
+
// },
|
|
1120
|
+
// };
|
|
1121
|
+
|
|
1122
|
+
expect(todos).toEqual([
|
|
1123
|
+
{
|
|
1124
|
+
id: 'id',
|
|
1125
|
+
completed: true,
|
|
1126
|
+
title: 'todo',
|
|
1127
|
+
},
|
|
1128
|
+
{
|
|
1129
|
+
id: 'id2',
|
|
1130
|
+
completed: false,
|
|
1131
|
+
title: 'todo 2',
|
|
1132
|
+
},
|
|
1133
|
+
{
|
|
1134
|
+
id: 'id3',
|
|
1135
|
+
completed: false,
|
|
1136
|
+
title: 'new todo',
|
|
1137
|
+
},
|
|
1138
|
+
]);
|
|
1139
|
+
});
|
|
1140
|
+
|
|
1141
|
+
describe('onToggle', () => {
|
|
1142
|
+
// this mock action is shared in two tests but they can run in parallel
|
|
1143
|
+
const onToggle = store.getAction('onToggle');
|
|
1144
|
+
|
|
1145
|
+
it('toggle false => true', async () => {
|
|
1146
|
+
// arguments of call() depend on the Store action signature, use auto completion!
|
|
1147
|
+
const {
|
|
1148
|
+
state: { todoIds, todoMap },
|
|
1149
|
+
actions,
|
|
1150
|
+
} = await onToggle.call('id2');
|
|
1151
|
+
|
|
1152
|
+
expect(todoIds).toEqual(['id', 'id2']);
|
|
1153
|
+
expect(todoMap).toEqual({
|
|
1154
|
+
id: {
|
|
1155
|
+
id: 'id',
|
|
1156
|
+
completed: true,
|
|
1157
|
+
title: 'todo',
|
|
1158
|
+
},
|
|
1159
|
+
id2: {
|
|
1160
|
+
id: 'id2',
|
|
1161
|
+
completed: true,
|
|
1162
|
+
title: 'todo 2',
|
|
1163
|
+
},
|
|
1164
|
+
});
|
|
1165
|
+
|
|
1166
|
+
// if there are some side effects calling store actions, you can test them
|
|
1167
|
+
// as mock actions are injected instead of real actions.
|
|
1168
|
+
expect(actions.updateRemoteList).toHaveBeenCalled();
|
|
1169
|
+
});
|
|
1170
|
+
|
|
1171
|
+
it('toggle true => false', async () => {
|
|
1172
|
+
const {
|
|
1173
|
+
state: { todoIds, todoMap },
|
|
1174
|
+
actions,
|
|
1175
|
+
} = await onToggle.call('id');
|
|
1176
|
+
|
|
1177
|
+
expect(todoIds).toEqual(['id', 'id2']);
|
|
1178
|
+
expect(todoMap).toEqual({
|
|
1179
|
+
id: {
|
|
1180
|
+
id: 'id',
|
|
1181
|
+
completed: false,
|
|
1182
|
+
title: 'todo',
|
|
1183
|
+
},
|
|
1184
|
+
id2: {
|
|
1185
|
+
id: 'id2',
|
|
1186
|
+
completed: false,
|
|
1187
|
+
title: 'todo 2',
|
|
1188
|
+
},
|
|
1189
|
+
});
|
|
1190
|
+
|
|
1191
|
+
// if there are some side effects calling store actions, you can test them
|
|
1192
|
+
// as mock actions are injected instead of real actions.
|
|
1193
|
+
expect(actions.updateRemoteList).toHaveBeenCalled();
|
|
1194
|
+
});
|
|
1195
|
+
});
|
|
1196
|
+
|
|
1197
|
+
// Another store / action
|
|
1198
|
+
describe('loadRemoteList', () => {
|
|
1199
|
+
// action is shared
|
|
1200
|
+
const action = store.getAction('loadRemoteList');
|
|
1201
|
+
|
|
1202
|
+
it('success', async () => {
|
|
1203
|
+
const { state } = await action
|
|
1204
|
+
// override default promise result
|
|
1205
|
+
.promiseResolves([
|
|
1206
|
+
{
|
|
1207
|
+
id: 'item1',
|
|
1208
|
+
title: 'item title',
|
|
1209
|
+
completed: true,
|
|
1210
|
+
},
|
|
1211
|
+
])
|
|
1212
|
+
.call();
|
|
1213
|
+
|
|
1214
|
+
expect(state.error).toBeFalsy();
|
|
1215
|
+
expect(state.todoIds).toEqual(['item1']);
|
|
1216
|
+
expect(state.todoMap).toEqual({
|
|
1217
|
+
item1: {
|
|
1218
|
+
id: 'item1',
|
|
1219
|
+
title: 'item title',
|
|
1220
|
+
completed: true,
|
|
1221
|
+
},
|
|
1222
|
+
});
|
|
1223
|
+
});
|
|
1224
|
+
|
|
1225
|
+
it('error', async () => {
|
|
1226
|
+
const { state } = await action
|
|
1227
|
+
// override default promise result with error
|
|
1228
|
+
.promiseRejects(new Error('boom'))
|
|
1229
|
+
.call();
|
|
1230
|
+
|
|
1231
|
+
expect(state.error).toBeTruthy();
|
|
1232
|
+
});
|
|
1233
|
+
});
|
|
1234
|
+
});
|
|
1319
1235
|
});
|
|
1320
1236
|
```
|
|
1321
1237
|
|
|
@@ -1440,9 +1356,9 @@ TypeError: Cannot set property 'crash' of null
|
|
|
1440
1356
|
### MockStore
|
|
1441
1357
|
|
|
1442
1358
|
| Function | Arguments | Returns | Description |
|
|
1443
|
-
| --------------- | ------------------------------------ | ------------------- | ------------------------------------------------------------------------------------------- |
|
|
1359
|
+
| --------------- | ------------------------------------ | ------------------- | ------------------------------------------------------------------------------------------- |
|
|
1444
1360
|
| getAction | actionName: keyof A | MockStoreAction | Get a mock action instance |
|
|
1445
|
-
| onInit | props?: Partial<P> | MockResult | Test store initialization (onPropsChanged marked onMount + onMount) |
|
|
1361
|
+
| onInit | props?: Partial<P> | MockResult | Test store initialization (onPropsChanged marked onMount + onMount) |
|
|
1446
1362
|
| onPropsChange | props: P, init?: boolean | MockResult | Test prop changes. Does not change internal props. Init allows to test onMount prop changes |
|
|
1447
1363
|
| setPartialProps | props: Partial<P> | new mock store | Create a new store with the merge of current props and partial props |
|
|
1448
1364
|
| setPartialState | state: Partial<S> | new mock store | Create a new store with the merge of current state and partial state |
|
|
@@ -1473,7 +1389,7 @@ TypeError: Cannot set property 'crash' of null
|
|
|
1473
1389
|
|
|
1474
1390
|
# Immutability
|
|
1475
1391
|
|
|
1476
|
-
- Immutability: each effects **must** return a new state instance (or
|
|
1392
|
+
- Immutability: each effects **must** return a new state instance (or `null` if there's no change).
|
|
1477
1393
|
- I recommend using [Immer](https://github.com/immerjs/immer) to manage complex cases (deep nesting). A **immerizeActions** action decorator is provided to decorate each effect with Immer.
|
|
1478
1394
|
|
|
1479
1395
|
# Limitations
|