├── .editorconfig ├── .gitignore ├── .prettierrc ├── .travis.yml ├── .watchmanconfig ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── example ├── .gitignore ├── package.json ├── public │ └── index.html ├── src │ ├── App.tsx │ ├── Store.ts │ ├── TodoInput.tsx │ ├── TodoItem.tsx │ ├── TodoList.tsx │ ├── index.tsx │ ├── react-app-env.d.ts │ └── reducer.ts ├── tsconfig.json ├── tsconfig.prod.json ├── tsconfig.test.json ├── tslint.json └── yarn.lock ├── package.json ├── rollup.config.js ├── src ├── __tests__ │ └── index-test.tsx ├── index.ts ├── react-app-env.d.ts └── shallowEqual.ts ├── tsconfig.json ├── tsconfig.test.json ├── tslint.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # See https://help.github.com/ignore-files/ for more about ignoring files. 3 | 4 | # dependencies 5 | node_modules 6 | 7 | # builds 8 | build 9 | coverage 10 | dist 11 | .rpt2_cache 12 | 13 | # misc 14 | .DS_Store 15 | .env 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": false, 3 | "printWidth": 80, 4 | "tabWidth": 2, 5 | "singleQuote": true, 6 | "trailingComma": "all", 7 | "bracketSpacing": false, 8 | "jsxBracketSameLine": true 9 | } 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 'node' 4 | cache: 5 | directories: 6 | - node_modules 7 | script: 8 | - yarn build 9 | - yarn test 10 | -------------------------------------------------------------------------------- /.watchmanconfig: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gaearon/redux-react-hook/0d5a12779228da6d7a6a7a70783b1660fbf5ba9d/.watchmanconfig -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog for redux-react-hook 2 | 3 | ## v3.0.2 4 | 5 | Jan 20, 2019 6 | 7 | - Add types field to package.json (thanks @Mrtenz!) 8 | - Docs update to not condemn multiple useMappedState calls 9 | 10 | ## v3.0.1 11 | 12 | Dec 26, 2018 13 | 14 | - Update docs and tests 15 | 16 | ## v3.0.0 17 | 18 | Dec 6, 2018 19 | 20 | - Breaking Change: Export `StoreContext` instead of just `StoreProvider` to allow access to the store in context outside of `redux-react-hook`. To update, replace imports of `StoreProvider` with `StoreContext` and usage of `` with `` 21 | 22 | ## v2.0.0 23 | 24 | Oct 30, 2018 25 | 26 | - Breaking Change: Export `StoreProvider` instead of requiring you to pass in context 27 | 28 | ## v1.0.0 29 | 30 | Oct 25, 2018 31 | 32 | - Initial release 33 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | Facebook has adopted a Code of Conduct that we expect project participants to adhere to. Please [read the full text](https://code.facebook.com/codeofconduct) so that you can understand what actions will and will not be tolerated. 4 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to redux-react-hook 2 | We want to make contributing to this project as easy and transparent as 3 | possible. 4 | 5 | ## Our Development Process 6 | All development is done in the open on GitHub. 7 | 8 | ## Pull Requests 9 | We actively welcome your pull requests. 10 | 11 | 1. Fork the repo and create your branch from `master`. 12 | 2. If you've added code that should be tested, add tests. 13 | 3. If you've changed APIs, update the documentation. 14 | 4. Ensure the test suite passes. 15 | 5. Make sure your code lints. 16 | 6. If you haven't already, complete the Contributor License Agreement ("CLA"). 17 | 18 | ## Contributor License Agreement ("CLA") 19 | In order to accept your pull request, we need you to submit a CLA. You only need 20 | to do this once to work on any of Facebook's open source projects. 21 | 22 | Complete your CLA here: 23 | 24 | ## Issues 25 | We use GitHub issues to track public bugs. Please ensure your description is 26 | clear and has sufficient instructions to be able to reproduce the issue. 27 | 28 | Facebook has a [bounty program](https://www.facebook.com/whitehat/) for the safe 29 | disclosure of security bugs. In those cases, please go through the process 30 | outlined on that page and do not file a public issue. 31 | 32 | ## Coding Style 33 | Format your code with Prettier -- it is also enforced by a git hook. 34 | 35 | ## License 36 | By contributing to redux-react-hook, you agree that your contributions will be licensed 37 | under the LICENSE file in the root directory of this source tree. 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Facebook, Inc. and its affiliates. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # redux-react-hook 2 | 3 | > React hook for accessing mapped state and dispatch from a Redux store. 4 | 5 | [![Build Status](https://travis-ci.com/facebookincubator/redux-react-hook.svg?branch=master)](https://travis-ci.com/facebookincubator/redux-react-hook) 6 | [![NPM](https://img.shields.io/npm/v/redux-react-hook.svg)](https://www.npmjs.com/package/redux-react-hook) 7 | [![Bundle Size](https://badgen.net/bundlephobia/minzip/redux-react-hook@latest)](https://bundlephobia.com/result?p=redux-react-hook@latest) 8 | 9 | ## Table of Contents 10 | 11 | - [Install](#install) 12 | - [Quick Start](#quick-start) 13 | - [Usage](#usage) 14 | - [`StoreContext`](#storecontext) 15 | - [`useMappedState(mapState)`](#usemappedstatemapstate) 16 | - [`useDispatch()`](#usedispatch) 17 | - [Example](#example) 18 | - [FAQ](#faq) 19 | - [More info](#more-info) 20 | - [Thanks](#thanks) 21 | - [Contributing](#contributing) 22 | - [License](#license) 23 | 24 | ## Install 25 | 26 | ```bash 27 | # Yarn 28 | yarn add redux-react-hook 29 | 30 | # NPM 31 | npm install --save redux-react-hook 32 | ``` 33 | 34 | ## Quick Start 35 | 36 | ```tsx 37 | // 38 | // Bootstrap your app 39 | // 40 | import {StoreContext} from 'redux-react-hook'; 41 | 42 | ReactDOM.render( 43 | 44 | 45 | , 46 | document.getElementById('root'), 47 | ); 48 | ``` 49 | 50 | ```tsx 51 | // 52 | // Individual components 53 | // 54 | import {useDispatch, useMappedState} from 'redux-react-hook'; 55 | 56 | export function DeleteButton({index}) { 57 | // Declare your memoized mapState function 58 | const mapState = useCallback( 59 | state => ({ 60 | canDelete: state.todos[index].canDelete, 61 | name: state.todos[index].name, 62 | }), 63 | [index], 64 | ); 65 | 66 | // Get data from and subscribe to the store 67 | const {canDelete, name} = useMappedState(mapState); 68 | 69 | // Create actions 70 | const dispatch = useDispatch(); 71 | const deleteTodo = useCallback( 72 | () => 73 | dispatch({ 74 | type: 'delete todo', 75 | index, 76 | }), 77 | [index], 78 | ); 79 | 80 | return ( 81 | 84 | ); 85 | } 86 | ``` 87 | 88 | ## Usage 89 | 90 | NOTE: React hooks currently require `react` and `react-dom` version `16.7.0-alpha.0` or higher. 91 | 92 | ### `StoreContext` 93 | 94 | Before you can use the hook, you must provide your Redux store via `StoreContext.Provider`: 95 | 96 | ```tsx 97 | import {createStore} from 'redux'; 98 | import {StoreContext} from 'redux-react-hook'; 99 | import reducer from './reducer'; 100 | 101 | const store = createStore(reducer); 102 | 103 | ReactDOM.render( 104 | 105 | 106 | , 107 | document.getElementById('root'), 108 | ); 109 | ``` 110 | 111 | You can also use the `StoreContext` to access the store directly, which is useful for event handlers that only need more state when they are triggered: 112 | 113 | ```tsx 114 | import {useContext} from 'react'; 115 | import {StoreContext} from 'redux-react-hook'; 116 | 117 | function Component() { 118 | const store = useContext(StoreContext); 119 | const onClick = useCallback(() => { 120 | const value = selectExpensiveValue(store.getState()); 121 | alert('Value: ' + value); 122 | }); 123 | return
; 124 | } 125 | ``` 126 | 127 | ### `useMappedState(mapState)` 128 | 129 | Runs the given `mapState` function against your store state, just like 130 | `mapStateToProps`. 131 | 132 | ```tsx 133 | const state = useMappedState(mapState); 134 | ``` 135 | 136 | You can use props or other component state in your `mapState` function. It must be memoized with `useCallback`, because `useMappedState` will infinitely recurse if you pass in a new mapState function every time. 137 | 138 | ```tsx 139 | import {useMappedState} from 'redux-react-hook'; 140 | 141 | function TodoItem({index}) { 142 | // Note that we pass the index as a dependency parameter -- this causes 143 | // useCallback to return the same function every time unless index changes. 144 | const mapState = useCallback(state => state.todos[index], [index]); 145 | const todo = useMappedState(mapState); 146 | 147 | return
  • {todo}
  • ; 148 | } 149 | ``` 150 | 151 | If you don't have any inputs (the second argument to `useCallback`) pass an empty array `[]` so React uses the same function instance each render. You could also declare `mapState` outside of the function, but the React team does not recommend it, since the whole point of hooks is to allow you to keep everything in the component. 152 | 153 | NOTE: Every call to `useMappedState` will subscribe to the store. If the store updates, though, your component will only re-render once. So, calling `useMappedState` more than once (for example encapsulated inside a custom hook) should not have a large performance impact. If your measurements show a performance impact, you can switch to returning an object instead. 154 | 155 | ### `useDispatch()` 156 | 157 | Simply returns the dispatch method. 158 | 159 | ```tsx 160 | import {useDispatch} from 'redux-react-hook'; 161 | 162 | function DeleteButton({index}) { 163 | const dispatch = useDispatch(); 164 | const deleteTodo = useCallback(() => dispatch({type: 'delete todo', index}), [ 165 | index, 166 | ]); 167 | 168 | return ; 169 | } 170 | ``` 171 | 172 | ## Example 173 | 174 | You can try out `redux-react-hook` right in your browser with the [Codesandbox example](https://codesandbox.io/s/github/ianobermiller/redux-react-hook-example). 175 | 176 | To run the example project locally: 177 | 178 | ```bash 179 | # In one terminal, run `yarn start` in the root to rebuild the library itself 180 | cd ./redux-react-example 181 | yarn start 182 | 183 | # In another terminal, run `yarn start` in the `example` folder 184 | cd example 185 | yarn start 186 | ``` 187 | 188 | ## FAQ 189 | 190 | ### How does this compare to React Redux? 191 | 192 | `redux-react-hook` has not been battle and perf-tested, so we don't recommend replacing [`react-redux`](https://github.com/reduxjs/react-redux) just yet. React Redux also guarantees that [data flows top down](https://medium.com/@kj_huang/implementation-of-react-redux-part-2-633441bd3306), so that child components update after their parents, which the hook does not. 193 | 194 | ### How do I fix the error "Too many re-renders. React limits the number of renders to prevent an infinite loop." 195 | 196 | You're not memoizing the `mapState` function. Either declare it outside of your 197 | stateless functional component or wrap it in `useCallback` to avoid creating a 198 | new function every render. 199 | 200 | ## More info 201 | 202 | Hooks are really new, and we are just beginning to see what people do with them. There is an [open issue on `react-redux`](https://github.com/reduxjs/react-redux/issues/1063) discussing the potential. Here are some other projects that are adding hooks for Redux: 203 | 204 | - [`use-substate`](https://github.com/philipp-spiess/use-substate) 205 | - [`react-use-redux`](https://github.com/martynaskadisa/react-use-redux) 206 | - [`react-use-dux`](https://github.com/richardpj/react-use-dux) 207 | - [`react-use-redux-state`](https://github.com/pinyin/react-use-redux-state) 208 | 209 | ## Thanks 210 | 211 | Special thanks to @sawyerhood and @sophiebits for writing most of the initial implementation! This repo was setup with the help of the excellent [`create-react-library`](https://www.npmjs.com/package/create-react-library). 212 | 213 | ## Contributing 214 | 215 | Contributions are definitely welcome! Check out the [issues](https://github.com/facebookincubator/redux-react-hook/issues) 216 | for ideas on where you can contribute. See the [CONTRIBUTING.md](CONTRIBUTING.md) file for more details. 217 | 218 | ## License 219 | 220 | MIT © Facebook Inc. 221 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | /src/redux-react-hook 12 | 13 | # misc 14 | .DS_Store 15 | .env.local 16 | .env.development.local 17 | .env.test.local 18 | .env.production.local 19 | 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-react-hook-example", 3 | "homepage": "https://ianobermiller.github.io/redux-react-hook", 4 | "version": "0.0.0", 5 | "license": "MIT", 6 | "private": true, 7 | "dependencies": { 8 | "@types/jest": "^23.3.10", 9 | "@types/node": "^10.12.12", 10 | "emotion": "^9.2.12", 11 | "react": "^16.7.0-alpha.2", 12 | "react-dom": "^16.7.0-alpha.2", 13 | "react-scripts": "^2.1.1", 14 | "redux": "^4.0.1" 15 | }, 16 | "scripts": { 17 | "start": "react-scripts start", 18 | "build": "react-scripts build", 19 | "test": "react-scripts test --env=jsdom", 20 | "eject": "react-scripts eject" 21 | }, 22 | "devDependencies": { 23 | "@types/react": "^16.7.13", 24 | "@types/react-dom": "^16.0.11", 25 | "typescript": "^3.2.2" 26 | }, 27 | "browserslist": [ 28 | ">0.2%", 29 | "not dead", 30 | "not ie <= 11", 31 | "not op_mini all" 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /example/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | redux-react-hook 10 | 11 | 12 | 13 | 16 | 17 |
    18 | 19 | 20 | -------------------------------------------------------------------------------- /example/src/App.tsx: -------------------------------------------------------------------------------- 1 | // Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved 2 | 3 | import {css} from 'emotion'; 4 | import React from 'react'; 5 | import TodoInput from './TodoInput'; 6 | import TodoList from './TodoList'; 7 | 8 | export default function App() { 9 | return ( 10 |
    11 |

    Todo

    12 | 13 | 14 |
    15 | ); 16 | } 17 | 18 | const styles = { 19 | root: css` 20 | box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2), 0 25px 50px 0 rgba(0, 0, 0, 0.1); 21 | font-family: system-ui; 22 | margin: 24px auto; 23 | padding: 4px 24px 24px 24px; 24 | width: 300px; 25 | `, 26 | }; 27 | -------------------------------------------------------------------------------- /example/src/Store.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved 2 | 3 | import {Action, createStore, Store} from 'redux'; 4 | import reducer from './reducer'; 5 | 6 | export interface IState { 7 | lastUpdated: number; 8 | todos: string[]; 9 | } 10 | 11 | export type Action = 12 | | { 13 | type: 'add todo'; 14 | todo: string; 15 | } 16 | | { 17 | type: 'delete todo'; 18 | index: number; 19 | }; 20 | 21 | export function makeStore(): Store { 22 | return createStore(reducer, INITIAL_STATE); 23 | } 24 | 25 | export const INITIAL_STATE: IState = { 26 | lastUpdated: 0, 27 | todos: [ 28 | 'Make the fire!', 29 | 'Fix the breakfast!', 30 | 'Wash the dishes!', 31 | 'Do the mopping!', 32 | ], 33 | }; 34 | -------------------------------------------------------------------------------- /example/src/TodoInput.tsx: -------------------------------------------------------------------------------- 1 | // Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved 2 | 3 | import {css} from 'emotion'; 4 | import React, {useState} from 'react'; 5 | import {useDispatch} from './redux-react-hook'; 6 | 7 | export default function TodoInput() { 8 | const [newTodo, setNewTodo] = useState(''); 9 | const dispatch = useDispatch(); 10 | 11 | return ( 12 | setNewTodo(e.target.value)} 16 | onKeyDown={e => { 17 | if (e.key === 'Enter') { 18 | dispatch({type: 'add todo', todo: newTodo}); 19 | setNewTodo(''); 20 | } 21 | }} 22 | placeholder="What needs to be done?" 23 | value={newTodo} 24 | /> 25 | ); 26 | } 27 | 28 | const styles = { 29 | root: css` 30 | box-sizing: border-box; 31 | font-size: 16px; 32 | margin-bottom: 24px; 33 | padding: 8px 12px; 34 | width: 100%; 35 | `, 36 | }; 37 | -------------------------------------------------------------------------------- /example/src/TodoItem.tsx: -------------------------------------------------------------------------------- 1 | // Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved 2 | 3 | import {css} from 'emotion'; 4 | import React, {useCallback} from 'react'; 5 | import {useDispatch, useMappedState} from './redux-react-hook'; 6 | import {IState} from './Store'; 7 | 8 | export default function TodoItem({index}: {index: number}) { 9 | const {todo, deleteTodo} = useTodo(index); 10 | 11 | return ( 12 |
  • 13 | {todo} 14 | 15 |
  • 16 | ); 17 | } 18 | 19 | // Example of creating a custom hook to encapsulate the store 20 | function useTodo(index: number): {todo: string; deleteTodo: () => void} { 21 | const todo = useMappedState( 22 | useCallback((state: IState) => state.todos[index], [index]), 23 | ); 24 | 25 | const dispatch = useDispatch(); 26 | const deleteTodo = useCallback(() => dispatch({type: 'delete todo', index}), [ 27 | index, 28 | ]); 29 | return {todo, deleteTodo}; 30 | } 31 | 32 | const styles = { 33 | root: css` 34 | display: flex; 35 | justify-content: space-between; 36 | list-style-type: none; 37 | margin: 0; 38 | padding: 8px 12px; 39 | 40 | &:hover { 41 | background-color: #efefef; 42 | 43 | button { 44 | display: block; 45 | } 46 | } 47 | 48 | button { 49 | display: none; 50 | } 51 | `, 52 | }; 53 | -------------------------------------------------------------------------------- /example/src/TodoList.tsx: -------------------------------------------------------------------------------- 1 | // Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved 2 | 3 | import {css} from 'emotion'; 4 | import React, {useCallback} from 'react'; 5 | import {useMappedState} from './redux-react-hook'; 6 | import {IState} from './Store'; 7 | import TodoItem from './TodoItem'; 8 | 9 | export default function TodoList() { 10 | const {lastUpdated, todoCount} = useMappedState( 11 | useCallback( 12 | (state: IState) => ({ 13 | lastUpdated: state.lastUpdated, 14 | todoCount: state.todos.length, 15 | }), 16 | [], 17 | ), 18 | ); 19 | return ( 20 |
    21 |
    You have {todoCount} todos
    22 |
      23 | {new Array(todoCount).fill(null).map((_, index) => ( 24 | 25 | ))} 26 |
    27 |
    28 | Last updated: {lastUpdated ? new Date(lastUpdated).toString() : 'never'} 29 |
    30 |
    31 | ); 32 | } 33 | 34 | const styles = { 35 | count: css` 36 | font-size: 12px; 37 | `, 38 | lastUpdated: css` 39 | color: #666; 40 | font-size: 10px; 41 | `, 42 | todos: css` 43 | padding-left: 0; 44 | `, 45 | }; 46 | -------------------------------------------------------------------------------- /example/src/index.tsx: -------------------------------------------------------------------------------- 1 | // Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved 2 | 3 | import React from 'react'; 4 | import ReactDOM from 'react-dom'; 5 | import {StoreContext} from './redux-react-hook'; 6 | 7 | import App from './App'; 8 | import {makeStore} from './Store'; 9 | 10 | const store = makeStore(); 11 | 12 | ReactDOM.render( 13 | 14 | 15 | , 16 | document.getElementById('root'), 17 | ); 18 | -------------------------------------------------------------------------------- /example/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved 2 | 3 | /// 4 | -------------------------------------------------------------------------------- /example/src/reducer.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved 2 | 3 | import {Action, IState, INITIAL_STATE} from './Store'; 4 | 5 | export default function reducer(state: IState = INITIAL_STATE, action: Action) { 6 | switch (action.type) { 7 | case 'add todo': { 8 | return { 9 | ...state, 10 | lastUpdated: Date.now(), 11 | todos: state.todos.concat(action.todo), 12 | }; 13 | } 14 | 15 | case 'delete todo': { 16 | const todos = state.todos.slice(); 17 | todos.splice(action.index, 1); 18 | return {...state, lastUpdated: Date.now(), todos}; 19 | } 20 | 21 | default: 22 | return state; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "allowSyntheticDefaultImports": true, 5 | "esModuleInterop": true, 6 | "forceConsistentCasingInFileNames": true, 7 | "importHelpers": true, 8 | "isolatedModules": true, 9 | "jsx": "preserve", 10 | "lib": ["es6", "dom"], 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "noEmit": true, 14 | "noImplicitAny": true, 15 | "noImplicitReturns": true, 16 | "noImplicitThis": true, 17 | "noUnusedLocals": true, 18 | "outDir": "build/dist", 19 | "resolveJsonModule": true, 20 | "rootDir": "src", 21 | "skipLibCheck": false, 22 | "sourceMap": true, 23 | "strict": true, 24 | "strictNullChecks": true, 25 | "suppressImplicitAnyIndexErrors": true, 26 | "target": "es5" 27 | }, 28 | "exclude": [ 29 | "acceptance-tests", 30 | "build", 31 | "jest", 32 | "node_modules", 33 | "scripts", 34 | "src/setupTests.ts", 35 | "webpack" 36 | ], 37 | "include": ["src"] 38 | } 39 | -------------------------------------------------------------------------------- /example/tsconfig.prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json" 3 | } -------------------------------------------------------------------------------- /example/tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs" 5 | } 6 | } -------------------------------------------------------------------------------- /example/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["tslint:recommended", "tslint-react", "tslint-config-prettier"], 3 | "linterOptions": { 4 | "exclude": [ 5 | "config/**/*.js", 6 | "node_modules/**/*.ts", 7 | "coverage/lcov-report/*.js" 8 | ] 9 | }, 10 | "rules": { 11 | "jsx-no-lambda": false 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-react-hook", 3 | "version": "3.0.2", 4 | "description": "React hook for accessing a Redux store.", 5 | "author": { 6 | "name": "Ian Obermiller", 7 | "email": "ian@obermillers.com", 8 | "url": "https://ianobermiller.com/" 9 | }, 10 | "license": "MIT", 11 | "repository": "https://github.com/facebookincubator/redux-react-hook.git", 12 | "main": "dist/index.js", 13 | "module": "dist/index.es.js", 14 | "jsnext:main": "dist/index.es.js", 15 | "types": "dist/index.d.ts", 16 | "engines": { 17 | "node": ">=8", 18 | "npm": ">=5" 19 | }, 20 | "scripts": { 21 | "test": "react-scripts test --env=jsdom", 22 | "test:watch": "react-scripts test --env=jsdom", 23 | "prettier": "prettier --config .prettierrc --write \"{src,example}/**/*.{js,ts,tsx}\"", 24 | "build": "rollup -c", 25 | "start": "rollup -c -w", 26 | "prepare": "yarn run prettier && yarn run build", 27 | "predeploy": "cd example && yarn install && yarn run build", 28 | "deploy": "gh-pages -d example/build" 29 | }, 30 | "peerDependencies": { 31 | "prop-types": "^15.5.4", 32 | "react": "^16.7.0-alpha.2", 33 | "react-dom": "^16.7.0-alpha.2", 34 | "redux": "^4.0.1" 35 | }, 36 | "devDependencies": { 37 | "@types/jest": "^23.3.10", 38 | "@types/node": "^10.12.12", 39 | "@types/react": "^16.7.13", 40 | "@types/react-dom": "^16.0.11", 41 | "babel-core": "^6.26.3", 42 | "babel-runtime": "^6.26.0", 43 | "cross-env": "^5.1.4", 44 | "gh-pages": "^2.0.1", 45 | "prettier": "^1.14.3", 46 | "react": "^16.7.0-alpha.2", 47 | "react-dom": "^16.7.0-alpha.2", 48 | "react-scripts": "^2.1.1", 49 | "redux": "^4.0.1", 50 | "rollup": "^0.66.6", 51 | "rollup-plugin-babel": "^4.0.3", 52 | "rollup-plugin-commonjs": "^9.1.3", 53 | "rollup-plugin-cpy": "^1.1.0", 54 | "rollup-plugin-node-resolve": "^3.3.0", 55 | "rollup-plugin-peer-deps-external": "^2.2.0", 56 | "rollup-plugin-typescript2": "^0.17.2", 57 | "rollup-plugin-url": "^2.0.1", 58 | "typescript": "^3.2.2" 59 | }, 60 | "files": [ 61 | "dist" 62 | ] 63 | } 64 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved 2 | 3 | import typescript from 'rollup-plugin-typescript2'; 4 | import commonjs from 'rollup-plugin-commonjs'; 5 | import copy from 'rollup-plugin-cpy'; 6 | import external from 'rollup-plugin-peer-deps-external'; 7 | import resolve from 'rollup-plugin-node-resolve'; 8 | import url from 'rollup-plugin-url'; 9 | 10 | import pkg from './package.json'; 11 | 12 | export default { 13 | input: 'src/index.ts', 14 | output: [ 15 | { 16 | file: pkg.main, 17 | format: 'cjs', 18 | exports: 'named', 19 | sourcemap: true, 20 | }, 21 | { 22 | file: pkg.module, 23 | format: 'es', 24 | exports: 'named', 25 | sourcemap: true, 26 | }, 27 | ], 28 | plugins: [ 29 | external(), 30 | url(), 31 | resolve(), 32 | typescript({ 33 | clean: true, 34 | rollupCommonJSResolveHack: true, 35 | exclude: ['*.d.ts', '**/*.d.ts'], 36 | }), 37 | commonjs(), 38 | copy([ 39 | // The example uses create-react-app (via create-react-library), which 40 | // doesn't work correctly with yarn or npm links. It will end up with 41 | // two versions of React in the build, which breaks hooks in particular 42 | // since they rely on global state. To avoid this problem we simply copy 43 | // the source directly into the example project. 44 | // 45 | // For more info about the issue: 46 | // https://stackoverflow.com/questions/31169760/how-to-avoid-react-loading-twice-with-webpack-when-developing 47 | { 48 | files: ['src/index.ts', 'src/shallowEqual.ts'], 49 | dest: 'example/src/redux-react-hook/', 50 | }, 51 | ]), 52 | ], 53 | }; 54 | -------------------------------------------------------------------------------- /src/__tests__/index-test.tsx: -------------------------------------------------------------------------------- 1 | // Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved 2 | 3 | import * as React from 'react'; 4 | import * as ReactDOM from 'react-dom'; 5 | import {Store} from 'redux'; 6 | import {StoreContext, useMappedState} from '..'; 7 | 8 | interface IAction { 9 | type: 'add todo'; 10 | } 11 | 12 | interface IState { 13 | bar: number; 14 | foo: string; 15 | } 16 | 17 | describe('redux-react-hook', () => { 18 | let subscriberCallback: () => void; 19 | let state: IState; 20 | let cancelSubscription: () => void; 21 | let store: Store; 22 | let reactRoot: HTMLDivElement; 23 | 24 | const createStore = (): Store => ({ 25 | dispatch: (action: any) => action, 26 | getState: () => state, 27 | subscribe: jest.fn((l: () => void) => { 28 | subscriberCallback = l; 29 | return cancelSubscription; 30 | }), 31 | // tslint:disable-next-line:no-empty 32 | replaceReducer() {}, 33 | }); 34 | 35 | beforeEach(() => { 36 | cancelSubscription = jest.fn(); 37 | state = {bar: 123, foo: 'bar'}; 38 | store = createStore(); 39 | 40 | reactRoot = document.createElement('div'); 41 | document.body.appendChild(reactRoot); 42 | }); 43 | 44 | afterEach(() => { 45 | document.body.removeChild(reactRoot); 46 | }); 47 | 48 | function render(element: React.ReactElement) { 49 | ReactDOM.render( 50 | {element}, 51 | reactRoot, 52 | ); 53 | } 54 | 55 | function getText() { 56 | return reactRoot.textContent; 57 | } 58 | 59 | it('renders with state from the store', () => { 60 | const mapState = (s: IState) => s.foo; 61 | const Component = () => { 62 | const foo = useMappedState(mapState); 63 | return
    {foo}
    ; 64 | }; 65 | 66 | render(); 67 | 68 | expect(getText()).toBe('bar'); 69 | }); 70 | 71 | it('rerenders with new state when the subscribe callback is called', () => { 72 | const mapState = (s: IState) => s.foo; 73 | const Component = () => { 74 | const foo = useMappedState(mapState); 75 | return
    {foo}
    ; 76 | }; 77 | 78 | render(); 79 | 80 | state = {bar: 123, foo: 'foo'}; 81 | subscriberCallback(); 82 | 83 | expect(getText()).toBe('foo'); 84 | }); 85 | 86 | it('cancels subscription on unmount', () => { 87 | const mapState = (s: IState) => s.foo; 88 | const Component = () => { 89 | const foo = useMappedState(mapState); 90 | return
    {foo}
    ; 91 | }; 92 | 93 | render(); 94 | 95 | ReactDOM.unmountComponentAtNode(reactRoot); 96 | 97 | expect(cancelSubscription).toHaveBeenCalled(); 98 | }); 99 | 100 | it('does not rerender if the selected state has not changed', () => { 101 | const mapState = (s: IState) => s.foo; 102 | let renderCount = 0; 103 | const Component = () => { 104 | const foo = useMappedState(mapState); 105 | renderCount++; 106 | return ( 107 |
    108 | {foo} {renderCount} 109 |
    110 | ); 111 | }; 112 | 113 | render(); 114 | 115 | expect(getText()).toBe('bar 1'); 116 | 117 | state = {bar: 456, ...state}; 118 | subscriberCallback(); 119 | 120 | expect(getText()).toBe('bar 1'); 121 | }); 122 | 123 | it('rerenders if the mapState function changes', () => { 124 | const Component = ({n}: {n: number}) => { 125 | const mapState = React.useCallback((s: IState) => s.foo + ' ' + n, [n]); 126 | const foo = useMappedState(mapState); 127 | return
    {foo}
    ; 128 | }; 129 | 130 | render(); 131 | 132 | expect(getText()).toBe('bar 100'); 133 | 134 | render(); 135 | 136 | expect(getText()).toBe('bar 45'); 137 | }); 138 | 139 | it('rerenders if the store changes', () => { 140 | const mapState = (s: IState) => s.foo; 141 | const Component = () => { 142 | const foo = useMappedState(mapState); 143 | return
    {foo}
    ; 144 | }; 145 | 146 | render(); 147 | 148 | expect(getText()).toBe('bar'); 149 | 150 | store = createStore(); 151 | state = {...state, foo: 'hello'}; 152 | render(); 153 | expect(getText()).toBe('hello'); 154 | }); 155 | 156 | it('calls the correct mapState if mapState changes and the store updates', () => { 157 | const Component = ({n}: {n: number}) => { 158 | const mapState = React.useCallback((s: IState) => s.foo + ' ' + n, [n]); 159 | const foo = useMappedState(mapState); 160 | return
    {foo}
    ; 161 | }; 162 | 163 | render(); 164 | render(); 165 | 166 | flushEffects(); 167 | 168 | state = {...state, foo: 'foo'}; 169 | subscriberCallback(); 170 | 171 | expect(getText()).toBe('foo 45'); 172 | }); 173 | }); 174 | 175 | // https://github.com/kentcdodds/react-testing-library/commit/11a41ce3ad9e9695f4b1662a5c67b890fc304894 176 | function flushEffects() { 177 | ReactDOM.render(
    , document.createElement('div')); 178 | } 179 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved 2 | 3 | import {createContext, useContext, useEffect, useRef, useState} from 'react'; 4 | import {Action, Dispatch, Store} from 'redux'; 5 | import shallowEqual from './shallowEqual'; 6 | 7 | export const StoreContext = createContext | null>(null); 8 | 9 | const CONTEXT_ERROR_MESSAGE = 10 | 'redux-react-hook requires your Redux store to ' + 11 | 'be passed through context via the '; 12 | 13 | /** 14 | * Your passed in mapState function should be memoized to avoid 15 | * resubscribing every render. If you use other props in mapState, use 16 | * useCallback to memoize the resulting function, otherwise define the mapState 17 | * function outside of the component: 18 | * 19 | * const mapState = useCallback( 20 | * state => state.todos.get(id), 21 | * // The second parameter to useCallback tells you 22 | * [id], 23 | * ); 24 | * const todo = useMappedState(mapState); 25 | */ 26 | export function useMappedState( 27 | mapState: (state: TState) => TResult, 28 | ): TResult { 29 | const store = useContext(StoreContext); 30 | if (!store) { 31 | throw new Error(CONTEXT_ERROR_MESSAGE); 32 | } 33 | const runMapState = () => mapState(store.getState()); 34 | 35 | const [derivedState, setDerivedState] = useState(() => runMapState()); 36 | 37 | // If the store or mapState change, rerun mapState 38 | const [prevStore, setPrevStore] = useState(store); 39 | const [prevMapState, setPrevMapState] = useState(() => mapState); 40 | if (prevStore !== store || prevMapState !== mapState) { 41 | setPrevStore(store); 42 | setPrevMapState(() => mapState); 43 | setDerivedState(runMapState()); 44 | } 45 | 46 | // We use a ref to store the last result of mapState in local component 47 | // state. This way we can compare with the previous version to know if 48 | // the component should re-render. Otherwise, we'd have pass derivedState 49 | // in the array of memoization paramaters to the second useEffect below, 50 | // which would cause it to unsubscribe and resubscribe from Redux every time 51 | // the state changes. 52 | const lastRenderedDerivedState = useRef(derivedState); 53 | // Set the last mapped state after rendering. 54 | useEffect(() => { 55 | lastRenderedDerivedState.current = derivedState; 56 | }); 57 | 58 | useEffect( 59 | () => { 60 | // Run the mapState callback and if the result has changed, make the 61 | // component re-render with the new state. 62 | const checkForUpdates = () => { 63 | const newDerivedState = runMapState(); 64 | if (!shallowEqual(newDerivedState, lastRenderedDerivedState.current)) { 65 | setDerivedState(newDerivedState); 66 | } 67 | }; 68 | 69 | // Pull data from the store on first render. 70 | checkForUpdates(); 71 | 72 | // Subscribe to the store to be notified of subsequent changes. 73 | const unsubscribe = store.subscribe(checkForUpdates); 74 | 75 | // The return value of useEffect will be called when unmounting, so 76 | // we use it to unsubscribe from the store. 77 | return unsubscribe; 78 | }, 79 | [store, mapState], 80 | ); 81 | 82 | return derivedState; 83 | } 84 | 85 | export function useDispatch(): Dispatch { 86 | const store = useContext(StoreContext); 87 | if (!store) { 88 | throw new Error(CONTEXT_ERROR_MESSAGE); 89 | } 90 | return store.dispatch; 91 | } 92 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved 2 | 3 | /// 4 | -------------------------------------------------------------------------------- /src/shallowEqual.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved 2 | 3 | // From https://github.com/reduxjs/react-redux/blob/3e53ff96ed10f71c21346f08823e503df724db35/src/utils/shallowEqual.js 4 | 5 | const hasOwn = Object.prototype.hasOwnProperty; 6 | 7 | function is(x: any, y: any) { 8 | if (x === y) { 9 | return x !== 0 || y !== 0 || 1 / x === 1 / y; 10 | } else { 11 | return x !== x && y !== y; 12 | } 13 | } 14 | 15 | export default function shallowEqual(objA: any, objB: any) { 16 | if (is(objA, objB)) { 17 | return true; 18 | } 19 | 20 | if ( 21 | typeof objA !== 'object' || 22 | objA === null || 23 | typeof objB !== 'object' || 24 | objB === null 25 | ) { 26 | return false; 27 | } 28 | 29 | const keysA = Object.keys(objA); 30 | const keysB = Object.keys(objB); 31 | 32 | if (keysA.length !== keysB.length) { 33 | return false; 34 | } 35 | 36 | // tslint:disable-next-line:prefer-for-of 37 | for (let i = 0; i < keysA.length; i++) { 38 | if (!hasOwn.call(objB, keysA[i]) || !is(objA[keysA[i]], objB[keysA[i]])) { 39 | return false; 40 | } 41 | } 42 | 43 | return true; 44 | } 45 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": false, 4 | "allowSyntheticDefaultImports": true, 5 | "declaration": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "isolatedModules": false, 9 | "jsx": "preserve", 10 | "lib": ["es6", "dom", "es2016", "es2017"], 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "noEmit": true, 14 | "noImplicitAny": true, 15 | "noImplicitReturns": true, 16 | "noImplicitThis": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "outDir": "build", 20 | "resolveJsonModule": true, 21 | "skipLibCheck": false, 22 | "sourceMap": true, 23 | "strict": true, 24 | "strictNullChecks": true, 25 | "suppressImplicitAnyIndexErrors": true, 26 | "target": "es5" 27 | }, 28 | "exclude": ["build", "dist", "example", "node_modules", "rollup.config.js"], 29 | "include": ["src"] 30 | } 31 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs" 5 | } 6 | } -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["tslint:recommended", "tslint-react", "tslint-config-prettier"], 3 | "linterOptions": { 4 | "exclude": [ 5 | "config/**/*.js", 6 | "node_modules/**/*.ts", 7 | "coverage/lcov-report/*.js" 8 | ] 9 | } 10 | } 11 | --------------------------------------------------------------------------------