From efd270f3e549bf95ae916621b502a3e81dca6f7e Mon Sep 17 00:00:00 2001 From: Ryan Kennedy Date: Tue, 11 Jun 2019 12:27:01 -0400 Subject: [PATCH 1/3] feat: implement `create*Hook` APIs --- docs/api/hooks.md | 36 +++++++++++++++ src/hooks/useDispatch.js | 23 +++++++--- src/hooks/useReduxContext.js | 30 ++++++++----- src/hooks/useSelector.js | 81 ++++++++++++++++++++++------------ src/hooks/useStore.js | 20 ++++++--- src/index.js | 11 +++-- test/hooks/useDispatch.spec.js | 41 ++++++++++++++++- test/hooks/useSelector.spec.js | 55 ++++++++++++++++++++++- 8 files changed, 244 insertions(+), 53 deletions(-) diff --git a/docs/api/hooks.md b/docs/api/hooks.md index ea549a20f..e27b1caaf 100644 --- a/docs/api/hooks.md +++ b/docs/api/hooks.md @@ -295,6 +295,42 @@ export const CounterComponent = ({ value }) => { } ``` + +## Custom context + +The `` component allows you to specify an alternate context via the `context` prop. This is useful if you're building a complex reusable component, and you don't want your store to collide with any Redux store your consumers' applications might use. + +To access an alternate context via the hooks API, use the hook creator functions: + +```js +import React from 'react' +import { + Provider, + createReduxContextHook, + createStoreHook, + createDispatchHook, + createSelectorHook +} from 'react-redux' + +const MyContext = React.createContext(null) +const useMyReduxContext = createReduxContextHook(MyContext) + +// Export your custom hooks if you wish to use them in other files. +export const useStore = createStoreHook(useMyReduxContext) +export const useDispatch = createDispatchHook(useMyReduxContext) +export const useSelector = createSelectorHook(useMyReduxContext) + +const myStore = createStore(rootReducer) + +export function MyProvider({ children }) { + return ( + + {children} + + ) +} +``` + ## Usage Warnings ### Stale Props and "Zombie Children" diff --git a/src/hooks/useDispatch.js b/src/hooks/useDispatch.js index d88dd406e..803ba819a 100644 --- a/src/hooks/useDispatch.js +++ b/src/hooks/useDispatch.js @@ -1,4 +1,20 @@ -import { useStore } from './useStore' +import { useStore as useDefaultStore, createStoreHook } from './useStore' + +/** + * Hook factory, which creates a `useDispatch` hook bound to a given context. + * + * @param {Function} [useReduxContext] Hook which returns the Redux context. + * @returns {Function} A `useDispatch` hook bound to the specified context. + */ +export function createDispatchHook(useReduxContext = null) { + const useStore = useReduxContext + ? createStoreHook(useReduxContext) + : useDefaultStore + return function useDispatch() { + const store = useStore() + return store.dispatch + } +} /** * A hook to access the redux `dispatch` function. Note that in most cases where you @@ -23,7 +39,4 @@ import { useStore } from './useStore' * ) * } */ -export function useDispatch() { - const store = useStore() - return store.dispatch -} +export const useDispatch = createDispatchHook() diff --git a/src/hooks/useReduxContext.js b/src/hooks/useReduxContext.js index 903a11c44..a0e518cb9 100644 --- a/src/hooks/useReduxContext.js +++ b/src/hooks/useReduxContext.js @@ -2,6 +2,25 @@ import { useContext } from 'react' import invariant from 'invariant' import { ReactReduxContext } from '../components/Context' +/** + * Hook factory, which creates a `useReduxContext` hook bound to a given context. + * + * @param {Function} [context=ReactReduxContext] React context passed to the `context` prop of your ``. + * @returns {Function} A `useReactContext` hook bound to the specified context. + */ +export function createReduxContextHook(context = ReactReduxContext) { + return function useReduxContext() { + const contextValue = useContext(context) + + invariant( + contextValue, + 'could not find react-redux context value; please ensure the component is wrapped in a ' + ) + + return contextValue + } +} + /** * A hook to access the value of the `ReactReduxContext`. This is a low-level * hook that you should usually not need to call directly. @@ -18,13 +37,4 @@ import { ReactReduxContext } from '../components/Context' * 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 -} +export const useReduxContext = createReduxContextHook() diff --git a/src/hooks/useSelector.js b/src/hooks/useSelector.js index 9fcc1f017..2a7a541fe 100644 --- a/src/hooks/useSelector.js +++ b/src/hooks/useSelector.js @@ -1,6 +1,6 @@ import { useReducer, useRef, useEffect, useMemo, useLayoutEffect } from 'react' import invariant from 'invariant' -import { useReduxContext } from './useReduxContext' +import { useReduxContext as useDefaultReduxContext } from './useReduxContext' import Subscription from '../utils/Subscription' // React currently throws a warning when using useLayoutEffect on the server. @@ -16,33 +16,12 @@ const useIsomorphicLayoutEffect = 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. - * - * This hook takes an optional equality comparison function as the second parameter - * that allows you to customize the way the selected state is compared to determine - * whether the component needs to be re-rendered. - * - * @param {Function} selector the selector function - * @param {Function=} equalityFn the function that will be used to determine equality - * - * @returns {any} the selected state - * - * @example - * - * import React from 'react' - * import { useSelector } from 'react-redux' - * - * export const CounterComponent = () => { - * const counter = useSelector(state => state.counter) - * return
{counter}
- * } - */ -export function useSelector(selector, equalityFn = refEquality) { - invariant(selector, `You must pass a selector to useSelectors`) - - const { store, subscription: contextSub } = useReduxContext() +function useSelectorWithStoreAndSubscription( + selector, + equalityFn, + store, + contextSub +) { const [, forceRender] = useReducer(s => s + 1, 0) const subscription = useMemo(() => new Subscription(store, contextSub), [ @@ -112,3 +91,49 @@ export function useSelector(selector, equalityFn = refEquality) { return selectedState } + +/** + * Hook factory, which creates a `useSelector` hook bound to a given context. + * + * @param {Function} [useReduxContext] Hook which returns the Redux context. + * @returns {Function} A `useSelector` hook bound to the specified context. + */ +export function createSelectorHook(useReduxContext = useDefaultReduxContext) { + return function useSelector(selector, equalityFn = refEquality) { + invariant(selector, `You must pass a selector to useSelectors`) + + const { store, subscription: contextSub } = useReduxContext() + + return useSelectorWithStoreAndSubscription( + selector, + equalityFn, + store, + contextSub + ) + } +} + +/** + * 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. + * + * This hook takes an optional equality comparison function as the second parameter + * that allows you to customize the way the selected state is compared to determine + * whether the component needs to be re-rendered. + * + * @param {Function} selector the selector function + * @param {Function=} equalityFn the function that will be used to determine equality + * + * @returns {any} the selected state + * + * @example + * + * import React from 'react' + * import { useSelector } from 'react-redux' + * + * export const CounterComponent = () => { + * const counter = useSelector(state => state.counter) + * return
{counter}
+ * } + */ +export const useSelector = createSelectorHook() diff --git a/src/hooks/useStore.js b/src/hooks/useStore.js index 16cca17a4..336830606 100644 --- a/src/hooks/useStore.js +++ b/src/hooks/useStore.js @@ -1,4 +1,17 @@ -import { useReduxContext } from './useReduxContext' +import { useReduxContext as useDefaultReduxContext } from './useReduxContext' + +/** + * Hook factory, which creates a `useStore` hook bound to a given context. + * + * @param {Function} [useReduxContext] Hook which returns the Redux context. + * @returns {Function} A `useStore` hook bound to the specified context. + */ +export function createStoreHook(useReduxContext = useDefaultReduxContext) { + return function useStore() { + const { store } = useReduxContext() + return store + } +} /** * A hook to access the redux store. @@ -15,7 +28,4 @@ import { useReduxContext } from './useReduxContext' * return
{store.getState()}
* } */ -export function useStore() { - const { store } = useReduxContext() - return store -} +export const useStore = createStoreHook() diff --git a/src/index.js b/src/index.js index 8817a27aa..f94836071 100644 --- a/src/index.js +++ b/src/index.js @@ -3,9 +3,10 @@ import connectAdvanced from './components/connectAdvanced' import { ReactReduxContext } from './components/Context' import connect from './connect/connect' -import { useDispatch } from './hooks/useDispatch' -import { useSelector } from './hooks/useSelector' -import { useStore } from './hooks/useStore' +import { useDispatch, createDispatchHook } from './hooks/useDispatch' +import { useSelector, createSelectorHook } from './hooks/useSelector' +import { useStore, createStoreHook } from './hooks/useStore' +import { createReduxContextHook } from './hooks/useReduxContext' import { setBatch } from './utils/batch' import { unstable_batchedUpdates as batch } from './utils/reactBatchedUpdates' @@ -20,7 +21,11 @@ export { connect, batch, useDispatch, + createDispatchHook, useSelector, + createSelectorHook, useStore, + createStoreHook, + createReduxContextHook, shallowEqual } diff --git a/test/hooks/useDispatch.spec.js b/test/hooks/useDispatch.spec.js index 6f43937c5..908b5c76d 100644 --- a/test/hooks/useDispatch.spec.js +++ b/test/hooks/useDispatch.spec.js @@ -1,9 +1,15 @@ import React from 'react' import { createStore } from 'redux' import { renderHook } from 'react-hooks-testing-library' -import { Provider as ProviderMock, useDispatch } from '../../src/index.js' +import { + Provider as ProviderMock, + useDispatch, + createDispatchHook, + createReduxContextHook +} from '../../src/index.js' const store = createStore(c => c + 1) +const store2 = createStore(c => c + 2) describe('React', () => { describe('hooks', () => { @@ -16,5 +22,38 @@ describe('React', () => { expect(result.current).toBe(store.dispatch) }) }) + describe('createDispatchHook', () => { + it("returns the correct store's dispatch function", () => { + const nestedContext = React.createContext(null) + const useCustomDispatch = createDispatchHook( + createReduxContextHook(nestedContext) + ) + const { result } = renderHook(() => useDispatch(), { + // eslint-disable-next-line react/prop-types + wrapper: ({ children, ...props }) => ( + + + {children} + + + ) + }) + + expect(result.current).toBe(store.dispatch) + + const { result: result2 } = renderHook(() => useCustomDispatch(), { + // eslint-disable-next-line react/prop-types + wrapper: ({ children, ...props }) => ( + + + {children} + + + ) + }) + + expect(result2.current).toBe(store2.dispatch) + }) + }) }) }) diff --git a/test/hooks/useSelector.spec.js b/test/hooks/useSelector.spec.js index 119f81e54..db363ae52 100644 --- a/test/hooks/useSelector.spec.js +++ b/test/hooks/useSelector.spec.js @@ -8,7 +8,9 @@ import { Provider as ProviderMock, useSelector, shallowEqual, - connect + connect, + createSelectorHook, + createReduxContextHook } from '../../src/index.js' import { useReduxContext } from '../../src/hooks/useReduxContext' @@ -383,5 +385,56 @@ describe('React', () => { }) }) }) + + describe('createSelectorHook', () => { + let defaultStore + let customStore + + beforeEach(() => { + defaultStore = createStore(({ count } = { count: -1 }) => ({ + count: count + 1 + })) + customStore = createStore(({ count } = { count: 10 }) => ({ + count: count + 2 + })) + }) + + afterEach(() => rtl.cleanup()) + + it('subscribes to the correct store', () => { + const nestedContext = React.createContext(null) + const useCustomSelector = createSelectorHook( + createReduxContextHook(nestedContext) + ) + let defaultCount = null + let customCount = null + + const getCount = s => s.count + + const DisplayDefaultCount = ({ children = null }) => { + const count = useSelector(getCount) + defaultCount = count + return <>{children} + } + const DisplayCustomCount = ({ children = null }) => { + const count = useCustomSelector(getCount) + customCount = count + return <>{children} + } + + rtl.render( + + + + + + + + ) + + expect(defaultCount).toBe(0) + expect(customCount).toBe(12) + }) + }) }) }) From 69006eb9564e24a190bea44ffb558fa6bd232904 Mon Sep 17 00:00:00 2001 From: Ryan Kennedy Date: Wed, 12 Jun 2019 10:27:30 -0400 Subject: [PATCH 2/3] feat: Hook creators accept context directly --- docs/api/hooks.md | 8 +++----- src/hooks/useDispatch.js | 10 +++++----- src/hooks/useReduxContext.js | 31 +++++++++++-------------------- src/hooks/useSelector.js | 9 +++++---- src/hooks/useStore.js | 9 +++++---- src/index.js | 2 -- test/hooks/useDispatch.spec.js | 7 ++----- test/hooks/useSelector.spec.js | 7 ++----- 8 files changed, 33 insertions(+), 50 deletions(-) diff --git a/docs/api/hooks.md b/docs/api/hooks.md index e27b1caaf..0fa5ab95e 100644 --- a/docs/api/hooks.md +++ b/docs/api/hooks.md @@ -306,19 +306,17 @@ To access an alternate context via the hooks API, use the hook creator functions import React from 'react' import { Provider, - createReduxContextHook, createStoreHook, createDispatchHook, createSelectorHook } from 'react-redux' const MyContext = React.createContext(null) -const useMyReduxContext = createReduxContextHook(MyContext) // Export your custom hooks if you wish to use them in other files. -export const useStore = createStoreHook(useMyReduxContext) -export const useDispatch = createDispatchHook(useMyReduxContext) -export const useSelector = createSelectorHook(useMyReduxContext) +export const useStore = createStoreHook(MyContext) +export const useDispatch = createDispatchHook(MyContext) +export const useSelector = createSelectorHook(MyContext) const myStore = createStore(rootReducer) diff --git a/src/hooks/useDispatch.js b/src/hooks/useDispatch.js index 803ba819a..0c3e33466 100644 --- a/src/hooks/useDispatch.js +++ b/src/hooks/useDispatch.js @@ -1,15 +1,15 @@ +import { ReactReduxContext } from '../components/Context' import { useStore as useDefaultStore, createStoreHook } from './useStore' /** * Hook factory, which creates a `useDispatch` hook bound to a given context. * - * @param {Function} [useReduxContext] Hook which returns the Redux context. + * @param {Function} [context=ReactReduxContext] Context passed to your ``. * @returns {Function} A `useDispatch` hook bound to the specified context. */ -export function createDispatchHook(useReduxContext = null) { - const useStore = useReduxContext - ? createStoreHook(useReduxContext) - : useDefaultStore +export function createDispatchHook(context = ReactReduxContext) { + const useStore = + context === ReactReduxContext ? useDefaultStore : createStoreHook(context) return function useDispatch() { const store = useStore() return store.dispatch diff --git a/src/hooks/useReduxContext.js b/src/hooks/useReduxContext.js index a0e518cb9..2a526ac2e 100644 --- a/src/hooks/useReduxContext.js +++ b/src/hooks/useReduxContext.js @@ -2,29 +2,11 @@ import { useContext } from 'react' import invariant from 'invariant' import { ReactReduxContext } from '../components/Context' -/** - * Hook factory, which creates a `useReduxContext` hook bound to a given context. - * - * @param {Function} [context=ReactReduxContext] React context passed to the `context` prop of your ``. - * @returns {Function} A `useReactContext` hook bound to the specified context. - */ -export function createReduxContextHook(context = ReactReduxContext) { - return function useReduxContext() { - const contextValue = useContext(context) - - invariant( - contextValue, - 'could not find react-redux context value; please ensure the component is wrapped in a ' - ) - - return contextValue - } -} - /** * A hook to access the value of the `ReactReduxContext`. This is a low-level * hook that you should usually not need to call directly. * + * @param {Function} [context=ReactReduxContext] Context passed to your ``, if you're not using the default. * @returns {any} the value of the `ReactReduxContext` * * @example @@ -37,4 +19,13 @@ export function createReduxContextHook(context = ReactReduxContext) { * return
{store.getState()}
* } */ -export const useReduxContext = createReduxContextHook() +export function useReduxContext(context = ReactReduxContext) { + const contextValue = useContext(context) + + 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 index 2a7a541fe..9d272ee61 100644 --- a/src/hooks/useSelector.js +++ b/src/hooks/useSelector.js @@ -1,7 +1,8 @@ import { useReducer, useRef, useEffect, useMemo, useLayoutEffect } from 'react' import invariant from 'invariant' -import { useReduxContext as useDefaultReduxContext } from './useReduxContext' +import { useReduxContext } from './useReduxContext' import Subscription from '../utils/Subscription' +import { ReactReduxContext } from '../components/Context' // 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 @@ -95,14 +96,14 @@ function useSelectorWithStoreAndSubscription( /** * Hook factory, which creates a `useSelector` hook bound to a given context. * - * @param {Function} [useReduxContext] Hook which returns the Redux context. + * @param {Function} [context=ReactReduxContext] Context passed to your ``. * @returns {Function} A `useSelector` hook bound to the specified context. */ -export function createSelectorHook(useReduxContext = useDefaultReduxContext) { +export function createSelectorHook(context = ReactReduxContext) { return function useSelector(selector, equalityFn = refEquality) { invariant(selector, `You must pass a selector to useSelectors`) - const { store, subscription: contextSub } = useReduxContext() + const { store, subscription: contextSub } = useReduxContext(context) return useSelectorWithStoreAndSubscription( selector, diff --git a/src/hooks/useStore.js b/src/hooks/useStore.js index 336830606..376578fdb 100644 --- a/src/hooks/useStore.js +++ b/src/hooks/useStore.js @@ -1,14 +1,15 @@ -import { useReduxContext as useDefaultReduxContext } from './useReduxContext' +import { ReactReduxContext } from '../components/Context' +import { useReduxContext } from './useReduxContext' /** * Hook factory, which creates a `useStore` hook bound to a given context. * - * @param {Function} [useReduxContext] Hook which returns the Redux context. + * @param {Function} [context=ReactReduxContext] Context passed to your ``. * @returns {Function} A `useStore` hook bound to the specified context. */ -export function createStoreHook(useReduxContext = useDefaultReduxContext) { +export function createStoreHook(context = ReactReduxContext) { return function useStore() { - const { store } = useReduxContext() + const { store } = useReduxContext(context) return store } } diff --git a/src/index.js b/src/index.js index f94836071..d02c35a07 100644 --- a/src/index.js +++ b/src/index.js @@ -6,7 +6,6 @@ import connect from './connect/connect' import { useDispatch, createDispatchHook } from './hooks/useDispatch' import { useSelector, createSelectorHook } from './hooks/useSelector' import { useStore, createStoreHook } from './hooks/useStore' -import { createReduxContextHook } from './hooks/useReduxContext' import { setBatch } from './utils/batch' import { unstable_batchedUpdates as batch } from './utils/reactBatchedUpdates' @@ -26,6 +25,5 @@ export { createSelectorHook, useStore, createStoreHook, - createReduxContextHook, shallowEqual } diff --git a/test/hooks/useDispatch.spec.js b/test/hooks/useDispatch.spec.js index 908b5c76d..72653575c 100644 --- a/test/hooks/useDispatch.spec.js +++ b/test/hooks/useDispatch.spec.js @@ -4,8 +4,7 @@ import { renderHook } from 'react-hooks-testing-library' import { Provider as ProviderMock, useDispatch, - createDispatchHook, - createReduxContextHook + createDispatchHook } from '../../src/index.js' const store = createStore(c => c + 1) @@ -25,9 +24,7 @@ describe('React', () => { describe('createDispatchHook', () => { it("returns the correct store's dispatch function", () => { const nestedContext = React.createContext(null) - const useCustomDispatch = createDispatchHook( - createReduxContextHook(nestedContext) - ) + const useCustomDispatch = createDispatchHook(nestedContext) const { result } = renderHook(() => useDispatch(), { // eslint-disable-next-line react/prop-types wrapper: ({ children, ...props }) => ( diff --git a/test/hooks/useSelector.spec.js b/test/hooks/useSelector.spec.js index db363ae52..47d7dc599 100644 --- a/test/hooks/useSelector.spec.js +++ b/test/hooks/useSelector.spec.js @@ -9,8 +9,7 @@ import { useSelector, shallowEqual, connect, - createSelectorHook, - createReduxContextHook + createSelectorHook } from '../../src/index.js' import { useReduxContext } from '../../src/hooks/useReduxContext' @@ -403,9 +402,7 @@ describe('React', () => { it('subscribes to the correct store', () => { const nestedContext = React.createContext(null) - const useCustomSelector = createSelectorHook( - createReduxContextHook(nestedContext) - ) + const useCustomSelector = createSelectorHook(nestedContext) let defaultCount = null let customCount = null From e9631ae30913cb5906a43314fcb7811a5fdefe9e Mon Sep 17 00:00:00 2001 From: Ryan Kennedy Date: Wed, 12 Jun 2019 16:06:11 -0400 Subject: [PATCH 3/3] feat: simplify custom context handling --- src/hooks/useReduxContext.js | 5 ++--- src/hooks/useSelector.js | 17 ++++++++++++++--- src/hooks/useStore.js | 9 +++++++-- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/src/hooks/useReduxContext.js b/src/hooks/useReduxContext.js index 2a526ac2e..903a11c44 100644 --- a/src/hooks/useReduxContext.js +++ b/src/hooks/useReduxContext.js @@ -6,7 +6,6 @@ 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. * - * @param {Function} [context=ReactReduxContext] Context passed to your ``, if you're not using the default. * @returns {any} the value of the `ReactReduxContext` * * @example @@ -19,8 +18,8 @@ import { ReactReduxContext } from '../components/Context' * return
{store.getState()}
* } */ -export function useReduxContext(context = ReactReduxContext) { - const contextValue = useContext(context) +export function useReduxContext() { + const contextValue = useContext(ReactReduxContext) invariant( contextValue, diff --git a/src/hooks/useSelector.js b/src/hooks/useSelector.js index 9d272ee61..2b88b16d1 100644 --- a/src/hooks/useSelector.js +++ b/src/hooks/useSelector.js @@ -1,6 +1,13 @@ -import { useReducer, useRef, useEffect, useMemo, useLayoutEffect } from 'react' +import { + useReducer, + useRef, + useEffect, + useMemo, + useLayoutEffect, + useContext +} from 'react' import invariant from 'invariant' -import { useReduxContext } from './useReduxContext' +import { useReduxContext as useDefaultReduxContext } from './useReduxContext' import Subscription from '../utils/Subscription' import { ReactReduxContext } from '../components/Context' @@ -100,10 +107,14 @@ function useSelectorWithStoreAndSubscription( * @returns {Function} A `useSelector` hook bound to the specified context. */ export function createSelectorHook(context = ReactReduxContext) { + const useReduxContext = + context === ReactReduxContext + ? useDefaultReduxContext + : () => useContext(context) return function useSelector(selector, equalityFn = refEquality) { invariant(selector, `You must pass a selector to useSelectors`) - const { store, subscription: contextSub } = useReduxContext(context) + const { store, subscription: contextSub } = useReduxContext() return useSelectorWithStoreAndSubscription( selector, diff --git a/src/hooks/useStore.js b/src/hooks/useStore.js index 376578fdb..4cc426521 100644 --- a/src/hooks/useStore.js +++ b/src/hooks/useStore.js @@ -1,5 +1,6 @@ +import { useContext } from 'react' import { ReactReduxContext } from '../components/Context' -import { useReduxContext } from './useReduxContext' +import { useReduxContext as useDefaultReduxContext } from './useReduxContext' /** * Hook factory, which creates a `useStore` hook bound to a given context. @@ -8,8 +9,12 @@ import { useReduxContext } from './useReduxContext' * @returns {Function} A `useStore` hook bound to the specified context. */ export function createStoreHook(context = ReactReduxContext) { + const useReduxContext = + context === ReactReduxContext + ? useDefaultReduxContext + : () => useContext(context) return function useStore() { - const { store } = useReduxContext(context) + const { store } = useReduxContext() return store } }