Skip to content

Commit 459b319

Browse files
ryaninventstimdorr
authored andcommitted
feat: implement create*Hook APIs (reduxjs#1309)
* feat: implement `create*Hook` APIs * feat: Hook creators accept context directly * feat: simplify custom context handling
1 parent b0e9404 commit 459b319

File tree

7 files changed

+233
-44
lines changed

7 files changed

+233
-44
lines changed

docs/api/hooks.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,40 @@ export const CounterComponent = ({ value }) => {
295295
}
296296
```
297297

298+
299+
## Custom context
300+
301+
The `<Provider>` 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.
302+
303+
To access an alternate context via the hooks API, use the hook creator functions:
304+
305+
```js
306+
import React from 'react'
307+
import {
308+
Provider,
309+
createStoreHook,
310+
createDispatchHook,
311+
createSelectorHook
312+
} from 'react-redux'
313+
314+
const MyContext = React.createContext(null)
315+
316+
// Export your custom hooks if you wish to use them in other files.
317+
export const useStore = createStoreHook(MyContext)
318+
export const useDispatch = createDispatchHook(MyContext)
319+
export const useSelector = createSelectorHook(MyContext)
320+
321+
const myStore = createStore(rootReducer)
322+
323+
export function MyProvider({ children }) {
324+
return (
325+
<Provider context={MyContext} store={myStore}>
326+
{children}
327+
</Provider>
328+
)
329+
}
330+
```
331+
298332
## Usage Warnings
299333

300334
### Stale Props and "Zombie Children"

src/hooks/useDispatch.js

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,20 @@
1-
import { useStore } from './useStore'
1+
import { ReactReduxContext } from '../components/Context'
2+
import { useStore as useDefaultStore, createStoreHook } from './useStore'
3+
4+
/**
5+
* Hook factory, which creates a `useDispatch` hook bound to a given context.
6+
*
7+
* @param {Function} [context=ReactReduxContext] Context passed to your `<Provider>`.
8+
* @returns {Function} A `useDispatch` hook bound to the specified context.
9+
*/
10+
export function createDispatchHook(context = ReactReduxContext) {
11+
const useStore =
12+
context === ReactReduxContext ? useDefaultStore : createStoreHook(context)
13+
return function useDispatch() {
14+
const store = useStore()
15+
return store.dispatch
16+
}
17+
}
218

319
/**
420
* A hook to access the redux `dispatch` function.
@@ -21,7 +37,4 @@ import { useStore } from './useStore'
2137
* )
2238
* }
2339
*/
24-
export function useDispatch() {
25-
const store = useStore()
26-
return store.dispatch
27-
}
40+
export const useDispatch = createDispatchHook()

src/hooks/useSelector.js

Lines changed: 66 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
1-
import { useReducer, useRef, useEffect, useMemo, useLayoutEffect } from 'react'
1+
import {
2+
useReducer,
3+
useRef,
4+
useEffect,
5+
useMemo,
6+
useLayoutEffect,
7+
useContext
8+
} from 'react'
29
import invariant from 'invariant'
3-
import { useReduxContext } from './useReduxContext'
10+
import { useReduxContext as useDefaultReduxContext } from './useReduxContext'
411
import Subscription from '../utils/Subscription'
12+
import { ReactReduxContext } from '../components/Context'
513

614
// React currently throws a warning when using useLayoutEffect on the server.
715
// To get around it, we can conditionally useEffect on the server (no-op) and
@@ -16,33 +24,12 @@ const useIsomorphicLayoutEffect =
1624

1725
const refEquality = (a, b) => a === b
1826

19-
/**
20-
* A hook to access the redux store's state. This hook takes a selector function
21-
* as an argument. The selector is called with the store state.
22-
*
23-
* This hook takes an optional equality comparison function as the second parameter
24-
* that allows you to customize the way the selected state is compared to determine
25-
* whether the component needs to be re-rendered.
26-
*
27-
* @param {Function} selector the selector function
28-
* @param {Function=} equalityFn the function that will be used to determine equality
29-
*
30-
* @returns {any} the selected state
31-
*
32-
* @example
33-
*
34-
* import React from 'react'
35-
* import { useSelector } from 'react-redux'
36-
*
37-
* export const CounterComponent = () => {
38-
* const counter = useSelector(state => state.counter)
39-
* return <div>{counter}</div>
40-
* }
41-
*/
42-
export function useSelector(selector, equalityFn = refEquality) {
43-
invariant(selector, `You must pass a selector to useSelectors`)
44-
45-
const { store, subscription: contextSub } = useReduxContext()
27+
function useSelectorWithStoreAndSubscription(
28+
selector,
29+
equalityFn,
30+
store,
31+
contextSub
32+
) {
4633
const [, forceRender] = useReducer(s => s + 1, 0)
4734

4835
const subscription = useMemo(() => new Subscription(store, contextSub), [
@@ -112,3 +99,53 @@ export function useSelector(selector, equalityFn = refEquality) {
11299

113100
return selectedState
114101
}
102+
103+
/**
104+
* Hook factory, which creates a `useSelector` hook bound to a given context.
105+
*
106+
* @param {Function} [context=ReactReduxContext] Context passed to your `<Provider>`.
107+
* @returns {Function} A `useSelector` hook bound to the specified context.
108+
*/
109+
export function createSelectorHook(context = ReactReduxContext) {
110+
const useReduxContext =
111+
context === ReactReduxContext
112+
? useDefaultReduxContext
113+
: () => useContext(context)
114+
return function useSelector(selector, equalityFn = refEquality) {
115+
invariant(selector, `You must pass a selector to useSelectors`)
116+
117+
const { store, subscription: contextSub } = useReduxContext()
118+
119+
return useSelectorWithStoreAndSubscription(
120+
selector,
121+
equalityFn,
122+
store,
123+
contextSub
124+
)
125+
}
126+
}
127+
128+
/**
129+
* A hook to access the redux store's state. This hook takes a selector function
130+
* as an argument. The selector is called with the store state.
131+
*
132+
* This hook takes an optional equality comparison function as the second parameter
133+
* that allows you to customize the way the selected state is compared to determine
134+
* whether the component needs to be re-rendered.
135+
*
136+
* @param {Function} selector the selector function
137+
* @param {Function=} equalityFn the function that will be used to determine equality
138+
*
139+
* @returns {any} the selected state
140+
*
141+
* @example
142+
*
143+
* import React from 'react'
144+
* import { useSelector } from 'react-redux'
145+
*
146+
* export const CounterComponent = () => {
147+
* const counter = useSelector(state => state.counter)
148+
* return <div>{counter}</div>
149+
* }
150+
*/
151+
export const useSelector = createSelectorHook()

src/hooks/useStore.js

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,23 @@
1-
import { useReduxContext } from './useReduxContext'
1+
import { useContext } from 'react'
2+
import { ReactReduxContext } from '../components/Context'
3+
import { useReduxContext as useDefaultReduxContext } from './useReduxContext'
4+
5+
/**
6+
* Hook factory, which creates a `useStore` hook bound to a given context.
7+
*
8+
* @param {Function} [context=ReactReduxContext] Context passed to your `<Provider>`.
9+
* @returns {Function} A `useStore` hook bound to the specified context.
10+
*/
11+
export function createStoreHook(context = ReactReduxContext) {
12+
const useReduxContext =
13+
context === ReactReduxContext
14+
? useDefaultReduxContext
15+
: () => useContext(context)
16+
return function useStore() {
17+
const { store } = useReduxContext()
18+
return store
19+
}
20+
}
221

322
/**
423
* A hook to access the redux store.
@@ -15,7 +34,4 @@ import { useReduxContext } from './useReduxContext'
1534
* return <div>{store.getState()}</div>
1635
* }
1736
*/
18-
export function useStore() {
19-
const { store } = useReduxContext()
20-
return store
21-
}
37+
export const useStore = createStoreHook()

src/index.js

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@ import connectAdvanced from './components/connectAdvanced'
33
import { ReactReduxContext } from './components/Context'
44
import connect from './connect/connect'
55

6-
import { useDispatch } from './hooks/useDispatch'
7-
import { useSelector } from './hooks/useSelector'
8-
import { useStore } from './hooks/useStore'
6+
import { useDispatch, createDispatchHook } from './hooks/useDispatch'
7+
import { useSelector, createSelectorHook } from './hooks/useSelector'
8+
import { useStore, createStoreHook } from './hooks/useStore'
99

1010
import { setBatch } from './utils/batch'
1111
import { unstable_batchedUpdates as batch } from './utils/reactBatchedUpdates'
@@ -20,7 +20,10 @@ export {
2020
connect,
2121
batch,
2222
useDispatch,
23+
createDispatchHook,
2324
useSelector,
25+
createSelectorHook,
2426
useStore,
27+
createStoreHook,
2528
shallowEqual
2629
}

test/hooks/useDispatch.spec.js

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
import React from 'react'
22
import { createStore } from 'redux'
33
import { renderHook } from '@testing-library/react-hooks'
4-
import { Provider as ProviderMock, useDispatch } from '../../src/index.js'
4+
import {
5+
Provider as ProviderMock,
6+
useDispatch,
7+
createDispatchHook
8+
} from '../../src/index.js'
59

610
const store = createStore(c => c + 1)
11+
const store2 = createStore(c => c + 2)
712

813
describe('React', () => {
914
describe('hooks', () => {
@@ -16,5 +21,36 @@ describe('React', () => {
1621
expect(result.current).toBe(store.dispatch)
1722
})
1823
})
24+
describe('createDispatchHook', () => {
25+
it("returns the correct store's dispatch function", () => {
26+
const nestedContext = React.createContext(null)
27+
const useCustomDispatch = createDispatchHook(nestedContext)
28+
const { result } = renderHook(() => useDispatch(), {
29+
// eslint-disable-next-line react/prop-types
30+
wrapper: ({ children, ...props }) => (
31+
<ProviderMock {...props} store={store}>
32+
<ProviderMock context={nestedContext} store={store2}>
33+
{children}
34+
</ProviderMock>
35+
</ProviderMock>
36+
)
37+
})
38+
39+
expect(result.current).toBe(store.dispatch)
40+
41+
const { result: result2 } = renderHook(() => useCustomDispatch(), {
42+
// eslint-disable-next-line react/prop-types
43+
wrapper: ({ children, ...props }) => (
44+
<ProviderMock {...props} store={store}>
45+
<ProviderMock context={nestedContext} store={store2}>
46+
{children}
47+
</ProviderMock>
48+
</ProviderMock>
49+
)
50+
})
51+
52+
expect(result2.current).toBe(store2.dispatch)
53+
})
54+
})
1955
})
2056
})

test/hooks/useSelector.spec.js

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import {
88
Provider as ProviderMock,
99
useSelector,
1010
shallowEqual,
11-
connect
11+
connect,
12+
createSelectorHook
1213
} from '../../src/index.js'
1314
import { useReduxContext } from '../../src/hooks/useReduxContext'
1415

@@ -383,5 +384,54 @@ describe('React', () => {
383384
})
384385
})
385386
})
387+
388+
describe('createSelectorHook', () => {
389+
let defaultStore
390+
let customStore
391+
392+
beforeEach(() => {
393+
defaultStore = createStore(({ count } = { count: -1 }) => ({
394+
count: count + 1
395+
}))
396+
customStore = createStore(({ count } = { count: 10 }) => ({
397+
count: count + 2
398+
}))
399+
})
400+
401+
afterEach(() => rtl.cleanup())
402+
403+
it('subscribes to the correct store', () => {
404+
const nestedContext = React.createContext(null)
405+
const useCustomSelector = createSelectorHook(nestedContext)
406+
let defaultCount = null
407+
let customCount = null
408+
409+
const getCount = s => s.count
410+
411+
const DisplayDefaultCount = ({ children = null }) => {
412+
const count = useSelector(getCount)
413+
defaultCount = count
414+
return <>{children}</>
415+
}
416+
const DisplayCustomCount = ({ children = null }) => {
417+
const count = useCustomSelector(getCount)
418+
customCount = count
419+
return <>{children}</>
420+
}
421+
422+
rtl.render(
423+
<ProviderMock store={defaultStore}>
424+
<ProviderMock context={nestedContext} store={customStore}>
425+
<DisplayCustomCount>
426+
<DisplayDefaultCount />
427+
</DisplayCustomCount>
428+
</ProviderMock>
429+
</ProviderMock>
430+
)
431+
432+
expect(defaultCount).toBe(0)
433+
expect(customCount).toBe(12)
434+
})
435+
})
386436
})
387437
})

0 commit comments

Comments
 (0)