diff --git a/package.json b/package.json index fb97eb65d..3954d9e6e 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 ( + <> +