-
-
Notifications
You must be signed in to change notification settings - Fork 3.4k
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
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
fdadc6e
add react hooks for accessing redux store state and dispatching redux…
MrWolfZ c525cb9
remove `useReduxContext` from public API
MrWolfZ 9c7fc93
add `useRedux` hook
MrWolfZ bc3d092
Preserve stack trace of errors inside store subscription callback
markerikson 91116ac
Alter test descriptions to use string names
markerikson File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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)] | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.