Skip to content

Commit

Permalink
add react hooks for accessing redux store state and dispatching redux…
Browse files Browse the repository at this point in the history
… 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.
  • Loading branch information
MrWolfZ authored and timdorr committed Jun 11, 2019
1 parent 851eb0c commit 15ef9b9
Show file tree
Hide file tree
Showing 14 changed files with 878 additions and 3 deletions.
2 changes: 1 addition & 1 deletion package.json
Expand Up @@ -29,7 +29,7 @@
"build": "npm run build:commonjs && npm run build:es && npm run build:umd && npm run build:umd:min",
"clean": "rimraf lib dist es coverage",
"format": "prettier --write \"{src,test}/**/*.{js,ts}\" index.d.ts \"docs/**/*.md\"",
"lint": "eslint src test/utils test/components",
"lint": "eslint src test/utils test/components test/hooks",
"prepare": "npm run clean && npm run build",
"pretest": "npm run lint",
"test": "jest",
Expand Down
19 changes: 18 additions & 1 deletion src/alternate-renderers.js
Expand Up @@ -3,9 +3,26 @@ import connectAdvanced from './components/connectAdvanced'
import { ReactReduxContext } from './components/Context'
import connect from './connect/connect'

import { useActions } from './hooks/useActions'
import { useDispatch } from './hooks/useDispatch'
import { useRedux } from './hooks/useRedux'
import { useSelector } from './hooks/useSelector'
import { useStore } from './hooks/useStore'

import { getBatch } from './utils/batch'

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

export { Provider, connectAdvanced, ReactReduxContext, connect, batch }
export {
Provider,
connectAdvanced,
ReactReduxContext,
connect,
batch,
useActions,
useDispatch,
useRedux,
useSelector,
useStore
}
74 changes: 74 additions & 0 deletions src/hooks/useActions.js
@@ -0,0 +1,74 @@
import { bindActionCreators } from 'redux'
import invariant from 'invariant'
import { useDispatch } from './useDispatch'
import { useMemo } from 'react'

/**
* A hook to bind action creators to the redux store's `dispatch` function
* similar to how redux's `bindActionCreators` works.
*
* Supports passing a single action creator, an array/tuple of action
* creators, or an object of action creators.
*
* Any arguments passed to the created callbacks are passed through to
* the your functions.
*
* This hook takes a dependencies array as an optional second argument,
* which when passed ensures referential stability of the created callbacks.
*
* @param {Function|Function[]|Object.<string, Function>} actions the action creators to bind
* @param {any[]} deps (optional) dependencies array to control referential stability
*
* @returns {Function|Function[]|Object.<string, Function>} callback(s) bound to store's `dispatch` function
*
* Usage:
*
```jsx
import React from 'react'
import { useActions } from 'react-redux'
const increaseCounter = ({ amount }) => ({
type: 'increase-counter',
amount,
})
export const CounterComponent = ({ value }) => {
// supports passing an object of action creators
const { increaseCounterByOne, increaseCounterByTwo } = useActions({
increaseCounterByOne: () => increaseCounter(1),
increaseCounterByTwo: () => increaseCounter(2),
}, [])
// supports passing an array/tuple of action creators
const [increaseCounterByThree, increaseCounterByFour] = useActions([
() => increaseCounter(3),
() => increaseCounter(4),
], [])
// supports passing a single action creator
const increaseCounterBy5 = useActions(() => increaseCounter(5), [])
// passes through any arguments to the callback
const increaseCounterByX = useActions(x => increaseCounter(x), [])
return (
<div>
<span>{value}</span>
<button onClick={increaseCounterByOne}>Increase counter by 1</button>
</div>
)
}
```
*/
export function useActions(actions, deps) {
invariant(actions, `You must pass actions to useActions`)

const dispatch = useDispatch()
return useMemo(() => {
if (Array.isArray(actions)) {
return actions.map(a => bindActionCreators(a, dispatch))
}

return bindActionCreators(actions, dispatch)
}, deps)
}
31 changes: 31 additions & 0 deletions src/hooks/useDispatch.js
@@ -0,0 +1,31 @@
import { useStore } from './useStore'

/**
* A hook to access the redux `dispatch` function. Note that in most cases where you
* might want to use this hook it is recommended to use `useActions` instead to bind
* action creators to the `dispatch` function.
*
* @returns {any} redux store's `dispatch` function
*
* Usage:
*
```jsx
import React, { useCallback } from 'react'
import { useReduxDispatch } from 'react-redux'
export const CounterComponent = ({ value }) => {
const dispatch = useDispatch()
const increaseCounter = useCallback(() => dispatch({ type: 'increase-counter' }), [])
return (
<div>
<span>{value}</span>
<button onClick={increaseCounter}>Increase counter</button>
</div>
)
}
```
*/
export function useDispatch() {
const store = useStore()
return store.dispatch
}
40 changes: 40 additions & 0 deletions src/hooks/useRedux.js
@@ -0,0 +1,40 @@
import { useSelector } from './useSelector'
import { useActions } from './useActions'

/**
* A hook to access the redux store's state and to bind action creators to
* the store's dispatch function. In essence, this hook is a combination of
* `useSelector` and `useActions`.
*
* @param {Function} selector the selector function
* @param {Function|Function[]|Object.<string, Function>} actions the action creators to bind
*
* @returns {[any, any]} a tuple of the selected state and the bound action creators
*
* Usage:
*
```jsx
import React from 'react'
import { useRedux } from 'react-redux'
export const CounterComponent = () => {
const [counter, { inc1, inc }] = useRedux(state => state.counter, {
inc1: () => ({ type: 'inc1' }),
inc: amount => ({ type: 'inc', amount }),
})
return (
<>
<div>
{counter}
</div>
<button onClick={inc1}>Increment by 1</button>
<button onClick={() => inc(5)}>Increment by 5</button>
</>
)
}
```
*/
export function useRedux(selector, actions) {
return [useSelector(selector), useActions(actions)]
}
32 changes: 32 additions & 0 deletions src/hooks/useReduxContext.js
@@ -0,0 +1,32 @@
import { useContext } from 'react'
import invariant from 'invariant'
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.
*
* @returns {any} the value of the `ReactReduxContext`
*
* Usage:
*
```jsx
import React from 'react'
import { useReduxContext } from 'react-redux'
export const CounterComponent = ({ value }) => {
const { store } = useReduxContext()
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
}
108 changes: 108 additions & 0 deletions src/hooks/useSelector.js
@@ -0,0 +1,108 @@
import { useReducer, useRef, useEffect, useMemo, useLayoutEffect } from 'react'
import invariant from 'invariant'
import { useReduxContext } from './useReduxContext'
import shallowEqual from '../utils/shallowEqual'
import Subscription from '../utils/Subscription'

// 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
// useLayoutEffect in the browser. We need useLayoutEffect to ensure the store
// subscription callback always has the selector from the latest render commit
// available, otherwise a store update may happen between render and the effect,
// which may cause missed updates; we also must ensure the store subscription
// is created synchronously, otherwise a store update may occur before the
// subscription is created and an inconsistent state may be observed
const useIsomorphicLayoutEffect =
typeof window !== 'undefined' ? useLayoutEffect : useEffect

/**
* 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.
*
* @param {Function} selector the selector function
*
* @returns {any} the selected state
*
* Usage:
*
```jsx
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) {
invariant(selector, `You must pass a selector to useSelectors`)

const { store, subscription: contextSub } = useReduxContext()
const [, forceRender] = useReducer(s => s + 1, 0)

const subscription = useMemo(() => new Subscription(store, contextSub), [
store,
contextSub
])

const latestSubscriptionCallbackError = useRef()
const latestSelector = useRef(selector)

let selectedState = undefined

try {
selectedState = latestSelector.current(store.getState())
} catch (err) {
let errorMessage = `An error occured while selecting the store state: ${
err.message
}.`

if (latestSubscriptionCallbackError.current) {
errorMessage += `\nThe error may be correlated with this previous error:\n${
latestSubscriptionCallbackError.current.stack
}\n\nOriginal stack trace:`
}

throw new Error(errorMessage)
}

const latestSelectedState = useRef(selectedState)

useIsomorphicLayoutEffect(() => {
latestSelector.current = selector
latestSelectedState.current = selectedState
latestSubscriptionCallbackError.current = undefined
})

useIsomorphicLayoutEffect(() => {
function checkForUpdates() {
try {
const newSelectedState = latestSelector.current(store.getState())

if (shallowEqual(newSelectedState, latestSelectedState.current)) {
return
}

latestSelectedState.current = newSelectedState
} catch (err) {
// we ignore all errors here, since when the component
// is re-rendered, the selectors are called again, and
// will throw again, if neither props nor store state
// changed
latestSubscriptionCallbackError.current = err
}

forceRender({})
}

subscription.onStateChange = checkForUpdates
subscription.trySubscribe()

checkForUpdates()

return () => subscription.tryUnsubscribe()
}, [store, subscription])

return selectedState
}
23 changes: 23 additions & 0 deletions src/hooks/useStore.js
@@ -0,0 +1,23 @@
import { useReduxContext } from './useReduxContext'

/**
* A hook to access the redux store.
*
* @returns {any} the redux store
*
* Usage:
*
```jsx
import React from 'react'
import { useStore } from 'react-redux'
export const CounterComponent = ({ value }) => {
const store = useStore()
return <div>{store.getState()}</div>
}
```
*/
export function useStore() {
const { store } = useReduxContext()
return store
}
19 changes: 18 additions & 1 deletion src/index.js
Expand Up @@ -3,9 +3,26 @@ import connectAdvanced from './components/connectAdvanced'
import { ReactReduxContext } from './components/Context'
import connect from './connect/connect'

import { useActions } from './hooks/useActions'
import { useDispatch } from './hooks/useDispatch'
import { useRedux } from './hooks/useRedux'
import { useSelector } from './hooks/useSelector'
import { useStore } from './hooks/useStore'

import { setBatch } from './utils/batch'
import { unstable_batchedUpdates as batch } from './utils/reactBatchedUpdates'

setBatch(batch)

export { Provider, connectAdvanced, ReactReduxContext, connect, batch }
export {
Provider,
connectAdvanced,
ReactReduxContext,
connect,
batch,
useActions,
useDispatch,
useRedux,
useSelector,
useStore
}

0 comments on commit 15ef9b9

Please sign in to comment.