Skip to content

Commit

Permalink
feat: implement create*Hook APIs (#1309)
Browse files Browse the repository at this point in the history
* feat: implement `create*Hook` APIs

* feat: Hook creators accept context directly

* feat: simplify custom context handling
  • Loading branch information
ryaninvents authored and timdorr committed Aug 1, 2019
1 parent 4cded48 commit 5e6205a
Show file tree
Hide file tree
Showing 7 changed files with 233 additions and 44 deletions.
34 changes: 34 additions & 0 deletions docs/api/hooks.md
Expand Up @@ -295,6 +295,40 @@ 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,
createStoreHook,
createDispatchHook,
createSelectorHook
} from 'react-redux'

const MyContext = React.createContext(null)

// Export your custom hooks if you wish to use them in other files.
export const useStore = createStoreHook(MyContext)
export const useDispatch = createDispatchHook(MyContext)
export const useSelector = createSelectorHook(MyContext)

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
@@ -1,4 +1,20 @@
import { useStore } from './useStore'
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} [context=ReactReduxContext] Context passed to your `<Provider>`.
* @returns {Function} A `useDispatch` hook bound to the specified context.
*/
export function createDispatchHook(context = ReactReduxContext) {
const useStore =
context === ReactReduxContext ? useDefaultStore : createStoreHook(context)
return function useDispatch() {
const store = useStore()
return store.dispatch
}
}

/**
* A hook to access the redux `dispatch` function.
Expand All @@ -21,7 +37,4 @@ import { useStore } from './useStore'
* )
* }
*/
export function useDispatch() {
const store = useStore()
return store.dispatch
}
export const useDispatch = createDispatchHook()
95 changes: 66 additions & 29 deletions src/hooks/useSelector.js
@@ -1,7 +1,15 @@
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'

// 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
Expand All @@ -16,33 +24,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(
selector,
equalityFn,
store,
contextSub
) {
const [, forceRender] = useReducer(s => s + 1, 0)

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

return selectedState
}

/**
* Hook factory, which creates a `useSelector` hook bound to a given context.
*
* @param {Function} [context=ReactReduxContext] Context passed to your `<Provider>`.
* @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()

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()
26 changes: 21 additions & 5 deletions src/hooks/useStore.js
@@ -1,4 +1,23 @@
import { useReduxContext } from './useReduxContext'
import { useContext } from 'react'
import { ReactReduxContext } from '../components/Context'
import { useReduxContext as useDefaultReduxContext } from './useReduxContext'

/**
* Hook factory, which creates a `useStore` hook bound to a given context.
*
* @param {Function} [context=ReactReduxContext] Context passed to your `<Provider>`.
* @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()
return store
}
}

/**
* A hook to access the redux store.
Expand All @@ -15,7 +34,4 @@ import { useReduxContext } from './useReduxContext'
* return <div>{store.getState()}</div>
* }
*/
export function useStore() {
const { store } = useReduxContext()
return store
}
export const useStore = createStoreHook()
9 changes: 6 additions & 3 deletions src/index.js
Expand Up @@ -3,9 +3,9 @@ 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 { setBatch } from './utils/batch'
import { unstable_batchedUpdates as batch } from './utils/reactBatchedUpdates'
Expand All @@ -20,7 +20,10 @@ export {
connect,
batch,
useDispatch,
createDispatchHook,
useSelector,
createSelectorHook,
useStore,
createStoreHook,
shallowEqual
}
38 changes: 37 additions & 1 deletion test/hooks/useDispatch.spec.js
@@ -1,9 +1,14 @@
import React from 'react'
import { createStore } from 'redux'
import { renderHook } from '@testing-library/react-hooks'
import { Provider as ProviderMock, useDispatch } from '../../src/index.js'
import {
Provider as ProviderMock,
useDispatch,
createDispatchHook
} from '../../src/index.js'

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

describe('React', () => {
describe('hooks', () => {
Expand All @@ -16,5 +21,36 @@ 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(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)
})
})
})
})
52 changes: 51 additions & 1 deletion test/hooks/useSelector.spec.js
Expand Up @@ -8,7 +8,8 @@ import {
Provider as ProviderMock,
useSelector,
shallowEqual,
connect
connect,
createSelectorHook
} from '../../src/index.js'
import { useReduxContext } from '../../src/hooks/useReduxContext'

Expand Down Expand Up @@ -383,5 +384,54 @@ 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(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)
})
})
})
})

0 comments on commit 5e6205a

Please sign in to comment.