Skip to content

Commit 15ef9b9

Browse files
MrWolfZtimdorr
authored andcommitted
add react hooks for accessing redux store state and dispatching redux actions (#1248)
* add react hooks for accessing redux store state and dispatching redux actions * remove `useReduxContext` from public API * add `useRedux` hook * Preserve stack trace of errors inside store subscription callback Ported changes from react-redux-hooks-poc Note: the "transient errors" test seems flawed atm. * Alter test descriptions to use string names WebStorm won't recognize tests as runnable if `someFunc.name` is used as the `describe()` argument.
1 parent 851eb0c commit 15ef9b9

14 files changed

+878
-3
lines changed

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
"build": "npm run build:commonjs && npm run build:es && npm run build:umd && npm run build:umd:min",
3030
"clean": "rimraf lib dist es coverage",
3131
"format": "prettier --write \"{src,test}/**/*.{js,ts}\" index.d.ts \"docs/**/*.md\"",
32-
"lint": "eslint src test/utils test/components",
32+
"lint": "eslint src test/utils test/components test/hooks",
3333
"prepare": "npm run clean && npm run build",
3434
"pretest": "npm run lint",
3535
"test": "jest",

src/alternate-renderers.js

+18-1
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,26 @@ import connectAdvanced from './components/connectAdvanced'
33
import { ReactReduxContext } from './components/Context'
44
import connect from './connect/connect'
55

6+
import { useActions } from './hooks/useActions'
7+
import { useDispatch } from './hooks/useDispatch'
8+
import { useRedux } from './hooks/useRedux'
9+
import { useSelector } from './hooks/useSelector'
10+
import { useStore } from './hooks/useStore'
11+
612
import { getBatch } from './utils/batch'
713

814
// For other renderers besides ReactDOM and React Native, use the default noop batch function
915
const batch = getBatch()
1016

11-
export { Provider, connectAdvanced, ReactReduxContext, connect, batch }
17+
export {
18+
Provider,
19+
connectAdvanced,
20+
ReactReduxContext,
21+
connect,
22+
batch,
23+
useActions,
24+
useDispatch,
25+
useRedux,
26+
useSelector,
27+
useStore
28+
}

src/hooks/useActions.js

+74
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { bindActionCreators } from 'redux'
2+
import invariant from 'invariant'
3+
import { useDispatch } from './useDispatch'
4+
import { useMemo } from 'react'
5+
6+
/**
7+
* A hook to bind action creators to the redux store's `dispatch` function
8+
* similar to how redux's `bindActionCreators` works.
9+
*
10+
* Supports passing a single action creator, an array/tuple of action
11+
* creators, or an object of action creators.
12+
*
13+
* Any arguments passed to the created callbacks are passed through to
14+
* the your functions.
15+
*
16+
* This hook takes a dependencies array as an optional second argument,
17+
* which when passed ensures referential stability of the created callbacks.
18+
*
19+
* @param {Function|Function[]|Object.<string, Function>} actions the action creators to bind
20+
* @param {any[]} deps (optional) dependencies array to control referential stability
21+
*
22+
* @returns {Function|Function[]|Object.<string, Function>} callback(s) bound to store's `dispatch` function
23+
*
24+
* Usage:
25+
*
26+
```jsx
27+
import React from 'react'
28+
import { useActions } from 'react-redux'
29+
30+
const increaseCounter = ({ amount }) => ({
31+
type: 'increase-counter',
32+
amount,
33+
})
34+
35+
export const CounterComponent = ({ value }) => {
36+
// supports passing an object of action creators
37+
const { increaseCounterByOne, increaseCounterByTwo } = useActions({
38+
increaseCounterByOne: () => increaseCounter(1),
39+
increaseCounterByTwo: () => increaseCounter(2),
40+
}, [])
41+
42+
// supports passing an array/tuple of action creators
43+
const [increaseCounterByThree, increaseCounterByFour] = useActions([
44+
() => increaseCounter(3),
45+
() => increaseCounter(4),
46+
], [])
47+
48+
// supports passing a single action creator
49+
const increaseCounterBy5 = useActions(() => increaseCounter(5), [])
50+
51+
// passes through any arguments to the callback
52+
const increaseCounterByX = useActions(x => increaseCounter(x), [])
53+
54+
return (
55+
<div>
56+
<span>{value}</span>
57+
<button onClick={increaseCounterByOne}>Increase counter by 1</button>
58+
</div>
59+
)
60+
}
61+
```
62+
*/
63+
export function useActions(actions, deps) {
64+
invariant(actions, `You must pass actions to useActions`)
65+
66+
const dispatch = useDispatch()
67+
return useMemo(() => {
68+
if (Array.isArray(actions)) {
69+
return actions.map(a => bindActionCreators(a, dispatch))
70+
}
71+
72+
return bindActionCreators(actions, dispatch)
73+
}, deps)
74+
}

src/hooks/useDispatch.js

+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { useStore } from './useStore'
2+
3+
/**
4+
* A hook to access the redux `dispatch` function. Note that in most cases where you
5+
* might want to use this hook it is recommended to use `useActions` instead to bind
6+
* action creators to the `dispatch` function.
7+
*
8+
* @returns {any} redux store's `dispatch` function
9+
*
10+
* Usage:
11+
*
12+
```jsx
13+
import React, { useCallback } from 'react'
14+
import { useReduxDispatch } from 'react-redux'
15+
16+
export const CounterComponent = ({ value }) => {
17+
const dispatch = useDispatch()
18+
const increaseCounter = useCallback(() => dispatch({ type: 'increase-counter' }), [])
19+
return (
20+
<div>
21+
<span>{value}</span>
22+
<button onClick={increaseCounter}>Increase counter</button>
23+
</div>
24+
)
25+
}
26+
```
27+
*/
28+
export function useDispatch() {
29+
const store = useStore()
30+
return store.dispatch
31+
}

src/hooks/useRedux.js

+40
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { useSelector } from './useSelector'
2+
import { useActions } from './useActions'
3+
4+
/**
5+
* A hook to access the redux store's state and to bind action creators to
6+
* the store's dispatch function. In essence, this hook is a combination of
7+
* `useSelector` and `useActions`.
8+
*
9+
* @param {Function} selector the selector function
10+
* @param {Function|Function[]|Object.<string, Function>} actions the action creators to bind
11+
*
12+
* @returns {[any, any]} a tuple of the selected state and the bound action creators
13+
*
14+
* Usage:
15+
*
16+
```jsx
17+
import React from 'react'
18+
import { useRedux } from 'react-redux'
19+
20+
export const CounterComponent = () => {
21+
const [counter, { inc1, inc }] = useRedux(state => state.counter, {
22+
inc1: () => ({ type: 'inc1' }),
23+
inc: amount => ({ type: 'inc', amount }),
24+
})
25+
26+
return (
27+
<>
28+
<div>
29+
{counter}
30+
</div>
31+
<button onClick={inc1}>Increment by 1</button>
32+
<button onClick={() => inc(5)}>Increment by 5</button>
33+
</>
34+
)
35+
}
36+
```
37+
*/
38+
export function useRedux(selector, actions) {
39+
return [useSelector(selector), useActions(actions)]
40+
}

src/hooks/useReduxContext.js

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { useContext } from 'react'
2+
import invariant from 'invariant'
3+
import { ReactReduxContext } from '../components/Context'
4+
5+
/**
6+
* A hook to access the value of the `ReactReduxContext`. This is a low-level
7+
* hook that you should usually not need to call directly.
8+
*
9+
* @returns {any} the value of the `ReactReduxContext`
10+
*
11+
* Usage:
12+
*
13+
```jsx
14+
import React from 'react'
15+
import { useReduxContext } from 'react-redux'
16+
17+
export const CounterComponent = ({ value }) => {
18+
const { store } = useReduxContext()
19+
return <div>{store.getState()}</div>
20+
}
21+
```
22+
*/
23+
export function useReduxContext() {
24+
const contextValue = useContext(ReactReduxContext)
25+
26+
invariant(
27+
contextValue,
28+
'could not find react-redux context value; please ensure the component is wrapped in a <Provider>'
29+
)
30+
31+
return contextValue
32+
}

src/hooks/useSelector.js

+108
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { useReducer, useRef, useEffect, useMemo, useLayoutEffect } from 'react'
2+
import invariant from 'invariant'
3+
import { useReduxContext } from './useReduxContext'
4+
import shallowEqual from '../utils/shallowEqual'
5+
import Subscription from '../utils/Subscription'
6+
7+
// React currently throws a warning when using useLayoutEffect on the server.
8+
// To get around it, we can conditionally useEffect on the server (no-op) and
9+
// useLayoutEffect in the browser. We need useLayoutEffect to ensure the store
10+
// subscription callback always has the selector from the latest render commit
11+
// available, otherwise a store update may happen between render and the effect,
12+
// which may cause missed updates; we also must ensure the store subscription
13+
// is created synchronously, otherwise a store update may occur before the
14+
// subscription is created and an inconsistent state may be observed
15+
const useIsomorphicLayoutEffect =
16+
typeof window !== 'undefined' ? useLayoutEffect : useEffect
17+
18+
/**
19+
* A hook to access the redux store's state. This hook takes a selector function
20+
* as an argument. The selector is called with the store state.
21+
*
22+
* @param {Function} selector the selector function
23+
*
24+
* @returns {any} the selected state
25+
*
26+
* Usage:
27+
*
28+
```jsx
29+
import React from 'react'
30+
import { useSelector } from 'react-redux'
31+
32+
export const CounterComponent = () => {
33+
const counter = useSelector(state => state.counter)
34+
return <div>{counter}</div>
35+
}
36+
```
37+
*/
38+
export function useSelector(selector) {
39+
invariant(selector, `You must pass a selector to useSelectors`)
40+
41+
const { store, subscription: contextSub } = useReduxContext()
42+
const [, forceRender] = useReducer(s => s + 1, 0)
43+
44+
const subscription = useMemo(() => new Subscription(store, contextSub), [
45+
store,
46+
contextSub
47+
])
48+
49+
const latestSubscriptionCallbackError = useRef()
50+
const latestSelector = useRef(selector)
51+
52+
let selectedState = undefined
53+
54+
try {
55+
selectedState = latestSelector.current(store.getState())
56+
} catch (err) {
57+
let errorMessage = `An error occured while selecting the store state: ${
58+
err.message
59+
}.`
60+
61+
if (latestSubscriptionCallbackError.current) {
62+
errorMessage += `\nThe error may be correlated with this previous error:\n${
63+
latestSubscriptionCallbackError.current.stack
64+
}\n\nOriginal stack trace:`
65+
}
66+
67+
throw new Error(errorMessage)
68+
}
69+
70+
const latestSelectedState = useRef(selectedState)
71+
72+
useIsomorphicLayoutEffect(() => {
73+
latestSelector.current = selector
74+
latestSelectedState.current = selectedState
75+
latestSubscriptionCallbackError.current = undefined
76+
})
77+
78+
useIsomorphicLayoutEffect(() => {
79+
function checkForUpdates() {
80+
try {
81+
const newSelectedState = latestSelector.current(store.getState())
82+
83+
if (shallowEqual(newSelectedState, latestSelectedState.current)) {
84+
return
85+
}
86+
87+
latestSelectedState.current = newSelectedState
88+
} catch (err) {
89+
// we ignore all errors here, since when the component
90+
// is re-rendered, the selectors are called again, and
91+
// will throw again, if neither props nor store state
92+
// changed
93+
latestSubscriptionCallbackError.current = err
94+
}
95+
96+
forceRender({})
97+
}
98+
99+
subscription.onStateChange = checkForUpdates
100+
subscription.trySubscribe()
101+
102+
checkForUpdates()
103+
104+
return () => subscription.tryUnsubscribe()
105+
}, [store, subscription])
106+
107+
return selectedState
108+
}

src/hooks/useStore.js

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { useReduxContext } from './useReduxContext'
2+
3+
/**
4+
* A hook to access the redux store.
5+
*
6+
* @returns {any} the redux store
7+
*
8+
* Usage:
9+
*
10+
```jsx
11+
import React from 'react'
12+
import { useStore } from 'react-redux'
13+
14+
export const CounterComponent = ({ value }) => {
15+
const store = useStore()
16+
return <div>{store.getState()}</div>
17+
}
18+
```
19+
*/
20+
export function useStore() {
21+
const { store } = useReduxContext()
22+
return store
23+
}

src/index.js

+18-1
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,26 @@ import connectAdvanced from './components/connectAdvanced'
33
import { ReactReduxContext } from './components/Context'
44
import connect from './connect/connect'
55

6+
import { useActions } from './hooks/useActions'
7+
import { useDispatch } from './hooks/useDispatch'
8+
import { useRedux } from './hooks/useRedux'
9+
import { useSelector } from './hooks/useSelector'
10+
import { useStore } from './hooks/useStore'
11+
612
import { setBatch } from './utils/batch'
713
import { unstable_batchedUpdates as batch } from './utils/reactBatchedUpdates'
814

915
setBatch(batch)
1016

11-
export { Provider, connectAdvanced, ReactReduxContext, connect, batch }
17+
export {
18+
Provider,
19+
connectAdvanced,
20+
ReactReduxContext,
21+
connect,
22+
batch,
23+
useActions,
24+
useDispatch,
25+
useRedux,
26+
useSelector,
27+
useStore
28+
}

0 commit comments

Comments
 (0)