Skip to content

Commit

Permalink
feat: Shallow equality function as 2nd argument for useStoreState() (#…
Browse files Browse the repository at this point in the history
…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 <[email protected]>
  • Loading branch information
joelmoss and ctrlplusb committed Jan 17, 2020
1 parent 90dec92 commit 01f5ac5
Show file tree
Hide file tree
Showing 7 changed files with 132 additions and 7 deletions.
3 changes: 2 additions & 1 deletion index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -655,6 +655,7 @@ export function reducer<State>(state: ReduxReducer<State>): Reducer<State>;
*/
export function useStoreState<StoreState extends State<any>, Result>(
mapState: (state: StoreState) => Result,
equalityFn?: (prev: Result, next: Result) => boolean,
): Result;

/**
Expand Down Expand Up @@ -725,7 +726,7 @@ export function createTypedHooks<StoreModel extends Object = {}>(): {
useStoreDispatch: () => Dispatch<StoreModel>;
useStoreState: <Result>(
mapState: (state: State<StoreModel>) => Result,
dependencies?: Array<any>,
equalityFn?: (prev: Result, next: Result) => boolean,
) => Result;
useStore: () => Store<StoreModel>;
};
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,6 @@
"prop-types": "^15.6.2",
"redux": "^4.0.5",
"redux-thunk": "^2.3.0",
"shallowequal": "^1.1.0",
"symbol-observable": "^1.2.0",
"ts-toolbelt": "^6.1.6"
},
Expand Down Expand Up @@ -103,6 +102,7 @@
"rollup-plugin-node-resolve": "^5.2.0",
"rollup-plugin-replace": "^2.2.0",
"rollup-plugin-uglify": "^6.0.4",
"shallowequal": "^1.1.0",
"title-case": "^3.0.2",
"typescript": "3.7.5",
"typings-tester": "^0.3.2"
Expand Down
2 changes: 0 additions & 2 deletions rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ const baseConfig = {
'react',
'redux',
'redux-thunk',
'shallowequal',
],
input: 'src/index.js',
output: {
Expand Down Expand Up @@ -58,7 +57,6 @@ const baseConfig = {

const commonUMD = config =>
produce(config, draft => {
draft.external.splice(draft.external.indexOf('shallowequal'), 1);
draft.output.format = 'umd';
draft.output.globals = {
debounce: 'debounce',
Expand Down
10 changes: 10 additions & 0 deletions src/__tests__/typescript/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,13 @@ let actionNoPayload = typedHooks.useStoreActions(
actions => actions.actionNoPayload,
);
actionNoPayload();

typedHooks.useStoreState(
state => ({ num: state.stateNumber, str: state.stateString }),
(prev, next) => {
prev.num += 1;
// typings:expect-error
prev.num += 'foo';
return prev.num === next.num;
},
);
65 changes: 65 additions & 0 deletions src/__tests__/use-store-state.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { render, fireEvent } from '@testing-library/react';
import shallowEqual from 'shallowequal';
import { mockConsole } from './utils';
import {
action,
Expand Down Expand Up @@ -281,3 +282,67 @@ test('multiple hooks receive state update in same render cycle', () => {
expect(getByTestId('items').textContent).toBe('foo');
expect(getByTestId('count').textContent).toBe('1');
});

test('equality function', () => {
// arrange
const store = createStore({
count: 1,
firstName: null,
lastName: null,
updateFirstName: action((state, payload) => {
state.firstName = payload;
}),
updateLastName: action((state, payload) => {
state.lastName = payload;
}),
});

const renderSpy = jest.fn();

function App() {
const { count, firstName } = useStoreState(
state => ({
count: state.count,
firstName: state.firstName,
}),
shallowEqual,
);
renderSpy();
return (
<>
<span data-testid="count">{count}</span>
<span data-testid="name">{firstName}</span>
</>
);
}

const { getByTestId } = render(
<StoreProvider store={store}>
<App />
</StoreProvider>,
);

// assert
expect(renderSpy).toHaveBeenCalledTimes(1);
expect(getByTestId('count').textContent).toBe('1');
expect(getByTestId('name').textContent).toBe('');

// act
act(() => {
store.getActions().updateFirstName('joel');
});

// assert
expect(renderSpy).toHaveBeenCalledTimes(2);
expect(getByTestId('count').textContent).toBe('1');
expect(getByTestId('name').textContent).toBe('joel');

// act
act(() => {
store.getActions().updateLastName('moss');
});

// assert
expect(renderSpy).toHaveBeenCalledTimes(2);
expect(getByTestId('name').textContent).toBe('joel');
});
11 changes: 9 additions & 2 deletions src/hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ const useIsomorphicLayoutEffect =
typeof window !== 'undefined' ? useLayoutEffect : useEffect;

export function createStoreStateHook(Context) {
return function useStoreState(mapState) {
return function useStoreState(mapState, equalityFn) {
const store = useContext(Context);
const mapStateRef = useRef(mapState);
const stateRef = useRef();
Expand Down Expand Up @@ -57,9 +57,16 @@ export function createStoreStateHook(Context) {
const checkMapState = () => {
try {
const newState = mapStateRef.current(store.getState());
if (newState === stateRef.current) {

const isStateEqual =
typeof equalityFn === 'function'
? equalityFn(stateRef.current, newState)
: stateRef.current === newState;

if (isStateEqual) {
return;
}

stateRef.current = newState;
} catch (err) {
// see https://github.com/reduxjs/react-redux/issues/1179
Expand Down
46 changes: 45 additions & 1 deletion website/docs/docs/api/use-store-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,38 @@ const todos = useStoreState(state => state.todos.items);

## Arguments

- `mapState` (Function, required)
- `mapState` (Function, *required*)

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

- `state` (Object)

The state of your store.

- `equalityFn` (Function, *optional*)

Allows you to provide custom logic for determining whether the mapped state has changed.

```javascript
useStoreState(
state => state.user,
(prev, next) => prev.username === next.username
)
```

It receives the following arguments:

- `prev` (any)

The state that was previously mapped by your selector.

- `next` (any)

The newly mapped state that has been mapped by your selector.

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.


## Example

```javascript
Expand Down Expand Up @@ -133,3 +157,23 @@ function FixedOptionTwo() {
);
}
```
## Using the `shallowequal` package to support mapping multiple values
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.
```javascript
import { useStoreState } from 'easy-peasy';
import shallowEqual from 'shallowequal';
function MyComponent() {
const { item1, item2 } = useStoreState(
state => ({
item1: state.items.item1,
item2: state.items.item2,
}),
shallowEqual // 👈 we can just pass the reference as the function signature
// is compatible with what the "equalityFn" argument expects
)
}
```

0 comments on commit 01f5ac5

Please sign in to comment.