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

add react hooks for accessing redux store state and dispatching redux actions #1248

Merged
merged 5 commits into from Apr 22, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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
Copy link
Contributor

Choose a reason for hiding this comment

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

My one concern here is swallowing errors when the component doesn't re-render.

Perhaps it's worth considering that "window of opportunity" approach from easy-peasy again?

The other downside is potentially losing some of the stack trace pointing back to the dispatched action.

// 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
}