From a69b4f484002bbf74e1ca4f7a0cab079b2713f50 Mon Sep 17 00:00:00 2001 From: Jonathan Ziller Date: Mon, 22 Apr 2019 05:15:08 +0200 Subject: [PATCH 01/15] add react hooks for accessing redux store state and dispatching redux actions (#1248) * add react hooks for accessing redux store state and dispatching redux actions * remove `useReduxContext` from public API * add `useRedux` hook * Preserve stack trace of errors inside store subscription callback Ported changes from react-redux-hooks-poc Note: the "transient errors" test seems flawed atm. * Alter test descriptions to use string names WebStorm won't recognize tests as runnable if `someFunc.name` is used as the `describe()` argument. --- package.json | 2 +- src/alternate-renderers.js | 19 ++- src/hooks/useActions.js | 74 +++++++++ src/hooks/useDispatch.js | 31 ++++ src/hooks/useRedux.js | 40 +++++ src/hooks/useReduxContext.js | 32 ++++ src/hooks/useSelector.js | 108 +++++++++++++ src/hooks/useStore.js | 23 +++ src/index.js | 19 ++- test/hooks/useActions.spec.js | 178 +++++++++++++++++++++ test/hooks/useDispatch.spec.js | 31 ++++ test/hooks/useRedux.spec.js | 51 ++++++ test/hooks/useReduxContext.spec.js | 26 +++ test/hooks/useSelector.spec.js | 247 +++++++++++++++++++++++++++++ 14 files changed, 878 insertions(+), 3 deletions(-) create mode 100644 src/hooks/useActions.js create mode 100644 src/hooks/useDispatch.js create mode 100644 src/hooks/useRedux.js create mode 100644 src/hooks/useReduxContext.js create mode 100644 src/hooks/useSelector.js create mode 100644 src/hooks/useStore.js create mode 100644 test/hooks/useActions.spec.js create mode 100644 test/hooks/useDispatch.spec.js create mode 100644 test/hooks/useRedux.spec.js create mode 100644 test/hooks/useReduxContext.spec.js create mode 100644 test/hooks/useSelector.spec.js diff --git a/package.json b/package.json index f9c87621d..5b6ba8648 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "build": "npm run build:commonjs && npm run build:es && npm run build:umd && npm run build:umd:min", "clean": "rimraf lib dist es coverage", "format": "prettier --write \"{src,test}/**/*.{js,ts}\" index.d.ts \"docs/**/*.md\"", - "lint": "eslint src test/utils test/components", + "lint": "eslint src test/utils test/components test/hooks", "prepare": "npm run clean && npm run build", "pretest": "npm run lint", "test": "jest", diff --git a/src/alternate-renderers.js b/src/alternate-renderers.js index adc443004..7bfa04e50 100644 --- a/src/alternate-renderers.js +++ b/src/alternate-renderers.js @@ -3,9 +3,26 @@ import connectAdvanced from './components/connectAdvanced' import { ReactReduxContext } from './components/Context' import connect from './connect/connect' +import { useActions } from './hooks/useActions' +import { useDispatch } from './hooks/useDispatch' +import { useRedux } from './hooks/useRedux' +import { useSelector } from './hooks/useSelector' +import { useStore } from './hooks/useStore' + import { getBatch } from './utils/batch' // For other renderers besides ReactDOM and React Native, use the default noop batch function const batch = getBatch() -export { Provider, connectAdvanced, ReactReduxContext, connect, batch } +export { + Provider, + connectAdvanced, + ReactReduxContext, + connect, + batch, + useActions, + useDispatch, + useRedux, + useSelector, + useStore +} diff --git a/src/hooks/useActions.js b/src/hooks/useActions.js new file mode 100644 index 000000000..4c924f509 --- /dev/null +++ b/src/hooks/useActions.js @@ -0,0 +1,74 @@ +import { bindActionCreators } from 'redux' +import invariant from 'invariant' +import { useDispatch } from './useDispatch' +import { useMemo } from 'react' + +/** + * A hook to bind action creators to the redux store's `dispatch` function + * similar to how redux's `bindActionCreators` works. + * + * Supports passing a single action creator, an array/tuple of action + * creators, or an object of action creators. + * + * Any arguments passed to the created callbacks are passed through to + * the your functions. + * + * This hook takes a dependencies array as an optional second argument, + * which when passed ensures referential stability of the created callbacks. + * + * @param {Function|Function[]|Object.} actions the action creators to bind + * @param {any[]} deps (optional) dependencies array to control referential stability + * + * @returns {Function|Function[]|Object.} callback(s) bound to store's `dispatch` function + * + * Usage: + * +```jsx +import React from 'react' +import { useActions } from 'react-redux' + +const increaseCounter = ({ amount }) => ({ + type: 'increase-counter', + amount, +}) + +export const CounterComponent = ({ value }) => { + // supports passing an object of action creators + const { increaseCounterByOne, increaseCounterByTwo } = useActions({ + increaseCounterByOne: () => increaseCounter(1), + increaseCounterByTwo: () => increaseCounter(2), + }, []) + + // supports passing an array/tuple of action creators + const [increaseCounterByThree, increaseCounterByFour] = useActions([ + () => increaseCounter(3), + () => increaseCounter(4), + ], []) + + // supports passing a single action creator + const increaseCounterBy5 = useActions(() => increaseCounter(5), []) + + // passes through any arguments to the callback + const increaseCounterByX = useActions(x => increaseCounter(x), []) + + return ( +
+ {value} + +
+ ) +} +``` + */ +export function useActions(actions, deps) { + invariant(actions, `You must pass actions to useActions`) + + const dispatch = useDispatch() + return useMemo(() => { + if (Array.isArray(actions)) { + return actions.map(a => bindActionCreators(a, dispatch)) + } + + return bindActionCreators(actions, dispatch) + }, deps) +} diff --git a/src/hooks/useDispatch.js b/src/hooks/useDispatch.js new file mode 100644 index 000000000..7331ea351 --- /dev/null +++ b/src/hooks/useDispatch.js @@ -0,0 +1,31 @@ +import { useStore } from './useStore' + +/** + * A hook to access the redux `dispatch` function. Note that in most cases where you + * might want to use this hook it is recommended to use `useActions` instead to bind + * action creators to the `dispatch` function. + * + * @returns {any} redux store's `dispatch` function + * + * Usage: + * +```jsx +import React, { useCallback } from 'react' +import { useReduxDispatch } from 'react-redux' + +export const CounterComponent = ({ value }) => { + const dispatch = useDispatch() + const increaseCounter = useCallback(() => dispatch({ type: 'increase-counter' }), []) + return ( +
+ {value} + +
+ ) +} +``` + */ +export function useDispatch() { + const store = useStore() + return store.dispatch +} diff --git a/src/hooks/useRedux.js b/src/hooks/useRedux.js new file mode 100644 index 000000000..58f7f4ee9 --- /dev/null +++ b/src/hooks/useRedux.js @@ -0,0 +1,40 @@ +import { useSelector } from './useSelector' +import { useActions } from './useActions' + +/** + * A hook to access the redux store's state and to bind action creators to + * the store's dispatch function. In essence, this hook is a combination of + * `useSelector` and `useActions`. + * + * @param {Function} selector the selector function + * @param {Function|Function[]|Object.} actions the action creators to bind + * + * @returns {[any, any]} a tuple of the selected state and the bound action creators + * + * Usage: + * +```jsx +import React from 'react' +import { useRedux } from 'react-redux' + +export const CounterComponent = () => { + const [counter, { inc1, inc }] = useRedux(state => state.counter, { + inc1: () => ({ type: 'inc1' }), + inc: amount => ({ type: 'inc', amount }), + }) + + return ( + <> +
+ {counter} +
+ + + + ) +} +``` + */ +export function useRedux(selector, actions) { + return [useSelector(selector), useActions(actions)] +} diff --git a/src/hooks/useReduxContext.js b/src/hooks/useReduxContext.js new file mode 100644 index 000000000..e580b0eeb --- /dev/null +++ b/src/hooks/useReduxContext.js @@ -0,0 +1,32 @@ +import { useContext } from 'react' +import invariant from 'invariant' +import { ReactReduxContext } from '../components/Context' + +/** + * A hook to access the value of the `ReactReduxContext`. This is a low-level + * hook that you should usually not need to call directly. + * + * @returns {any} the value of the `ReactReduxContext` + * + * Usage: + * +```jsx +import React from 'react' +import { useReduxContext } from 'react-redux' + +export const CounterComponent = ({ value }) => { + const { store } = useReduxContext() + return
{store.getState()}
+} +``` + */ +export function useReduxContext() { + const contextValue = useContext(ReactReduxContext) + + invariant( + contextValue, + 'could not find react-redux context value; please ensure the component is wrapped in a ' + ) + + return contextValue +} diff --git a/src/hooks/useSelector.js b/src/hooks/useSelector.js new file mode 100644 index 000000000..397a93c9e --- /dev/null +++ b/src/hooks/useSelector.js @@ -0,0 +1,108 @@ +import { useReducer, useRef, useEffect, useMemo, useLayoutEffect } from 'react' +import invariant from 'invariant' +import { useReduxContext } from './useReduxContext' +import shallowEqual from '../utils/shallowEqual' +import Subscription from '../utils/Subscription' + +// React currently throws a warning when using useLayoutEffect on the server. +// To get around it, we can conditionally useEffect on the server (no-op) and +// useLayoutEffect in the browser. We need useLayoutEffect to ensure the store +// subscription callback always has the selector from the latest render commit +// available, otherwise a store update may happen between render and the effect, +// which may cause missed updates; we also must ensure the store subscription +// is created synchronously, otherwise a store update may occur before the +// subscription is created and an inconsistent state may be observed +const useIsomorphicLayoutEffect = + typeof window !== 'undefined' ? useLayoutEffect : useEffect + +/** + * A hook to access the redux store's state. This hook takes a selector function + * as an argument. The selector is called with the store state. + * + * @param {Function} selector the selector function + * + * @returns {any} the selected state + * + * Usage: + * +```jsx +import React from 'react' +import { useSelector } from 'react-redux' + +export const CounterComponent = () => { + const counter = useSelector(state => state.counter) + return
{counter}
+} +``` + */ +export function useSelector(selector) { + invariant(selector, `You must pass a selector to useSelectors`) + + const { store, subscription: contextSub } = useReduxContext() + const [, forceRender] = useReducer(s => s + 1, 0) + + const subscription = useMemo(() => new Subscription(store, contextSub), [ + store, + contextSub + ]) + + const latestSubscriptionCallbackError = useRef() + const latestSelector = useRef(selector) + + let selectedState = undefined + + try { + selectedState = latestSelector.current(store.getState()) + } catch (err) { + let errorMessage = `An error occured while selecting the store state: ${ + err.message + }.` + + if (latestSubscriptionCallbackError.current) { + errorMessage += `\nThe error may be correlated with this previous error:\n${ + latestSubscriptionCallbackError.current.stack + }\n\nOriginal stack trace:` + } + + throw new Error(errorMessage) + } + + const latestSelectedState = useRef(selectedState) + + useIsomorphicLayoutEffect(() => { + latestSelector.current = selector + latestSelectedState.current = selectedState + latestSubscriptionCallbackError.current = undefined + }) + + useIsomorphicLayoutEffect(() => { + function checkForUpdates() { + try { + const newSelectedState = latestSelector.current(store.getState()) + + if (shallowEqual(newSelectedState, latestSelectedState.current)) { + return + } + + latestSelectedState.current = newSelectedState + } catch (err) { + // we ignore all errors here, since when the component + // is re-rendered, the selectors are called again, and + // will throw again, if neither props nor store state + // changed + latestSubscriptionCallbackError.current = err + } + + forceRender({}) + } + + subscription.onStateChange = checkForUpdates + subscription.trySubscribe() + + checkForUpdates() + + return () => subscription.tryUnsubscribe() + }, [store, subscription]) + + return selectedState +} diff --git a/src/hooks/useStore.js b/src/hooks/useStore.js new file mode 100644 index 000000000..ff19882d2 --- /dev/null +++ b/src/hooks/useStore.js @@ -0,0 +1,23 @@ +import { useReduxContext } from './useReduxContext' + +/** + * A hook to access the redux store. + * + * @returns {any} the redux store + * + * Usage: + * +```jsx +import React from 'react' +import { useStore } from 'react-redux' + +export const CounterComponent = ({ value }) => { + const store = useStore() + return
{store.getState()}
+} +``` + */ +export function useStore() { + const { store } = useReduxContext() + return store +} diff --git a/src/index.js b/src/index.js index 8d6460fde..3431cb6b3 100644 --- a/src/index.js +++ b/src/index.js @@ -3,9 +3,26 @@ import connectAdvanced from './components/connectAdvanced' import { ReactReduxContext } from './components/Context' import connect from './connect/connect' +import { useActions } from './hooks/useActions' +import { useDispatch } from './hooks/useDispatch' +import { useRedux } from './hooks/useRedux' +import { useSelector } from './hooks/useSelector' +import { useStore } from './hooks/useStore' + import { setBatch } from './utils/batch' import { unstable_batchedUpdates as batch } from './utils/reactBatchedUpdates' setBatch(batch) -export { Provider, connectAdvanced, ReactReduxContext, connect, batch } +export { + Provider, + connectAdvanced, + ReactReduxContext, + connect, + batch, + useActions, + useDispatch, + useRedux, + useSelector, + useStore +} diff --git a/test/hooks/useActions.spec.js b/test/hooks/useActions.spec.js new file mode 100644 index 000000000..e87b80931 --- /dev/null +++ b/test/hooks/useActions.spec.js @@ -0,0 +1,178 @@ +import React from 'react' +import { createStore } from 'redux' +import * as rtl from 'react-testing-library' +import { Provider as ProviderMock, useActions } from '../../src/index.js' + +describe('React', () => { + describe('hooks', () => { + describe('useActions', () => { + let store + let dispatchedActions = [] + + beforeEach(() => { + const reducer = (state = 0, action) => { + dispatchedActions.push(action) + + if (action.type === 'inc1') { + return state + 1 + } + + if (action.type === 'inc') { + return state + action.amount + } + + return state + } + + store = createStore(reducer) + dispatchedActions = [] + }) + + afterEach(() => rtl.cleanup()) + + it('supports a single action creator', () => { + const Comp = () => { + const inc1 = useActions(() => ({ type: 'inc1' })) + + return ( + <> + - - ) -} -``` + * import React from 'react' + * import { useActions } from 'react-redux' + * + * const increaseCounter = amount => ({ + * type: 'increase-counter', + * amount, + * }) + * + * export const CounterComponent = ({ value }) => { + * // supports passing an object of action creators + * const { increaseCounterByOne, increaseCounterByTwo } = useActions({ + * increaseCounterByOne: () => increaseCounter(1), + * increaseCounterByTwo: () => increaseCounter(2), + * }, []) + * + * // supports passing an array/tuple of action creators + * const [increaseCounterByThree, increaseCounterByFour] = useActions([ + * () => increaseCounter(3), + * () => increaseCounter(4), + * ], []) + * + * // supports passing a single action creator + * const increaseCounterBy5 = useActions(() => increaseCounter(5), []) + * + * // passes through any arguments to the callback + * const increaseCounterByX = useActions(x => increaseCounter(x), []) + * + * return ( + *
+ * {value} + * + *
+ * ) + * } */ export function useActions(actions, deps) { invariant(actions, `You must pass actions to useActions`) diff --git a/src/hooks/useDispatch.js b/src/hooks/useDispatch.js index 7331ea351..cdef99c9b 100644 --- a/src/hooks/useDispatch.js +++ b/src/hooks/useDispatch.js @@ -4,26 +4,24 @@ import { useStore } from './useStore' * A hook to access the redux `dispatch` function. Note that in most cases where you * might want to use this hook it is recommended to use `useActions` instead to bind * action creators to the `dispatch` function. - * + * * @returns {any} redux store's `dispatch` function * - * Usage: + * @example * -```jsx -import React, { useCallback } from 'react' -import { useReduxDispatch } from 'react-redux' - -export const CounterComponent = ({ value }) => { - const dispatch = useDispatch() - const increaseCounter = useCallback(() => dispatch({ type: 'increase-counter' }), []) - return ( -
- {value} - -
- ) -} -``` + * import React, { useCallback } from 'react' + * import { useReduxDispatch } from 'react-redux' + * + * export const CounterComponent = ({ value }) => { + * const dispatch = useDispatch() + * const increaseCounter = useCallback(() => dispatch({ type: 'increase-counter' }), []) + * return ( + *
+ * {value} + * + *
+ * ) + * } */ export function useDispatch() { const store = useStore() diff --git a/src/hooks/useRedux.js b/src/hooks/useRedux.js index 58f7f4ee9..293abc8c3 100644 --- a/src/hooks/useRedux.js +++ b/src/hooks/useRedux.js @@ -2,38 +2,41 @@ import { useSelector } from './useSelector' import { useActions } from './useActions' /** - * A hook to access the redux store's state and to bind action creators to + * A hook to access the redux store's state and to bind action creators to * the store's dispatch function. In essence, this hook is a combination of * `useSelector` and `useActions`. - * + * + * Note that this hook does currently not allow to pass a dependencies array, + * so the passed selector and any created callbacks are not memoized. If you + * require memoization, please use `useActions` and `useSelector`. + * * @param {Function} selector the selector function * @param {Function|Function[]|Object.} actions the action creators to bind - * + * * @returns {[any, any]} a tuple of the selected state and the bound action creators * - * Usage: + * @example * -```jsx -import React from 'react' -import { useRedux } from 'react-redux' - -export const CounterComponent = () => { - const [counter, { inc1, inc }] = useRedux(state => state.counter, { - inc1: () => ({ type: 'inc1' }), - inc: amount => ({ type: 'inc', amount }), - }) - - return ( - <> -
- {counter} -
- - - - ) -} -``` + * import React from 'react' + * import { useRedux } from 'react-redux' + * import { RootState } from './store' + * + * export const CounterComponent = () => { + * const [counter, { inc1, inc }] = useRedux(state => state.counter, { + * inc1: () => ({ type: 'inc1' }), + * inc: amount => ({ type: 'inc', amount }), + * }) + * + * return ( + * <> + *
+ * {counter} + *
+ * + * + * + * ) + * } */ export function useRedux(selector, actions) { return [useSelector(selector), useActions(actions)] diff --git a/src/hooks/useReduxContext.js b/src/hooks/useReduxContext.js index e580b0eeb..903a11c44 100644 --- a/src/hooks/useReduxContext.js +++ b/src/hooks/useReduxContext.js @@ -5,20 +5,18 @@ import { ReactReduxContext } from '../components/Context' /** * A hook to access the value of the `ReactReduxContext`. This is a low-level * hook that you should usually not need to call directly. - * + * * @returns {any} the value of the `ReactReduxContext` * - * Usage: + * @example * -```jsx -import React from 'react' -import { useReduxContext } from 'react-redux' - -export const CounterComponent = ({ value }) => { - const { store } = useReduxContext() - return
{store.getState()}
-} -``` + * import React from 'react' + * import { useReduxContext } from 'react-redux' + * + * export const CounterComponent = ({ value }) => { + * const { store } = useReduxContext() + * return
{store.getState()}
+ * } */ export function useReduxContext() { const contextValue = useContext(ReactReduxContext) diff --git a/src/hooks/useSelector.js b/src/hooks/useSelector.js index 70fb70934..83e1666f9 100644 --- a/src/hooks/useSelector.js +++ b/src/hooks/useSelector.js @@ -22,24 +22,23 @@ const useIsomorphicLayoutEffect = * This hook takes a dependencies array as an optional second argument, * which when passed ensures referential stability of the selector (this is primarily * useful if you provide a selector that memoizes values). - * + * * @param {Function} selector the selector function * @param {any[]} deps (optional) dependencies array to control referential stability * of the selector - * + * * @returns {any} the selected state * - * Usage: + * @example * -```jsx -import React from 'react' -import { useSelector } from 'react-redux' - -export const CounterComponent = () => { - const counter = useSelector(state => state.counter) - return
{counter}
-} -``` + * import React from 'react' + * import { useSelector } from 'react-redux' + * import { RootState } from './store' + * + * export const CounterComponent = () => { + * const counter = useSelector(state => state.counter, []) + * return
{counter}
+ * } */ export function useSelector(selector, deps) { invariant(selector, `You must pass a selector to useSelectors`) diff --git a/src/hooks/useStore.js b/src/hooks/useStore.js index ff19882d2..16cca17a4 100644 --- a/src/hooks/useStore.js +++ b/src/hooks/useStore.js @@ -2,20 +2,18 @@ import { useReduxContext } from './useReduxContext' /** * A hook to access the redux store. - * + * * @returns {any} the redux store * - * Usage: + * @example * -```jsx -import React from 'react' -import { useStore } from 'react-redux' - -export const CounterComponent = ({ value }) => { - const store = useStore() - return
{store.getState()}
-} -``` + * import React from 'react' + * import { useStore } from 'react-redux' + * + * export const ExampleComponent = () => { + * const store = useStore() + * return
{store.getState()}
+ * } */ export function useStore() { const { store } = useReduxContext() From 756ae497a8ed4cbcb7f92a94f8bb90590f5f1f09 Mon Sep 17 00:00:00 2001 From: Michael Peyper Date: Fri, 26 Apr 2019 01:38:59 +1000 Subject: [PATCH 06/15] Use react-hooks-testing-library to test hooks (#1259) * Use react-hooks-testing-library to test hooks * Disable react/display-name rule with nested .eslintrc file --- package-lock.json | 21 +++++ package.json | 2 + test/hooks/.eslintrc | 5 ++ test/hooks/useActions.spec.js | 131 ++++++++--------------------- test/hooks/useDispatch.spec.js | 21 ++--- test/hooks/useRedux.spec.js | 39 +++------ test/hooks/useReduxContext.spec.js | 12 +-- test/hooks/useSelector.spec.js | 37 +++----- 8 files changed, 99 insertions(+), 169 deletions(-) create mode 100644 test/hooks/.eslintrc diff --git a/package-lock.json b/package-lock.json index d72df7a8f..806c6b00e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7178,11 +7178,32 @@ "scheduler": "^0.13.6" } }, + "react-hooks-testing-library": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/react-hooks-testing-library/-/react-hooks-testing-library-0.5.0.tgz", + "integrity": "sha512-qX4SA28pcCCf1Q23Gtl1VKqQk26pSPTEsdLtfJanDqm4oacT5wadL+e2Xypk/H+AOXN5kdZrWmXkt+hAaiNHgg==", + "dev": true, + "requires": { + "@babel/runtime": "^7.4.2" + } + }, "react-is": { "version": "16.8.6", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.8.6.tgz", "integrity": "sha512-aUk3bHfZ2bRSVFFbbeVS4i+lNPZr3/WM5jT2J5omUVV1zzcs1nAaf3l51ctA5FFvCRbhrH0bdAsRRQddFJZPtA==" }, + "react-test-renderer": { + "version": "16.8.6", + "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-16.8.6.tgz", + "integrity": "sha512-H2srzU5IWYT6cZXof6AhUcx/wEyJddQ8l7cLM/F7gDXYyPr4oq+vCIxJYXVGhId1J706sqziAjuOEjyNkfgoEw==", + "dev": true, + "requires": { + "object-assign": "^4.1.1", + "prop-types": "^15.6.2", + "react-is": "^16.8.6", + "scheduler": "^0.13.6" + } + }, "react-testing-library": { "version": "5.9.0", "resolved": "https://registry.npmjs.org/react-testing-library/-/react-testing-library-5.9.0.tgz", diff --git a/package.json b/package.json index cde6a6d1c..befbc307a 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,8 @@ "prettier": "^1.16.4", "react": "^16.8.6", "react-dom": "^16.8.6", + "react-hooks-testing-library": "^0.5.0", + "react-test-renderer": "^16.8.6", "react-testing-library": "^5.9.0", "redux": "^4.0.1", "rimraf": "^2.6.3", diff --git a/test/hooks/.eslintrc b/test/hooks/.eslintrc new file mode 100644 index 000000000..1de5b1a79 --- /dev/null +++ b/test/hooks/.eslintrc @@ -0,0 +1,5 @@ +{ + "rules": { + "react/display-name": 0 + } +} diff --git a/test/hooks/useActions.spec.js b/test/hooks/useActions.spec.js index e87b80931..d0807ed44 100644 --- a/test/hooks/useActions.spec.js +++ b/test/hooks/useActions.spec.js @@ -1,6 +1,6 @@ import React from 'react' import { createStore } from 'redux' -import * as rtl from 'react-testing-library' +import { renderHook } from 'react-hooks-testing-library' import { Provider as ProviderMock, useActions } from '../../src/index.js' describe('React', () => { @@ -28,58 +28,29 @@ describe('React', () => { dispatchedActions = [] }) - afterEach(() => rtl.cleanup()) - it('supports a single action creator', () => { - const Comp = () => { - const inc1 = useActions(() => ({ type: 'inc1' })) - - return ( - <> - - * - * - * ) - * } - */ -export function useRedux(selector, actions) { - return [useSelector(selector), useActions(actions)] -} diff --git a/src/index.js b/src/index.js index 3431cb6b3..10b168806 100644 --- a/src/index.js +++ b/src/index.js @@ -5,7 +5,6 @@ import connect from './connect/connect' import { useActions } from './hooks/useActions' import { useDispatch } from './hooks/useDispatch' -import { useRedux } from './hooks/useRedux' import { useSelector } from './hooks/useSelector' import { useStore } from './hooks/useStore' @@ -22,7 +21,6 @@ export { batch, useActions, useDispatch, - useRedux, useSelector, useStore } diff --git a/test/hooks/useRedux.spec.js b/test/hooks/useRedux.spec.js deleted file mode 100644 index 53b272ee3..000000000 --- a/test/hooks/useRedux.spec.js +++ /dev/null @@ -1,40 +0,0 @@ -/*eslint-disable react/prop-types*/ - -import React from 'react' -import { createStore } from 'redux' -import { renderHook, act } from 'react-hooks-testing-library' -import { Provider as ProviderMock, useRedux } from '../../src/index.js' - -describe('React', () => { - describe('hooks', () => { - describe('useRedux', () => { - let store - - beforeEach(() => { - store = createStore(({ count } = { count: -1 }) => ({ - count: count + 1 - })) - }) - - it('selects the state and binds action creators', () => { - const { result } = renderHook( - () => - useRedux(s => s.count, { - inc: () => ({ type: '' }) - }), - { - wrapper: props => - } - ) - - expect(result.current[0]).toEqual(0) - - act(() => { - result.current[1].inc() - }) - - expect(result.current[0]).toEqual(1) - }) - }) - }) -}) From 921130abb03fc39f75ae5b79909c960303828407 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sun, 28 Apr 2019 12:48:48 -0400 Subject: [PATCH 09/15] 7.1.0-alpha.3 --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 806c6b00e..ee9937c8e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "react-redux", - "version": "7.1.0-alpha.1", + "version": "7.1.0-alpha.3", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index befbc307a..7a5ced855 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-redux", - "version": "7.1.0-alpha.1", + "version": "7.1.0-alpha.3", "description": "Official React bindings for Redux", "keywords": [ "react", From 54fd9ddf836202e7d092ba6c7a6fcc75c5cbdc28 Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Tue, 30 Apr 2019 21:47:12 -0700 Subject: [PATCH 10/15] Remove useActions --- src/alternate-renderers.js | 2 - src/hooks/useActions.js | 72 -------------------- src/index.js | 2 - test/hooks/useActions.spec.js | 119 ---------------------------------- 4 files changed, 195 deletions(-) delete mode 100644 src/hooks/useActions.js delete mode 100644 test/hooks/useActions.spec.js diff --git a/src/alternate-renderers.js b/src/alternate-renderers.js index 592eb3f41..c3a04a254 100644 --- a/src/alternate-renderers.js +++ b/src/alternate-renderers.js @@ -3,7 +3,6 @@ import connectAdvanced from './components/connectAdvanced' import { ReactReduxContext } from './components/Context' import connect from './connect/connect' -import { useActions } from './hooks/useActions' import { useDispatch } from './hooks/useDispatch' import { useSelector } from './hooks/useSelector' import { useStore } from './hooks/useStore' @@ -19,7 +18,6 @@ export { ReactReduxContext, connect, batch, - useActions, useDispatch, useSelector, useStore diff --git a/src/hooks/useActions.js b/src/hooks/useActions.js deleted file mode 100644 index 9321280cf..000000000 --- a/src/hooks/useActions.js +++ /dev/null @@ -1,72 +0,0 @@ -import { bindActionCreators } from 'redux' -import invariant from 'invariant' -import { useDispatch } from './useDispatch' -import { useMemo } from 'react' - -/** - * A hook to bind action creators to the redux store's `dispatch` function - * similar to how redux's `bindActionCreators` works. - * - * Supports passing a single action creator, an array/tuple of action - * creators, or an object of action creators. - * - * Any arguments passed to the created callbacks are passed through to - * your functions. - * - * This hook takes a dependencies array as an optional second argument, - * which when passed ensures referential stability of the created callbacks. - * - * @param {Function|Function[]|Object.} actions the action creators to bind - * @param {any[]} deps (optional) dependencies array to control referential stability - * - * @returns {Function|Function[]|Object.} callback(s) bound to store's `dispatch` function - * - * @example - * - * import React from 'react' - * import { useActions } from 'react-redux' - * - * const increaseCounter = amount => ({ - * type: 'increase-counter', - * amount, - * }) - * - * export const CounterComponent = ({ value }) => { - * // supports passing an object of action creators - * const { increaseCounterByOne, increaseCounterByTwo } = useActions({ - * increaseCounterByOne: () => increaseCounter(1), - * increaseCounterByTwo: () => increaseCounter(2), - * }, []) - * - * // supports passing an array/tuple of action creators - * const [increaseCounterByThree, increaseCounterByFour] = useActions([ - * () => increaseCounter(3), - * () => increaseCounter(4), - * ], []) - * - * // supports passing a single action creator - * const increaseCounterBy5 = useActions(() => increaseCounter(5), []) - * - * // passes through any arguments to the callback - * const increaseCounterByX = useActions(x => increaseCounter(x), []) - * - * return ( - *
- * {value} - * - *
- * ) - * } - */ -export function useActions(actions, deps) { - invariant(actions, `You must pass actions to useActions`) - - const dispatch = useDispatch() - return useMemo(() => { - if (Array.isArray(actions)) { - return actions.map(a => bindActionCreators(a, dispatch)) - } - - return bindActionCreators(actions, dispatch) - }, deps) -} diff --git a/src/index.js b/src/index.js index 10b168806..654d5e7fd 100644 --- a/src/index.js +++ b/src/index.js @@ -3,7 +3,6 @@ import connectAdvanced from './components/connectAdvanced' import { ReactReduxContext } from './components/Context' import connect from './connect/connect' -import { useActions } from './hooks/useActions' import { useDispatch } from './hooks/useDispatch' import { useSelector } from './hooks/useSelector' import { useStore } from './hooks/useStore' @@ -19,7 +18,6 @@ export { ReactReduxContext, connect, batch, - useActions, useDispatch, useSelector, useStore diff --git a/test/hooks/useActions.spec.js b/test/hooks/useActions.spec.js deleted file mode 100644 index d0807ed44..000000000 --- a/test/hooks/useActions.spec.js +++ /dev/null @@ -1,119 +0,0 @@ -import React from 'react' -import { createStore } from 'redux' -import { renderHook } from 'react-hooks-testing-library' -import { Provider as ProviderMock, useActions } from '../../src/index.js' - -describe('React', () => { - describe('hooks', () => { - describe('useActions', () => { - let store - let dispatchedActions = [] - - beforeEach(() => { - const reducer = (state = 0, action) => { - dispatchedActions.push(action) - - if (action.type === 'inc1') { - return state + 1 - } - - if (action.type === 'inc') { - return state + action.amount - } - - return state - } - - store = createStore(reducer) - dispatchedActions = [] - }) - - it('supports a single action creator', () => { - const { result } = renderHook( - () => useActions(() => ({ type: 'inc1' })), - { wrapper: props => } - ) - - result.current() - - expect(dispatchedActions).toEqual([{ type: 'inc1' }]) - }) - - it('supports an object of action creators', () => { - const { result } = renderHook( - () => - useActions({ - inc1: () => ({ type: 'inc1' }), - inc2: () => ({ type: 'inc', amount: 2 }) - }), - { wrapper: props => } - ) - - result.current.inc1() - result.current.inc2() - - expect(dispatchedActions).toEqual([ - { type: 'inc1' }, - { type: 'inc', amount: 2 } - ]) - }) - - it('supports an array of action creators', () => { - const { result } = renderHook( - () => - useActions([ - () => ({ type: 'inc1' }), - () => ({ type: 'inc', amount: 2 }) - ]), - { wrapper: props => } - ) - - result.current[0]() - result.current[1]() - - expect(dispatchedActions).toEqual([ - { type: 'inc1' }, - { type: 'inc', amount: 2 } - ]) - }) - - it('passes through arguments', () => { - const reducer = (state = 0, action) => { - dispatchedActions.push(action) - if (action.type === 'adjust') { - return action.isAdd ? state + action.amount : state - action.amount - } - - return state - } - - const store = createStore(reducer) - dispatchedActions = [] - - const { result } = renderHook( - () => - useActions({ - adjust: (amount, isAdd = true) => ({ - type: 'adjust', - amount, - isAdd - }) - }), - { wrapper: props => } - ) - - result.current.adjust(1) - result.current.adjust(2) - result.current.adjust(1, false) - - expect(dispatchedActions).toEqual([ - { type: 'adjust', amount: 1, isAdd: true }, - { type: 'adjust', amount: 2, isAdd: true }, - { type: 'adjust', amount: 1, isAdd: false } - ]) - }) - - // TODO: test for deps - }) - }) -}) From 06a674e733454b08bb4090f8bcb038a3a003e6aa Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Tue, 30 Apr 2019 22:29:28 -0700 Subject: [PATCH 11/15] 7.1.0-alpha.4 --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index ee9937c8e..83be93f56 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "react-redux", - "version": "7.1.0-alpha.3", + "version": "7.1.0-alpha.4", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 7a5ced855..d8cfc4d45 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-redux", - "version": "7.1.0-alpha.3", + "version": "7.1.0-alpha.4", "description": "Official React bindings for Redux", "keywords": [ "react", From a787aeeb5ab064e66ff168eedbb962d3397b378d Mon Sep 17 00:00:00 2001 From: Josep M Sobrepere Date: Mon, 13 May 2019 20:59:43 +0200 Subject: [PATCH 12/15] Remove deps of useSelector (#1272) --- src/hooks/useSelector.js | 14 +++++--------- test/hooks/useSelector.spec.js | 30 +----------------------------- 2 files changed, 6 insertions(+), 38 deletions(-) diff --git a/src/hooks/useSelector.js b/src/hooks/useSelector.js index 83e1666f9..8e40cfa10 100644 --- a/src/hooks/useSelector.js +++ b/src/hooks/useSelector.js @@ -24,8 +24,6 @@ const useIsomorphicLayoutEffect = * useful if you provide a selector that memoizes values). * * @param {Function} selector the selector function - * @param {any[]} deps (optional) dependencies array to control referential stability - * of the selector * * @returns {any} the selected state * @@ -36,11 +34,11 @@ const useIsomorphicLayoutEffect = * import { RootState } from './store' * * export const CounterComponent = () => { - * const counter = useSelector(state => state.counter, []) + * const counter = useSelector(state => state.counter) * return
{counter}
* } */ -export function useSelector(selector, deps) { +export function useSelector(selector) { invariant(selector, `You must pass a selector to useSelectors`) const { store, subscription: contextSub } = useReduxContext() @@ -51,15 +49,13 @@ export function useSelector(selector, deps) { contextSub ]) - const memoizedSelector = useMemo(() => selector, deps) - const latestSubscriptionCallbackError = useRef() - const latestSelector = useRef(memoizedSelector) + const latestSelector = useRef(selector) let selectedState = undefined try { - selectedState = memoizedSelector(store.getState()) + selectedState = selector(store.getState()) } catch (err) { let errorMessage = `An error occured while selecting the store state: ${ err.message @@ -77,7 +73,7 @@ export function useSelector(selector, deps) { const latestSelectedState = useRef(selectedState) useIsomorphicLayoutEffect(() => { - latestSelector.current = memoizedSelector + latestSelector.current = selector latestSelectedState.current = selectedState latestSubscriptionCallbackError.current = undefined }) diff --git a/test/hooks/useSelector.spec.js b/test/hooks/useSelector.spec.js index e0fb95c8b..c37c5b6e6 100644 --- a/test/hooks/useSelector.spec.js +++ b/test/hooks/useSelector.spec.js @@ -1,6 +1,6 @@ /*eslint-disable react/prop-types*/ -import React, { useReducer } from 'react' +import React from 'react' import { createStore } from 'redux' import { renderHook, act } from 'react-hooks-testing-library' import * as rtl from 'react-testing-library' @@ -154,34 +154,6 @@ describe('React', () => { expect(renderedItems.length).toBe(1) }) - - it('re-uses the selector if deps do not change', () => { - let selectorId = 0 - let forceRender - - const Comp = () => { - const [, f] = useReducer(c => c + 1, 0) - forceRender = f - const renderedSelectorId = selectorId++ - const value = useSelector(() => renderedSelectorId, []) - renderedItems.push(value) - return
- } - - rtl.render( - - - - ) - - rtl.act(forceRender) - - // this line verifies the susbcription callback uses the same memoized selector and therefore - // does not cause a re-render - store.dispatch({ type: '' }) - - expect(renderedItems).toEqual([0, 0]) - }) }) describe('edge cases', () => { From 8a673c9ed16eb421468262b74690af21f31848a8 Mon Sep 17 00:00:00 2001 From: Julian Grinblat Date: Mon, 20 May 2019 08:34:34 +0900 Subject: [PATCH 13/15] Replace shallowEqual with reference equality in useSelector (#1288) * Replace shallowEqual with reference equality in useSelector * useSelector: Allow optional compararison function Export shallowEqual function --- package-lock.json | 3 +-- src/hooks/useSelector.js | 8 +++++--- src/index.js | 4 +++- test/hooks/useSelector.spec.js | 33 ++++++++++++++++++++++++++++++--- 4 files changed, 39 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index 83be93f56..0cbd3105d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5010,8 +5010,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", - "dev": true, - "optional": true + "dev": true }, "is-fullwidth-code-point": { "version": "2.0.0", diff --git a/src/hooks/useSelector.js b/src/hooks/useSelector.js index 8e40cfa10..8f331f9cd 100644 --- a/src/hooks/useSelector.js +++ b/src/hooks/useSelector.js @@ -1,7 +1,6 @@ import { useReducer, useRef, useEffect, useMemo, useLayoutEffect } from 'react' import invariant from 'invariant' import { useReduxContext } from './useReduxContext' -import shallowEqual from '../utils/shallowEqual' import Subscription from '../utils/Subscription' // React currently throws a warning when using useLayoutEffect on the server. @@ -15,6 +14,8 @@ import Subscription from '../utils/Subscription' const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect +const refEquality = (a, b) => a === b + /** * A hook to access the redux store's state. This hook takes a selector function * as an argument. The selector is called with the store state. @@ -24,6 +25,7 @@ const useIsomorphicLayoutEffect = * useful if you provide a selector that memoizes values). * * @param {Function} selector the selector function + * @param {Function} equalityFn the function that will be used to determine equality * * @returns {any} the selected state * @@ -38,7 +40,7 @@ const useIsomorphicLayoutEffect = * return
{counter}
* } */ -export function useSelector(selector) { +export function useSelector(selector, equalityFn = refEquality) { invariant(selector, `You must pass a selector to useSelectors`) const { store, subscription: contextSub } = useReduxContext() @@ -83,7 +85,7 @@ export function useSelector(selector) { try { const newSelectedState = latestSelector.current(store.getState()) - if (shallowEqual(newSelectedState, latestSelectedState.current)) { + if (equalityFn(newSelectedState, latestSelectedState.current)) { return } diff --git a/src/index.js b/src/index.js index 654d5e7fd..8817a27aa 100644 --- a/src/index.js +++ b/src/index.js @@ -9,6 +9,7 @@ import { useStore } from './hooks/useStore' import { setBatch } from './utils/batch' import { unstable_batchedUpdates as batch } from './utils/reactBatchedUpdates' +import shallowEqual from './utils/shallowEqual' setBatch(batch) @@ -20,5 +21,6 @@ export { batch, useDispatch, useSelector, - useStore + useStore, + shallowEqual } diff --git a/test/hooks/useSelector.spec.js b/test/hooks/useSelector.spec.js index c37c5b6e6..8f9409234 100644 --- a/test/hooks/useSelector.spec.js +++ b/test/hooks/useSelector.spec.js @@ -4,7 +4,11 @@ import React from 'react' import { createStore } from 'redux' import { renderHook, act } from 'react-hooks-testing-library' import * as rtl from 'react-testing-library' -import { Provider as ProviderMock, useSelector } from '../../src/index.js' +import { + Provider as ProviderMock, + useSelector, + shallowEqual +} from '../../src/index.js' import { useReduxContext } from '../../src/hooks/useReduxContext' describe('React', () => { @@ -128,7 +132,30 @@ describe('React', () => { }) describe('performance optimizations and bail-outs', () => { - it('should shallowly compare the selected state to prevent unnecessary updates', () => { + it('defaults to ref-equality to prevent unnecessary updates', () => { + const state = {} + store = createStore(() => state) + + const Comp = () => { + const value = useSelector(s => s) + renderedItems.push(value) + return
+ } + + rtl.render( + + + + ) + + expect(renderedItems.length).toBe(1) + + store.dispatch({ type: '' }) + + expect(renderedItems.length).toBe(1) + }) + + it('allows other equality functions to prevent unnecessary updates', () => { store = createStore( ({ count, stable } = { count: -1, stable: {} }) => ({ count: count + 1, @@ -137,7 +164,7 @@ describe('React', () => { ) const Comp = () => { - const value = useSelector(s => Object.keys(s)) + const value = useSelector(s => Object.keys(s), shallowEqual) renderedItems.push(value) return
} From 007b5c78969689ad4ed1f239c6dd2a73e04fa2b9 Mon Sep 17 00:00:00 2001 From: Josep M Sobrepere Date: Mon, 20 May 2019 01:47:53 +0200 Subject: [PATCH 14/15] Avoid unnecessary selector evaluations (#1273) * Avoid unnecessary selector evaluations * Clean up state assignment logic * Add missing shallowEqual export --- src/alternate-renderers.js | 4 ++- src/hooks/useSelector.js | 16 +++++++--- test/hooks/useSelector.spec.js | 58 +++++++++++++++++++++++++++++++++- 3 files changed, 71 insertions(+), 7 deletions(-) diff --git a/src/alternate-renderers.js b/src/alternate-renderers.js index c3a04a254..0625e8cb2 100644 --- a/src/alternate-renderers.js +++ b/src/alternate-renderers.js @@ -8,6 +8,7 @@ import { useSelector } from './hooks/useSelector' import { useStore } from './hooks/useStore' import { getBatch } from './utils/batch' +import shallowEqual from './utils/shallowEqual' // For other renderers besides ReactDOM and React Native, use the default noop batch function const batch = getBatch() @@ -20,5 +21,6 @@ export { batch, useDispatch, useSelector, - useStore + useStore, + shallowEqual } diff --git a/src/hooks/useSelector.js b/src/hooks/useSelector.js index 8f331f9cd..83e48569d 100644 --- a/src/hooks/useSelector.js +++ b/src/hooks/useSelector.js @@ -52,12 +52,20 @@ export function useSelector(selector, equalityFn = refEquality) { ]) const latestSubscriptionCallbackError = useRef() - const latestSelector = useRef(selector) + const latestSelector = useRef() + const latestSelectedState = useRef() - let selectedState = undefined + let selectedState try { - selectedState = selector(store.getState()) + if ( + selector !== latestSelector.current || + latestSubscriptionCallbackError.current + ) { + selectedState = selector(store.getState()) + } else { + selectedState = latestSelectedState.current + } } catch (err) { let errorMessage = `An error occured while selecting the store state: ${ err.message @@ -72,8 +80,6 @@ export function useSelector(selector, equalityFn = refEquality) { throw new Error(errorMessage) } - const latestSelectedState = useRef(selectedState) - useIsomorphicLayoutEffect(() => { latestSelector.current = selector latestSelectedState.current = selectedState diff --git a/test/hooks/useSelector.spec.js b/test/hooks/useSelector.spec.js index 8f9409234..a4756d244 100644 --- a/test/hooks/useSelector.spec.js +++ b/test/hooks/useSelector.spec.js @@ -1,6 +1,6 @@ /*eslint-disable react/prop-types*/ -import React from 'react' +import React, { useCallback, useReducer } from 'react' import { createStore } from 'redux' import { renderHook, act } from 'react-hooks-testing-library' import * as rtl from 'react-testing-library' @@ -51,6 +51,29 @@ describe('React', () => { }) describe('lifeycle interactions', () => { + it('always uses the latest state', () => { + store = createStore(c => c + 1, -1) + + const Comp = () => { + const selector = useCallback(c => c + 1, []) + const value = useSelector(selector) + renderedItems.push(value) + return
+ } + + rtl.render( + + + + ) + + expect(renderedItems).toEqual([1]) + + store.dispatch({ type: '' }) + + expect(renderedItems).toEqual([1, 2]) + }) + it('subscribes to the store synchronously', () => { let rootSubscription @@ -183,6 +206,39 @@ describe('React', () => { }) }) + it('uses the latest selector', () => { + let selectorId = 0 + let forceRender + + const Comp = () => { + const [, f] = useReducer(c => c + 1, 0) + forceRender = f + const renderedSelectorId = selectorId++ + const value = useSelector(() => renderedSelectorId) + renderedItems.push(value) + return
+ } + + rtl.render( + + + + ) + + expect(renderedItems).toEqual([0]) + + rtl.act(forceRender) + expect(renderedItems).toEqual([0, 1]) + + rtl.act(() => { + store.dispatch({ type: '' }) + }) + expect(renderedItems).toEqual([0, 1]) + + rtl.act(forceRender) + expect(renderedItems).toEqual([0, 1, 2]) + }) + describe('edge cases', () => { it('ignores transient errors in selector (e.g. due to stale props)', () => { const spy = jest.spyOn(console, 'error').mockImplementation(() => {}) From 0e41eaeebf5d8d123daaa50a91ee1c219c4830de Mon Sep 17 00:00:00 2001 From: Mark Erikson Date: Sun, 19 May 2019 20:18:52 -0400 Subject: [PATCH 15/15] 7.1.0-alpha.5 --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0cbd3105d..f6a435579 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "react-redux", - "version": "7.1.0-alpha.4", + "version": "7.1.0-alpha.5", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index d8cfc4d45..fb87eb93f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-redux", - "version": "7.1.0-alpha.4", + "version": "7.1.0-alpha.5", "description": "Official React bindings for Redux", "keywords": [ "react",