Skip to content

Commit 01f5ac5

Browse files
joelmossctrlplusb
andcommitted
feat: Shallow equality function as 2nd argument for useStoreState() (#354)
Currently. useStoreState() performs a strict equality check (`oldState === newState`) on its state, which is great for most cases. But sometimes you need to work with more complex state and return something that will always fail a strict check. This PR adds support for providing a custom equality function as follows: ```javascript const store = createStore({ count: 1, firstName: null, lastName: null }); // In your component const { count, firstName } = useStoreState( state => ({ count: state.count, firstName: state.firstName, }), (prevState, nextState) => { // perform some equality comparison here return shallowEqual(prevState, nextState) } ); ``` In the above case, if either the `count` or `firstName` state vars change, the equality check with be true, and the component will re-render. And if any other state is changed; for example, the `lastName`, the equality check will be true, and the component will not re-render. An exported `shallowEqual()` function is provided to allow you to run shallow equality checks: `useStoreState(map, shallowEqual)`. See #275 Co-authored-by: Sean Matheson <sean@ctrlplusb.com>
1 parent 90dec92 commit 01f5ac5

File tree

7 files changed

+132
-7
lines changed

7 files changed

+132
-7
lines changed

index.d.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -655,6 +655,7 @@ export function reducer<State>(state: ReduxReducer<State>): Reducer<State>;
655655
*/
656656
export function useStoreState<StoreState extends State<any>, Result>(
657657
mapState: (state: StoreState) => Result,
658+
equalityFn?: (prev: Result, next: Result) => boolean,
658659
): Result;
659660

660661
/**
@@ -725,7 +726,7 @@ export function createTypedHooks<StoreModel extends Object = {}>(): {
725726
useStoreDispatch: () => Dispatch<StoreModel>;
726727
useStoreState: <Result>(
727728
mapState: (state: State<StoreModel>) => Result,
728-
dependencies?: Array<any>,
729+
equalityFn?: (prev: Result, next: Result) => boolean,
729730
) => Result;
730731
useStore: () => Store<StoreModel>;
731732
};

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,6 @@
5151
"prop-types": "^15.6.2",
5252
"redux": "^4.0.5",
5353
"redux-thunk": "^2.3.0",
54-
"shallowequal": "^1.1.0",
5554
"symbol-observable": "^1.2.0",
5655
"ts-toolbelt": "^6.1.6"
5756
},
@@ -103,6 +102,7 @@
103102
"rollup-plugin-node-resolve": "^5.2.0",
104103
"rollup-plugin-replace": "^2.2.0",
105104
"rollup-plugin-uglify": "^6.0.4",
105+
"shallowequal": "^1.1.0",
106106
"title-case": "^3.0.2",
107107
"typescript": "3.7.5",
108108
"typings-tester": "^0.3.2"

rollup.config.js

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ const baseConfig = {
2222
'react',
2323
'redux',
2424
'redux-thunk',
25-
'shallowequal',
2625
],
2726
input: 'src/index.js',
2827
output: {
@@ -58,7 +57,6 @@ const baseConfig = {
5857

5958
const commonUMD = config =>
6059
produce(config, draft => {
61-
draft.external.splice(draft.external.indexOf('shallowequal'), 1);
6260
draft.output.format = 'umd';
6361
draft.output.globals = {
6462
debounce: 'debounce',

src/__tests__/typescript/hooks.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,13 @@ let actionNoPayload = typedHooks.useStoreActions(
6363
actions => actions.actionNoPayload,
6464
);
6565
actionNoPayload();
66+
67+
typedHooks.useStoreState(
68+
state => ({ num: state.stateNumber, str: state.stateString }),
69+
(prev, next) => {
70+
prev.num += 1;
71+
// typings:expect-error
72+
prev.num += 'foo';
73+
return prev.num === next.num;
74+
},
75+
);

src/__tests__/use-store-state.test.js

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import React from 'react';
44
import { act } from 'react-dom/test-utils';
55
import { render, fireEvent } from '@testing-library/react';
6+
import shallowEqual from 'shallowequal';
67
import { mockConsole } from './utils';
78
import {
89
action,
@@ -281,3 +282,67 @@ test('multiple hooks receive state update in same render cycle', () => {
281282
expect(getByTestId('items').textContent).toBe('foo');
282283
expect(getByTestId('count').textContent).toBe('1');
283284
});
285+
286+
test('equality function', () => {
287+
// arrange
288+
const store = createStore({
289+
count: 1,
290+
firstName: null,
291+
lastName: null,
292+
updateFirstName: action((state, payload) => {
293+
state.firstName = payload;
294+
}),
295+
updateLastName: action((state, payload) => {
296+
state.lastName = payload;
297+
}),
298+
});
299+
300+
const renderSpy = jest.fn();
301+
302+
function App() {
303+
const { count, firstName } = useStoreState(
304+
state => ({
305+
count: state.count,
306+
firstName: state.firstName,
307+
}),
308+
shallowEqual,
309+
);
310+
renderSpy();
311+
return (
312+
<>
313+
<span data-testid="count">{count}</span>
314+
<span data-testid="name">{firstName}</span>
315+
</>
316+
);
317+
}
318+
319+
const { getByTestId } = render(
320+
<StoreProvider store={store}>
321+
<App />
322+
</StoreProvider>,
323+
);
324+
325+
// assert
326+
expect(renderSpy).toHaveBeenCalledTimes(1);
327+
expect(getByTestId('count').textContent).toBe('1');
328+
expect(getByTestId('name').textContent).toBe('');
329+
330+
// act
331+
act(() => {
332+
store.getActions().updateFirstName('joel');
333+
});
334+
335+
// assert
336+
expect(renderSpy).toHaveBeenCalledTimes(2);
337+
expect(getByTestId('count').textContent).toBe('1');
338+
expect(getByTestId('name').textContent).toBe('joel');
339+
340+
// act
341+
act(() => {
342+
store.getActions().updateLastName('moss');
343+
});
344+
345+
// assert
346+
expect(renderSpy).toHaveBeenCalledTimes(2);
347+
expect(getByTestId('name').textContent).toBe('joel');
348+
});

src/hooks.js

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ const useIsomorphicLayoutEffect =
2020
typeof window !== 'undefined' ? useLayoutEffect : useEffect;
2121

2222
export function createStoreStateHook(Context) {
23-
return function useStoreState(mapState) {
23+
return function useStoreState(mapState, equalityFn) {
2424
const store = useContext(Context);
2525
const mapStateRef = useRef(mapState);
2626
const stateRef = useRef();
@@ -57,9 +57,16 @@ export function createStoreStateHook(Context) {
5757
const checkMapState = () => {
5858
try {
5959
const newState = mapStateRef.current(store.getState());
60-
if (newState === stateRef.current) {
60+
61+
const isStateEqual =
62+
typeof equalityFn === 'function'
63+
? equalityFn(stateRef.current, newState)
64+
: stateRef.current === newState;
65+
66+
if (isStateEqual) {
6167
return;
6268
}
69+
6370
stateRef.current = newState;
6471
} catch (err) {
6572
// see https://github.com/reduxjs/react-redux/issues/1179

website/docs/docs/api/use-store-state.md

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,38 @@ const todos = useStoreState(state => state.todos.items);
88

99
## Arguments
1010

11-
- `mapState` (Function, required)
11+
- `mapState` (Function, *required*)
1212

1313
The function that is used to resolve the piece of state that your component requires. The function will receive the following arguments:
1414

1515
- `state` (Object)
1616

1717
The state of your store.
1818

19+
- `equalityFn` (Function, *optional*)
20+
21+
Allows you to provide custom logic for determining whether the mapped state has changed.
22+
23+
```javascript
24+
useStoreState(
25+
state => state.user,
26+
(prev, next) => prev.username === next.username
27+
)
28+
```
29+
30+
It receives the following arguments:
31+
32+
- `prev` (any)
33+
34+
The state that was previously mapped by your selector.
35+
36+
- `next` (any)
37+
38+
The newly mapped state that has been mapped by your selector.
39+
40+
It should return `true` to indicate that there is no change between the prev/next mapped state, else `false`. If it returns `false` your component will be re-rendered with the most recently mapped state value.
41+
42+
1943
## Example
2044

2145
```javascript
@@ -133,3 +157,23 @@ function FixedOptionTwo() {
133157
);
134158
}
135159
```
160+
161+
## Using the `shallowequal` package to support mapping multiple values
162+
163+
You can utilise the [`shallowequal`](https://github.com/dashed/shallowequal) to support mapping multiple values out via an object. The `shallowequal` package will perform a shallow equality check of the prev/next mapped object.
164+
165+
```javascript
166+
import { useStoreState } from 'easy-peasy';
167+
import shallowEqual from 'shallowequal';
168+
169+
function MyComponent() {
170+
const { item1, item2 } = useStoreState(
171+
state => ({
172+
item1: state.items.item1,
173+
item2: state.items.item2,
174+
}),
175+
shallowEqual // 👈 we can just pass the reference as the function signature
176+
// is compatible with what the "equalityFn" argument expects
177+
)
178+
}
179+
```

0 commit comments

Comments
 (0)