├── .babelrc ├── .eslintrc ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── package.json ├── src ├── connect.js ├── index.js ├── provider.js ├── shallow-equal.js └── test │ ├── connect.js │ ├── index.js │ └── provider.js └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "react"], 3 | "plugins": ["babel-plugin-add-module-exports"] 4 | } 5 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "extends": ["eslint:recommended", "plugin:react/recommended"], 4 | "plugins": ["react"], 5 | "globals": { 6 | "process": false, 7 | "console": false, 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | node_modules 3 | dist/ 4 | dist-test/ 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 6 4 | install: 5 | - npm install 6 | script: 7 | - npm run lint 8 | - npm test 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Giancarlo Anemone 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 | # react-redux-lite 2 | Lightweight version of the react-redux library 3 | 4 | [![build status](https://travis-ci.org/ganemone/react-redux-lite.svg?branch=master)](https://travis-ci.org/ganemone/react-redux-lite) 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-redux-lite", 3 | "version": "1.0.0", 4 | "description": "Light weight version of react-redux", 5 | "main": "dist/react-redux-lite.js", 6 | "scripts": { 7 | "lint": "eslint src/", 8 | "test": "webpack --env test && npm run just-test", 9 | "just-test": "node dist-test/index.js", 10 | "build": "webpack", 11 | "dev": "webpack --progress --colors --watch" 12 | }, 13 | "author": "Giancarlo Anemone", 14 | "license": "ISC", 15 | "peerDependencies": { 16 | "react": "^15.5.4", 17 | "redux": "^3.6.0", 18 | "prop-types": "^15.5.8" 19 | }, 20 | "devDependencies": { 21 | "babel": "^6.23.0", 22 | "babel-core": "^6.24.1", 23 | "babel-eslint": "^7.2.2", 24 | "babel-loader": "^6.4.1", 25 | "babel-plugin-add-module-exports": "^0.2.1", 26 | "babel-preset-es2015": "^6.24.1", 27 | "babel-preset-react": "^6.24.1", 28 | "eslint": "^3.19.0", 29 | "eslint-loader": "^1.7.1", 30 | "eslint-plugin-react": "^6.10.3", 31 | "jsdom": "^9.12.0", 32 | "prop-types": "^15.5.8", 33 | "react": "^15.5.4", 34 | "react-addons-test-utils": "^15.5.1", 35 | "react-dom": "^15.5.4", 36 | "redux": "^3.6.0", 37 | "tape": "^4.6.3", 38 | "webpack": "^2.4.1", 39 | "webpack-node-externals": "^1.5.4", 40 | "yargs": "^7.1.0" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/connect.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import shallowEqual from './shallow-equal.js'; 4 | import {bindActionCreators} from 'redux'; 5 | 6 | export default function wrapActionCreators(actionCreators) { 7 | return dispatch => bindActionCreators(actionCreators, dispatch); 8 | } 9 | 10 | export function connect(mapStateToProps, mapDispatchToProps) { 11 | return function withConnect(wrappedComponent) { 12 | class Connect extends Component { 13 | constructor(props, context) { 14 | super(props, context); 15 | const {store} = context; 16 | const {dispatch, getState} = store; 17 | this.onStateChange = this.onStateChange.bind(this); 18 | this.lastProps = mapStateToProps(getState()); 19 | this.dispatchProps = {dispatch}; 20 | if (typeof mapDispatchToProps === 'function') { 21 | this.dispatchProps = mapDispatchToProps(dispatch); 22 | } else if (typeof mapDispatchToProps === 'object') { 23 | this.dispatchProps = bindActionCreators( 24 | mapDispatchToProps, 25 | dispatch 26 | ); 27 | } 28 | } 29 | componentDidMount() { 30 | const store = this.context.store; 31 | this.unsubscribe = store.subscribe(this.onStateChange); 32 | } 33 | componentWillUnmount() { 34 | if (typeof this.unsubscribe === 'function') { 35 | this.unsubscribe(); 36 | } 37 | } 38 | onStateChange() { 39 | const newState = this.context.store.getState(); 40 | const newProps = mapStateToProps(newState); 41 | if (!shallowEqual(newProps, this.lastProps)) { 42 | this.lastProps = newProps; 43 | this.forceUpdate(); 44 | } 45 | } 46 | render() { 47 | const mergedProps = Object.assign( 48 | {}, 49 | this.lastProps, 50 | this.dispatchProps 51 | ); 52 | return React.createElement(wrappedComponent, mergedProps); 53 | } 54 | } 55 | 56 | const wrappedDisplayName = wrappedComponent.displayName || 57 | wrappedComponent.name || 58 | ''; 59 | Connect.displayName = `Connect(${wrappedDisplayName})`; 60 | 61 | Connect.contextTypes = { 62 | store: PropTypes.object.isRequired, 63 | }; 64 | 65 | // TODO: Handle display names 66 | return Connect; 67 | }; 68 | } 69 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export {connect} from './connect.js'; 2 | export {Provider} from './provider.js'; 3 | -------------------------------------------------------------------------------- /src/provider.js: -------------------------------------------------------------------------------- 1 | import {Component, Children} from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | let didWarnAboutReceivingStore = false; 5 | function warnAboutReceivingStore() { 6 | if (didWarnAboutReceivingStore) { 7 | return; 8 | } 9 | didWarnAboutReceivingStore = true; 10 | 11 | // eslint-disable-next-line no-console 12 | console.warn( 13 | ' does not support changing `store` on the fly. ' + 14 | 'It is most likely that you see this error because you updated to ' + 15 | 'Redux 2.x and React Redux 2.x which no longer hot reload reducers ' + 16 | 'automatically. See https://github.com/reactjs/react-redux/releases/' + 17 | 'tag/v2.0.0 for the migration instructions.' 18 | ); 19 | } 20 | 21 | export class Provider extends Component { 22 | getChildContext() { 23 | return {store: this.store, storeSubscription: null}; 24 | } 25 | 26 | constructor(props, context) { 27 | super(props, context); 28 | this.store = props.store; 29 | } 30 | 31 | render() { 32 | return Children.only(this.props.children); 33 | } 34 | } 35 | 36 | if (process.env.NODE_ENV !== 'production') { 37 | Provider.prototype.componentWillReceiveProps = function(nextProps) { 38 | const {store} = this; 39 | const {store: nextStore} = nextProps; 40 | 41 | if (store !== nextStore) { 42 | warnAboutReceivingStore(); 43 | } 44 | }; 45 | } 46 | 47 | Provider.propTypes = { 48 | store: PropTypes.object.isRequired, 49 | children: PropTypes.element.isRequired, 50 | }; 51 | 52 | Provider.childContextTypes = { 53 | store: PropTypes.object.isRequired, 54 | storeSubscription: PropTypes.any, 55 | }; 56 | Provider.displayName = 'Provider'; 57 | -------------------------------------------------------------------------------- /src/shallow-equal.js: -------------------------------------------------------------------------------- 1 | const hasOwn = Object.prototype.hasOwnProperty 2 | 3 | function is(x, y) { 4 | if (x === y) { 5 | return x !== 0 || y !== 0 || 1 / x === 1 / y 6 | } else { 7 | return x !== x && y !== y 8 | } 9 | } 10 | 11 | export default function shallowEqual(objA, objB) { 12 | if (is(objA, objB)) return true 13 | 14 | if (typeof objA !== 'object' || objA === null || 15 | typeof objB !== 'object' || objB === null) { 16 | return false 17 | } 18 | 19 | const keysA = Object.keys(objA) 20 | const keysB = Object.keys(objB) 21 | 22 | if (keysA.length !== keysB.length) return false 23 | 24 | for (let i = 0; i < keysA.length; i++) { 25 | if (!hasOwn.call(objB, keysA[i]) || 26 | !is(objA[keysA[i]], objB[keysA[i]])) { 27 | return false 28 | } 29 | } 30 | 31 | return true 32 | } 33 | -------------------------------------------------------------------------------- /src/test/connect.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import TestUtils from 'react-dom/test-utils'; 3 | import {Provider, connect} from '../index.js'; 4 | import test from 'tape'; 5 | import {createStore} from 'redux'; 6 | 7 | function getCounterStore() { 8 | const store = createStore( 9 | (state, action) => { 10 | if (action.type === 'INCREMENT') { 11 | return Object.assign({}, state, { 12 | counter: state.counter + 1, 13 | }); 14 | } else if (action.type === 'DECREMENT') { 15 | return Object.assign({}, state, { 16 | counter: state.counter - 1, 17 | }); 18 | } 19 | return state; 20 | }, 21 | { 22 | counter: 0, 23 | } 24 | ); 25 | return store; 26 | } 27 | 28 | test('connect is exported correctly', t => { 29 | t.equal(typeof connect, 'function', 'connect is a function'); 30 | t.end(); 31 | }); 32 | 33 | test('connect with counter mapStateToProps', t => { 34 | let numRenders = 0; 35 | class Child extends Component { 36 | render() { 37 | numRenders++; 38 | return
Hello World
; 39 | } 40 | } 41 | const store = getCounterStore(); 42 | const ConnectedChild = connect(state => { 43 | return { 44 | counter: state.counter, 45 | }; 46 | })(Child); 47 | const app = ( 48 | 49 | 50 | 51 | ); 52 | const tree = TestUtils.renderIntoDocument(app); 53 | const child = TestUtils.findRenderedComponentWithType(tree, Child); 54 | t.equal( 55 | child.props.counter, 56 | 0, 57 | 'correctly maps counter to props on first render' 58 | ); 59 | t.equal(numRenders, 1, 'renders the component only once'); 60 | store.dispatch({ 61 | type: 'INCREMENT', 62 | }); 63 | t.equal(numRenders, 2, 'renders the component again when the store changes'); 64 | t.equal(child.props.counter, 1, 're-renders component with updated store'); 65 | store.dispatch({ 66 | type: 'DO_NOTHING', 67 | }); 68 | t.equal( 69 | numRenders, 70 | 2, 71 | 'does not rerender component when counter does not change' 72 | ); 73 | t.end(); 74 | }); 75 | 76 | test('connect with mapStateToProps and mapDispatchToProps', t => { 77 | let numRenders = 0; 78 | let numMapDispatchToPropsCalls = 0; 79 | class Child extends Component { 80 | render() { 81 | numRenders++; 82 | return
Hello World
; 83 | } 84 | } 85 | const store = getCounterStore(); 86 | const ConnectedChild = connect( 87 | state => { 88 | return { 89 | counter: state.counter, 90 | }; 91 | }, 92 | dispatch => { 93 | numMapDispatchToPropsCalls++; 94 | return { 95 | increment: () => { 96 | dispatch({ 97 | type: 'INCREMENT', 98 | }); 99 | }, 100 | }; 101 | } 102 | )(Child); 103 | const app = ( 104 | 105 | 106 | 107 | ); 108 | const tree = TestUtils.renderIntoDocument(app); 109 | const child = TestUtils.findRenderedComponentWithType(tree, Child); 110 | t.equal( 111 | child.props.counter, 112 | 0, 113 | 'correctly maps counter to props on first render' 114 | ); 115 | t.equal(numRenders, 1, 'renders the component only once'); 116 | t.equal(numMapDispatchToPropsCalls, 1, 'calls mapDispatchToProps only once'); 117 | t.equal( 118 | typeof child.props.increment, 119 | 'function', 120 | 'passes props from mapDispatchToProps correctly' 121 | ); 122 | child.props.increment(); 123 | t.equal(numRenders, 2, 'renders the component again when the store changes'); 124 | t.equal(child.props.counter, 1, 're-renders component with updated store'); 125 | t.equal( 126 | typeof child.props.increment, 127 | 'function', 128 | 'passes props from mapDispatchToProps correctly' 129 | ); 130 | store.dispatch({ 131 | type: 'DO_NOTHING', 132 | }); 133 | t.equal( 134 | numRenders, 135 | 2, 136 | 'does not rerender component when counter does not change' 137 | ); 138 | t.end(); 139 | }); 140 | 141 | test('connect with object props in mapStateToProps', t => { 142 | const store = getDeepCounterStore(); 143 | let numRenders = 0; 144 | let numMapDispatchToPropsCalls = 0; 145 | class Child extends Component { 146 | render() { 147 | numRenders++; 148 | return
Hello World
; 149 | } 150 | } 151 | const ConnectedChild = connect( 152 | state => { 153 | return { 154 | counterA: state.counterA, 155 | counterB: state.counterB, 156 | }; 157 | }, 158 | dispatch => { 159 | numMapDispatchToPropsCalls++; 160 | return { 161 | incrementA: () => { 162 | dispatch({ 163 | type: 'INCREMENT_A', 164 | }); 165 | }, 166 | incrementB: () => { 167 | dispatch({ 168 | type: 'INCREMENT_B', 169 | }); 170 | }, 171 | incrementC: () => { 172 | dispatch({ 173 | type: 'INCREMENT_C', 174 | }); 175 | }, 176 | }; 177 | } 178 | )(Child); 179 | const app = ( 180 | 181 | 182 | 183 | ); 184 | const tree = TestUtils.renderIntoDocument(app); 185 | const child = TestUtils.findRenderedComponentWithType(tree, Child); 186 | t.equal( 187 | child.props.counterA.value, 188 | 0, 189 | 'correctly maps counter to props on first render' 190 | ); 191 | t.equal(numRenders, 1, 'renders the component only once'); 192 | t.equal(numMapDispatchToPropsCalls, 1, 'calls mapDispatchToProps only once'); 193 | t.equal( 194 | typeof child.props.incrementA, 195 | 'function', 196 | 'passes props from mapDispatchToProps correctly' 197 | ); 198 | child.props.incrementA(); 199 | t.equal(numRenders, 2, 'renders the component again when the store changes'); 200 | t.equal( 201 | child.props.counterA.value, 202 | 1, 203 | 're-renders component with updated store' 204 | ); 205 | t.equal( 206 | typeof child.props.incrementA, 207 | 'function', 208 | 'passes props from mapDispatchToProps correctly' 209 | ); 210 | t.equal( 211 | typeof child.props.incrementB, 212 | 'function', 213 | 'passes props from mapDispatchToProps correctly' 214 | ); 215 | store.dispatch({ 216 | type: 'DO_NOTHING', 217 | }); 218 | t.equal( 219 | numRenders, 220 | 2, 221 | 'does not rerender component when counter does not change' 222 | ); 223 | child.props.incrementC(); 224 | t.equal( 225 | numRenders, 226 | 2, 227 | 'does not rerender component when counter does not change' 228 | ); 229 | t.end(); 230 | }); 231 | 232 | function getDeepCounterStore() { 233 | const store = createStore( 234 | (state, action) => { 235 | if (action.type === 'INCREMENT_A') { 236 | const counterAState = Object.assign({}, state.counterA, { 237 | value: state.counterA.value + 1, 238 | }); 239 | return Object.assign({}, state, { 240 | counterA: counterAState, 241 | }); 242 | } else if (action.type === 'INCREMENT_B') { 243 | const counterBState = Object.assign({}, state.counterB, { 244 | value: state.counterB.value + 1, 245 | }); 246 | return Object.assign({}, state, { 247 | counterB: counterBState, 248 | }); 249 | } else if (action.type === 'INCREMENT_C') { 250 | const counterCState = Object.assign({}, state.counterC, { 251 | value: state.counterC.value + 1, 252 | }); 253 | return Object.assign({}, state, { 254 | counterC: counterCState, 255 | }); 256 | } 257 | return state; 258 | }, 259 | { 260 | counterA: { 261 | value: 0, 262 | name: 'counterA', 263 | }, 264 | counterB: { 265 | value: 0, 266 | name: 'counterB', 267 | }, 268 | counterC: { 269 | value: 0, 270 | name: 'counterC', 271 | }, 272 | } 273 | ); 274 | return store; 275 | } 276 | -------------------------------------------------------------------------------- /src/test/index.js: -------------------------------------------------------------------------------- 1 | /* global global, document */ 2 | import {jsdom} from 'jsdom'; 3 | global.document = jsdom(''); 4 | global.window = document.defaultView; 5 | global.navigator = global.window.navigator; 6 | 7 | import './provider.js'; 8 | import './connect.js'; 9 | -------------------------------------------------------------------------------- /src/test/provider.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import TestUtils from 'react-dom/test-utils'; 3 | import PropTypes from 'prop-types'; 4 | import {Provider} from '../index.js'; 5 | import test from 'tape'; 6 | import {createStore} from 'redux'; 7 | 8 | test('Provider is exported correctly', t => { 9 | t.equal(typeof Provider, 'function', 'provider is a function'); 10 | t.end(); 11 | }); 12 | 13 | test('Provider provides store correctly', t => { 14 | const store = createStore( 15 | (state /*, action */) => { 16 | return state; 17 | }, 18 | {} 19 | ); 20 | class Child extends Component { 21 | render() { 22 | return
Hello World
; 23 | } 24 | } 25 | Child.contextTypes = { 26 | store: PropTypes.object.isRequired, 27 | }; 28 | const app = ( 29 | 30 | 31 | 32 | ); 33 | const tree = TestUtils.renderIntoDocument(app); 34 | const child = TestUtils.findRenderedComponentWithType(tree, Child); 35 | t.equal(child.context.store, store, 'passes store context down correctly'); 36 | t.end(); 37 | }); 38 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | /*global __dirname, require, module*/ 2 | 3 | const nodeExternals = require('webpack-node-externals'); 4 | const path = require('path'); 5 | const env = require('yargs').argv.env; // use --env with webpack 2 6 | 7 | let libraryName = 'react-redux-lite'; 8 | 9 | let plugins = [], outputFile, entryFile, outputDir; 10 | if (env === 'test') { 11 | entryFile = './src/test/index.js'; 12 | outputFile = 'index.js'; 13 | outputDir = 'dist-test'; 14 | } else { 15 | outputDir = 'dist'; 16 | entryFile = './src/index.js'; 17 | outputFile = libraryName + '.js'; 18 | } 19 | 20 | const config = { 21 | entry: entryFile, 22 | devtool: 'source-map', 23 | output: { 24 | path: path.join(__dirname, outputDir), 25 | filename: outputFile, 26 | libraryTarget: 'commonjs', 27 | }, 28 | 29 | target: 'node', 30 | externals: [nodeExternals()], 31 | 32 | module: { 33 | rules: [ 34 | { 35 | test: /(\.jsx|\.js)$/, 36 | loader: 'babel-loader', 37 | exclude: /(node_modules|bower_components)/, 38 | }, 39 | // { 40 | // test: /(\.jsx|\.js)$/, 41 | // loader: 'eslint-loader', 42 | // exclude: /node_modules/, 43 | // }, 44 | ], 45 | }, 46 | resolve: { 47 | modules: [path.resolve('./src')], 48 | extensions: ['.json', '.js'], 49 | }, 50 | plugins: plugins, 51 | }; 52 | 53 | module.exports = config; 54 | --------------------------------------------------------------------------------