├── .editorconfig ├── .gitignore ├── .prettierrc ├── .travis.yml ├── .watchmanconfig ├── 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 │ ├── reducer.ts │ └── typings.d.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 ├── shallowEqual.ts └── typings.d.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/ianobermiller/redux-react-hook/4fa5d370583b4c95ea3e833b13c8179ab0b0976f/.watchmanconfig -------------------------------------------------------------------------------- /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 | # Moved to https://github.com/facebookincubator/redux-react-hook 2 | -------------------------------------------------------------------------------- /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 | "emotion": "^9.2.12", 9 | "react": "^16.7.0-alpha.0", 10 | "react-dom": "^16.7.0-alpha.0", 11 | "react-scripts-ts": "3.1.0", 12 | "redux": "^4.0.1" 13 | }, 14 | "scripts": { 15 | "start": "react-scripts-ts start", 16 | "build": "react-scripts-ts build", 17 | "test": "react-scripts-ts test --env=jsdom", 18 | "eject": "react-scripts-ts eject" 19 | }, 20 | "devDependencies": { 21 | "@types/react": "^16.3.13", 22 | "@types/react-dom": "^16.0.5", 23 | "typescript": "^3.1.3" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /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 | import {css} from 'emotion'; 2 | import * as React from 'react'; 3 | import TodoInput from './TodoInput'; 4 | import TodoList from './TodoList'; 5 | 6 | export default function App() { 7 | return ( 8 |
9 |

Todo

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