state-decorator 6.6.1 → 6.8.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
@@ -11,12 +11,11 @@ The StateDecorator is a set of Reacts hook that manages a complex component stat
11
11
  - Deterministic state changes (clear separation of effects and side effects)
12
12
  - Ease asynchronous actions state changes (loading states, success & error handlers, parallel actions management, optimistic updates...)
13
13
  - Easily and efficiently share slices of state.
14
- - Easily testable (uses pure functions, utility functions provided)
14
+ - Easily testable (test framework provided)
15
15
  - Easily update state from or react to props changes
16
- - Ease debugging (trace state changes)
16
+ - Ease debugging (trace state changes, dev tools)
17
17
  - Improve code conciseness (no boiler plate code)
18
- - Enforce separation of container components and presentation components.
19
- - Strongly typed
18
+ - Strongly typed (Typescript)
20
19
 
21
20
  # 🏎️ V6: The Store
22
21
 
@@ -76,19 +75,19 @@ type Props = {
76
75
  // Initial state & actions
77
76
 
78
77
  export const getInitialState = (): State => ({
79
- selectedUser: null,
78
+ selectedUserId: null,
80
79
  user: null,
81
80
  });
82
81
 
83
82
  export const userAppActions: StoreActions<State, Actions, Props> = {
84
83
  // synchronous action (simple form)
85
- selectUser: ({ state, args: [id] }) => ({ ...state, selectedUser: id }),
84
+ selectUser: ({ state, args: [id] }) => ({ ...state, selectedUserId: id }),
86
85
 
87
86
  // a synchronous action with side effects
88
87
  selectAndLoadUser: {
89
88
  // same as simple form, using aliases (use short context alias 's' instead of 'state')
90
89
  // effects contain only immutable state changes
91
- effects: ({ s, args: [id] }) => ({ ...s, selectedUser: id }),
90
+ effects: ({ s, args: [id] }) => ({ ...s, selectedUserId: id }),
92
91
  // side effects (ie. other effects than state changes), using "actions" alias
93
92
  sideEffects: ({ a }) => {
94
93
  a.loadUser();
@@ -139,12 +138,12 @@ export function App(props: Props) => {
139
138
 
140
139
  # React hooks
141
140
 
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 |
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 |
148
147
 
149
148
  # Initial state
150
149
 
@@ -492,63 +491,234 @@ When a hook that creates / binds a store is mounted, the **onMount** option is c
492
491
  ```typescript
493
492
  import { createStore } from 'state-decorator';
494
493
 
494
+ // global store
495
495
  const store = createStore(getInitialState, actionsImpl, {
496
496
  onMount: ({ a }) => {
497
497
  a.loadList;
498
498
  },
499
499
  });
500
500
 
501
- // onMount is called when this component is mounted
501
+ store.init(initialProps);
502
+ ```
503
+
504
+ ```typescript
505
+ import { createStore } from 'state-decorator';
506
+
507
+ // local store
502
508
  function Container(p: ContainerProps) {
503
- useBindStore(store, p);
509
+ const store = useGetLocalStore(getInitialState, actionsImpl, p, {
510
+ onMount: ({ a }) => {
511
+ a.loadList;
512
+ },
513
+ });
504
514
  }
505
515
  ```
506
516
 
507
517
  If an initial action is launched each time a property changes, consider using the **onMount** flag of the **onPropsChange** entry.
508
518
 
509
- ## State sharing and slices
519
+ # Global and Local Stores VS State sharing
510
520
 
511
- - Declare a store and bind it to a component using _useStore_ or _useBindStore_ hooks.
512
- - Deeper in the tree component, use _useStoreSlice_ to get a store slice.
513
- - Component using slices will be refreshed only if their store slice changes.
521
+ There are two ways to share state and actions:
514
522
 
515
- ```typescript
516
- import React from 'react';
517
- import { useStore, useStoreSlice, StoreActions } from 'state-decorator';
523
+ - Using regular props.
524
+ - Using state slices.
518
525
 
519
- // Declare typings & actions as above
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.
520
533
 
521
- // Create a store
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.
522
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.
545
+
546
+ ### Global store
547
+
548
+ ```typescript
549
+ // Declare typings & actions as above
550
+
551
+ // Create a store in a dedicated file, named GlobalStore.ts for example
523
552
  export const store = createStore(getInitialState, userAppActions);
524
553
 
525
- // Bind to react component
554
+ //----------------------
555
+
556
+ // In another file
557
+ import React, { memo } from 'react';
558
+ import { useStore } from 'state-decorator';
559
+ import store from './GlobalStore';
526
560
 
527
- export function Container(prop: Props) {
561
+ // Bind to react component
562
+ // Each time the store state changes, a re-render is done.
563
+ export function Container(props: Props) {
528
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
+ }
529
572
 
530
- // or
531
- // useBindStore(store, props);
532
- // if you are not interested in getting the state here
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
+ });
533
578
 
534
- return <div />;
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
+ );
535
602
  }
536
603
 
537
- // Components deeper in the component tree will be refreshed if, and only if,
538
- // slice is changed (here: text property)
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
539
616
 
540
- export function SubComponent2() {
541
- const s = useStoreSlice(store, ['text']);
542
- return <div>{s.text}</div>;
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
+ );
543
648
  }
544
649
 
545
- export function SubComponent() {
546
- const s = useStoreSlice(store, (s) => ({ text: s.text }));
547
- return <div>{s.text}</div>;
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>;
548
660
  }
549
661
  ```
550
662
 
551
- [![Edit Slice](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/slices-v6-eg471?file=/src/SliceView.tsx)
663
+ ## Local Store
664
+
665
+ ```typescript
666
+ // ------- stores/MyStore.ts --------
667
+
668
+ import React from 'react';
669
+ import { useGetLocalStore, useStoreContextSlice } from 'state-decorator';
670
+
671
+ // Declare typings & actions as above
672
+
673
+ // Create a store context
674
+
675
+ type StoreContextProps = StoreApi<State, Actions, Props>;
676
+
677
+ export const StoreContext = createContext<StoreContextProps>(null);
678
+
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
+ }
686
+
687
+ // In a container file ------------------------
688
+
689
+ import { StoreContextProvider } from 'stores/MyStore';
690
+
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
+ );
699
+ }
700
+
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']);
713
+
714
+ return <div>{title}</div>;
715
+ }
716
+
717
+ function Subtitle(props: { subtitle: string }) {
718
+ const { subtitle } = useStoreContextSlice(StoreContext, ['subtitle']);
719
+ return <div>{subtitle}</div>;
720
+ }
721
+ ```
552
722
 
553
723
  # Global configuration
554
724
 
@@ -571,7 +741,7 @@ setGlobalConfig({
571
741
  notifyError: undefined,
572
742
  // Notification function injected in side effects action context (success and error) to notify warning
573
743
  notifyWarning: undefined,
574
- // Function called to return common error message if error message is not provided or error not managed in action
744
+ // Function called to return common error message if error message is not provided or error not managed in action. To override locally in action, provide a function that returns _null_.
575
745
  getErrorMessage: undefined,
576
746
  });
577
747
  ```
@@ -812,42 +982,138 @@ function Container(props: Props) {
812
982
  # Unit testing
813
983
 
814
984
  - The state decorator provides a testing framework for stores.
815
- - The base principle is that a mock store is immutable. A new store is created is setting state/props. It allows to share mock store across tests.
816
- - On each test, reuse/setup store or store action and:
817
- - call the action and test state, side effect calls,...
818
- - calls have no effects on mock store
819
- - test prop changes
985
+ - The base principle is that a mock store is immutable:
986
+ - a new store is created each time state or props are set.
987
+ - it allows to share mock store across tests.
988
+ - The API is chainable: functions are returning a mock store, excepting test functions.
989
+ - On a mock store, we can test the store internals after initialiation, after inbound props have changed, after actions are called.
820
990
 
821
991
  ## Getting started
822
992
 
993
+ 1. Create and setup a mock store (_createMockFromStore_)
994
+ 2. Test store after initialization (_onInit_)
995
+ 3. Test store after props changes (_onPropsChange_)
996
+ 4. Test each action:
997
+ 1. Get mock store action (_getAction_)
998
+ 2. setup store state/prop (_setPartialState_, _setPartialProps_)
999
+ 3. if action is asynchronous, you can mock promise (_promiseResolves_, _promiseRejects_)
1000
+ 4. call action with params (_call_)
1001
+ 5. await response to test state/actions called...
1002
+
1003
+ ## Example
1004
+
1005
+ The store definition is available at [TodoStore.ts](https://github.com/GlobalSport/state-decorator/blob/develop/tests/fixtures/TodoStore.ts).
1006
+
1007
+ The test is available at [Todo.test.ts](https://github.com/GlobalSport/state-decorator/blob/develop/tests/Todo.test.ts).
1008
+
823
1009
  ```typescript
824
1010
  import { createMockFromStore } from 'state-decorator/test';
825
1011
 
826
- import { todoStore, TodoItem } from './Todos';
1012
+ import todoStore from './fixtures/TodoStore';
827
1013
 
828
1014
  describe('Todo', () => {
829
1015
  // create a mock store and setup initial state
830
- // state / props can be set in full form or partial form
831
1016
  // this store can be shared accross tests because it is immutable
832
- const store = createMockFromStore(todoStore, {}).setPartialState({
833
- idCount: 3,
834
- todoIds: ['id', 'id2'],
835
- todoMap: {
836
- id: {
837
- id: 'id',
838
- completed: true,
839
- title: 'todo',
840
- },
841
- id2: {
842
- id: 'id2',
843
- completed: false,
844
- title: 'todo 2',
1017
+ const store = createMockFromStore(todoStore)
1018
+ // or
1019
+ // const store = createMockStore(getInitialState, actions, props, options)
1020
+ // initial state / props can be set in full form or partial form
1021
+ .setPartialState({
1022
+ idCount: 3,
1023
+ todoIds: ['id', 'id2'],
1024
+ todoMap: {
1025
+ id: {
1026
+ id: 'id',
1027
+ completed: true,
1028
+ title: 'todo',
1029
+ },
1030
+ id2: {
1031
+ id: 'id2',
1032
+ completed: false,
1033
+ title: 'todo 2',
1034
+ },
845
1035
  },
846
- },
1036
+ });
1037
+
1038
+ describe('Initialization', () => {
1039
+ it('if not initial todo => load list', () => {
1040
+ store
1041
+ .setPartialState({
1042
+ todoIds: [],
1043
+ todoMap: {},
1044
+ })
1045
+ .onInit({ initialTodos: null })
1046
+ .test(({ state, actions }) => {
1047
+ // test state after initialization, ie:
1048
+ // - options.onPropsChange with onMount: true
1049
+ // - options.onMount
1050
+ expect(state.todoIds).toEqual([]);
1051
+ expect(state.todoMap).toEqual({});
1052
+
1053
+ // test derived state
1054
+ expect(state.todos).toEqual([]);
1055
+
1056
+ // test side effects also
1057
+ expect(actions.loadRemoteList).toHaveBeenCalled();
1058
+ });
1059
+ });
1060
+
1061
+ it('initial todos => setup state and do not load list', () => {
1062
+ store
1063
+ .setPartialState({
1064
+ todoIds: [],
1065
+ todoMap: {},
1066
+ })
1067
+ .onInit({
1068
+ initialTodos: [
1069
+ {
1070
+ id: 'item1',
1071
+ title: 'Item 1',
1072
+ completed: false,
1073
+ },
1074
+ ],
1075
+ })
1076
+ .test(({ state, actions }) => {
1077
+ // test state after initialization, ie:
1078
+ // - options.onPropsChange with onMount: true
1079
+ // - options.onMount
1080
+ expect(state.todoIds).toEqual(['item1']);
1081
+ expect(state.todoMap).toEqual({
1082
+ item1: {
1083
+ id: 'item1',
1084
+ title: 'Item 1',
1085
+ completed: false,
1086
+ },
1087
+ });
1088
+
1089
+ // test derived state
1090
+ expect(state.todos).toEqual([
1091
+ {
1092
+ id: 'item1',
1093
+ title: 'Item 1',
1094
+ completed: false,
1095
+ },
1096
+ ]);
1097
+
1098
+ // test side effects also
1099
+ expect(actions.loadRemoteList).not.toHaveBeenCalled();
1100
+ });
1101
+ });
1102
+ });
1103
+
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');
847
1111
  });
848
1112
 
849
1113
  it('onCreate', async () => {
850
- await store
1114
+ const {
1115
+ state: { todoIds, todoMap, todos },
1116
+ } = await store
851
1117
  // set partial state and return new store, source store is untouched
852
1118
  .setPartialState({
853
1119
  newTitle: 'new todo',
@@ -855,37 +1121,69 @@ describe('Todo', () => {
855
1121
  // get a mock action that can be shared too !
856
1122
  .getAction('onCreate')
857
1123
  // Invokes action. It has no side effect on state of mock action
858
- .call()
859
- // always return a promise whatever if the action is asynchronous or not
860
- .then(({ state: { todoIds, todoMap } }) => {
861
- // assert state
862
- expect(todoIds).toEqual(['id', 'id2', 'id3']);
863
- expect(todoMap).toEqual({
864
- id: {
865
- id: 'id',
866
- completed: true,
867
- title: 'todo',
868
- },
869
- id2: {
870
- id: 'id2',
871
- completed: false,
872
- title: 'todo 2',
873
- },
874
- id3: {
875
- id: 'id3',
876
- completed: false,
877
- title: 'new todo',
878
- },
879
- });
880
- });
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
+ ]);
881
1174
  });
882
1175
 
883
- it('onToggle', async () => {
884
- // this mock action is shared
1176
+ describe('onToggle', () => {
1177
+ // this mock action is shared in two tests but they can run in parallel
885
1178
  const onToggle = store.getAction('onToggle');
886
1179
 
887
- // arguments of call() depend on the Store action signature, use auto completion!
888
- await onToggle.call('id2').then(({ state: { todoIds, todoMap } }) => {
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
+
889
1187
  expect(todoIds).toEqual(['id', 'id2']);
890
1188
  expect(todoMap).toEqual({
891
1189
  id: {
@@ -899,9 +1197,18 @@ describe('Todo', () => {
899
1197
  title: 'todo 2',
900
1198
  },
901
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();
902
1204
  });
903
1205
 
904
- await onToggle.call('id').then(({ state: { todoIds, todoMap } }) => {
1206
+ it('toggle true => false', async () => {
1207
+ const {
1208
+ state: { todoIds, todoMap },
1209
+ actions,
1210
+ } = await onToggle.call('id');
1211
+
905
1212
  expect(todoIds).toEqual(['id', 'id2']);
906
1213
  expect(todoMap).toEqual({
907
1214
  id: {
@@ -915,53 +1222,103 @@ describe('Todo', () => {
915
1222
  title: 'todo 2',
916
1223
  },
917
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();
918
1229
  });
919
1230
  });
920
1231
 
921
1232
  // Another store / action
922
- it('getList', async () => {
923
- await store
924
- .getAction('getList')
925
- // override default promise result
926
- .promiseResolves([])
927
- // simulate error
928
- //.promiseRejects(new Error('boom'))
929
- .call()
930
- .then((res) => {
931
- // if there are some side effects calling store action, you test them
932
- // as mock actions are injected instead of real actions.
933
- expect(res.actions.sideEffectAction).toHaveBeenCalledWith('sideEffect');
934
- expect(res.state).toEqual({
935
- // state...
936
- });
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
+ },
937
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
+ });
938
1268
  });
939
1269
 
940
- // Another store
1270
+ // Test effects and side effect when props are changing
1271
+ //
1272
+ // Here:
1273
+ // export const todoOptions: StoreOptions<State, Actions, Props, DerivedState> = {
1274
+ // onPropsChange: [
1275
+ // {
1276
+ // getDeps: (p) => [p.initialTodos],
1277
+ // effects: ({ s, p }) => ({ ...s, ...splitList(p.initialTodos) }),
1278
+ // onMount: true,
1279
+ // },
1280
+ // ],
1281
+ // };
941
1282
  it('onPropsChange', () => {
942
1283
  store
943
1284
  // onPropsChange allows to test prop change, no side effect on original store
944
1285
  // to create a new store with new props, use setProps or setPartialProps
945
1286
  .onPropsChange({
946
- prop: 'new_prop',
1287
+ initialTodos: [
1288
+ {
1289
+ id: 'item1',
1290
+ title: 'Item 1',
1291
+ completed: false,
1292
+ },
1293
+ ],
947
1294
  })
948
- .test((res) => {
949
- // test side effects
950
- expect(res.actions.sideEffectAction).toHaveBeenCalledWith('sideEffect');
951
- // test store props
952
- expect(res.props.prop).toEqual('new_prop');
953
- //
954
- expect(res.state).toEqual({
955
- prop1: 'new_prop',
956
- derived: '', // derived state is updated
1295
+ .test(({ state, actions }) => {
1296
+ // test state after prop has changed
1297
+ expect(state.todoIds).toEqual(['item1']);
1298
+ expect(state.todoMap).toEqual({
1299
+ item1: {
1300
+ id: 'item1',
1301
+ title: 'Item 1',
1302
+ completed: false,
1303
+ },
957
1304
  });
1305
+
1306
+ // test derived state after prop has changed
1307
+ expect(state.todos).toEqual([
1308
+ {
1309
+ id: 'item1',
1310
+ title: 'Item 1',
1311
+ completed: false,
1312
+ },
1313
+ ]);
1314
+
1315
+ // test side effects also
1316
+ expect(actions.loadRemoteList).not.toHaveBeenCalled();
958
1317
  });
959
1318
  });
960
1319
  });
961
1320
  ```
962
1321
 
963
- [![Edit Todo](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/todo-forked-ml0z6?file=/src/TodoApp.tsx)
964
-
965
1322
  ## Error testing
966
1323
 
967
1324
  ### Managed error
@@ -1083,10 +1440,9 @@ TypeError: Cannot set property 'crash' of null
1083
1440
  ### MockStore
1084
1441
 
1085
1442
  | Function | Arguments | Returns | Description |
1086
- | --------------- | ------------------------------------ | ------------------- | ------------------------------------------------------------------------------------------- |
1443
+ | --------------- | ------------------------------------ | ------------------- | ------------------------------------------------------------------------------------------- | --- |
1087
1444
  | getAction | actionName: keyof A | MockStoreAction | Get a mock action instance |
1088
- | init | props?: Partial<P> | MockResult | Test store initialization (onPropsChanged marked onMount + onMount) |
1089
- | onMount | props: P | MockResult | Test onMount. Does not change internal props. |
1445
+ | onInit | props?: Partial<P> | MockResult | Test store initialization (onPropsChanged marked onMount + onMount) | |
1090
1446
  | onPropsChange | props: P, init?: boolean | MockResult | Test prop changes. Does not change internal props. Init allows to test onMount prop changes |
1091
1447
  | setPartialProps | props: Partial<P> | new mock store | Create a new store with the merge of current props and partial props |
1092
1448
  | setPartialState | state: Partial<S> | new mock store | Create a new store with the merge of current state and partial state |