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 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 (test framework provided)
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, dev tools)
16
+ - Ease debugging (trace state changes)
17
17
  - Improve code conciseness (no boiler plate code)
18
- - Strongly typed (Typescript)
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 | Purpose | Return | Component refreshed on store change | Store is destroyed on unmount |
142
- | ---------------- | --------------------------------------------------- | -------------- | ----------------------------------- | ----------------------------- |
143
- | useLocalStore | Create a store, binds it to the react component. | State, actions | Y | Y |
144
- | useGetLocalStore | Create a store, binds it to the react component. | Store | N | Y |
145
- | useStore | Binds a store instance to the react component. | State, actions | Y | N |
146
- | useStoreSlice | Binds a store slice to a react component (sharing). | Slice | If slice has changed only | N |
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 **null**, the side effects, if any, are not executed when the action is called.
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 **null** instead of new state in **effects**.
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 **null** is returned by **getPromise**, the action is not executed. It allows to cancel an action depending on state or props etc.
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 **null** instead of new state in **xxxEffects**.
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
  <!-- ![Lifecycle](https://raw.githubusercontent.com/GlobalSport/state-decorator/develop/doc/StateDecoratorDiagram.png) -->
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
- When an asynchronous action fails, if the state needs to be updated, set the _errorEffects_ property of the asynchronous action.
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
- **Note:** If an error message or an error reducer is defined, the error will be trapped to prevent other error management (console traces...). If, for any reason, you need to have the promise to be rejected, set **rejectPromiseOnError** to _true_.
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
- store.init(initialProps);
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
- const store = useGetLocalStore(getInitialState, actionsImpl, p, {
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
- # Global and Local Stores VS State sharing
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
- ### Global store
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
- [![Edit Slice](https://codesandbox.io/static/img/play-codesandbox.svg)](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 { useGetLocalStore, useStoreContextSlice } from 'state-decorator';
601
+ import { useStore, useStoreSlice, StoreActions } from 'state-decorator';
670
602
 
671
603
  // Declare typings & actions as above
672
604
 
673
- // Create a store context
674
-
675
- type StoreContextProps = StoreApi<State, Actions, Props>;
605
+ // Create a store
676
606
 
677
- export const StoreContext = createContext<StoreContextProps>(null);
607
+ export const store = createStore(getInitialState, userAppActions);
678
608
 
679
- // The StoreContext will allow to access to the store
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
- // In a container file ------------------------
611
+ export function Container(prop: Props) {
612
+ const { state, actions } = useStore(store, props);
688
613
 
689
- import { StoreContextProvider } from 'stores/MyStore';
614
+ // or
615
+ // useBindStore(store, props);
616
+ // if you are not interested in getting the state here
690
617
 
691
- // If Container is destroyed store is destroyed
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
- // Component deeper in the component tree...
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
- return <div>{title}</div>;
624
+ export function SubComponent2() {
625
+ const s = useStoreSlice(store, ['text']);
626
+ return <div>{s.text}</div>;
715
627
  }
716
628
 
717
- function Subtitle(props: { subtitle: string }) {
718
- const { subtitle } = useStoreContextSlice(StoreContext, ['subtitle']);
719
- return <div>{subtitle}</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
+ [![Edit Slice](https://codesandbox.io/static/img/play-codesandbox.svg)](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 **null** if there's no change).
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