Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: implement create*Hook APIs #1309

Merged
merged 4 commits into from
Aug 1, 2019
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions docs/api/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,42 @@ export const CounterComponent = ({ value }) => {
}
```


## Custom context

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.

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 (
<Provider context={MyContext} store={myStore}>
{children}
</Provider>
)
}
```

## Usage Warnings

### Stale Props and "Zombie Children"
Expand Down
23 changes: 18 additions & 5 deletions src/hooks/useDispatch.js
Original file line number Diff line number Diff line change
@@ -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) {
ryaninvents marked this conversation as resolved.
Show resolved Hide resolved
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
Expand All @@ -23,7 +39,4 @@ import { useStore } from './useStore'
* )
* }
*/
export function useDispatch() {
const store = useStore()
return store.dispatch
}
export const useDispatch = createDispatchHook()
30 changes: 20 additions & 10 deletions src/hooks/useReduxContext.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Provider>`.
* @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 <Provider>'
)

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.
Expand All @@ -18,13 +37,4 @@ import { ReactReduxContext } from '../components/Context'
* return <div>{store.getState()}</div>
* }
*/
export function useReduxContext() {
const contextValue = useContext(ReactReduxContext)

invariant(
contextValue,
'could not find react-redux context value; please ensure the component is wrapped in a <Provider>'
)

return contextValue
}
export const useReduxContext = createReduxContextHook()
81 changes: 53 additions & 28 deletions src/hooks/useSelector.js
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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 <div>{counter}</div>
* }
*/
export function useSelector(selector, equalityFn = refEquality) {
invariant(selector, `You must pass a selector to useSelectors`)

const { store, subscription: contextSub } = useReduxContext()
function useSelectorWithStoreAndSubscription(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I factored this out to make the diff easier to read, but can inline it again if you'd prefer.

selector,
equalityFn,
store,
contextSub
) {
const [, forceRender] = useReducer(s => s + 1, 0)

const subscription = useMemo(() => new Subscription(store, contextSub), [
Expand Down Expand Up @@ -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 <div>{counter}</div>
* }
*/
export const useSelector = createSelectorHook()
20 changes: 15 additions & 5 deletions src/hooks/useStore.js
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -15,7 +28,4 @@ import { useReduxContext } from './useReduxContext'
* return <div>{store.getState()}</div>
* }
*/
export function useStore() {
const { store } = useReduxContext()
return store
}
export const useStore = createStoreHook()
11 changes: 8 additions & 3 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -20,7 +21,11 @@ export {
connect,
batch,
useDispatch,
createDispatchHook,
useSelector,
createSelectorHook,
useStore,
createStoreHook,
createReduxContextHook,
shallowEqual
}
41 changes: 40 additions & 1 deletion test/hooks/useDispatch.spec.js
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -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 }) => (
<ProviderMock {...props} store={store}>
<ProviderMock context={nestedContext} store={store2}>
{children}
</ProviderMock>
</ProviderMock>
)
})

expect(result.current).toBe(store.dispatch)

const { result: result2 } = renderHook(() => useCustomDispatch(), {
// eslint-disable-next-line react/prop-types
wrapper: ({ children, ...props }) => (
<ProviderMock {...props} store={store}>
<ProviderMock context={nestedContext} store={store2}>
{children}
</ProviderMock>
</ProviderMock>
)
})

expect(result2.current).toBe(store2.dispatch)
})
})
})
})
55 changes: 54 additions & 1 deletion test/hooks/useSelector.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import {
Provider as ProviderMock,
useSelector,
shallowEqual,
connect
connect,
createSelectorHook,
createReduxContextHook
} from '../../src/index.js'
import { useReduxContext } from '../../src/hooks/useReduxContext'

Expand Down Expand Up @@ -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(
<ProviderMock store={defaultStore}>
<ProviderMock context={nestedContext} store={customStore}>
<DisplayCustomCount>
<DisplayDefaultCount />
</DisplayCustomCount>
</ProviderMock>
</ProviderMock>
)

expect(defaultCount).toBe(0)
expect(customCount).toBe(12)
})
})
})
})