├── .babelrc ├── .gitignore ├── .eslintignore ├── .npmignore ├── src ├── index.js └── utils │ ├── createReducer.js │ └── combineReducers.js ├── .eslintrc ├── package.json ├── LICENSE ├── test └── utils │ └── combineReducers.js └── README.md /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "stage": 0, 3 | "loose": "all" 4 | } 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.log 3 | node_modules 4 | dist 5 | lib 6 | coverage 7 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | lib/* 2 | **/dist/* 3 | **/node_modules/* 4 | **/server.js 5 | **/webpack.config*.js 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.log 3 | examples 4 | test 5 | coverage 6 | _book 7 | book.json 8 | docs 9 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import combineReducers from './utils/combineReducers'; 2 | import createReducer from './utils/createReducer'; 3 | 4 | export { 5 | combineReducers, 6 | createReducer 7 | }; 8 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb", 3 | "env": { 4 | "browser": true, 5 | "mocha": true, 6 | "node": true 7 | }, 8 | "rules": { 9 | "valid-jsdoc": 2, 10 | 11 | "react/jsx-uses-react": 2, 12 | "react/jsx-uses-vars": 2, 13 | "react/react-in-jsx-scope": 2, 14 | 15 | // Disable until Flow supports let and const 16 | "no-var": 0, 17 | "vars-on-top": 0, 18 | 19 | // Disable comma-dangle unless need to support it 20 | "comma-dangle": 0 21 | }, 22 | "plugins": [ 23 | "react" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-immutablejs", 3 | "version": "0.0.8", 4 | "description": "Redux Immutable facilities", 5 | "scripts": { 6 | "start": "node server.js", 7 | "test": "mocha --recursive --compilers js:babel-core/register", 8 | "test:watch": "npm test -- --watch", 9 | "build:lib": "babel src --out-dir lib", 10 | "prepublish": "npm run build:lib", 11 | "build:publish": "npm publish" 12 | }, 13 | "main": "lib/index.js", 14 | "files": [ 15 | "src", 16 | "lib" 17 | ], 18 | "repository": { 19 | "type": "git", 20 | "url": "https://github.com/indexiatech/redux-immutable.git" 21 | }, 22 | "author": "Indexia Tech", 23 | "license": "BSD-3-Clause", 24 | "bugs": { 25 | "url": "https://github.com/indexiatech/redux-immutablejs/issues" 26 | }, 27 | "homepage": "http://indexiatech.github.io/redux-immutable", 28 | "peerDependencies": { 29 | "redux": "^2.0.0 || ^3.0.0", 30 | "immutable": "^3.7.5" 31 | }, 32 | "devDependencies": { 33 | "babel": "^5.8.23", 34 | "babel-core": "^5.6.18", 35 | "babel-loader": "^5.1.4", 36 | "eslint-config-airbnb": "0.0.8", 37 | "eslint-plugin-react": "^3.3.1", 38 | "expect": "^1.9.0", 39 | "immutable": "^3.7.5", 40 | "mocha": "^2.3.3", 41 | "node-libs-browser": "^0.5.2", 42 | "redux": "^3.0.0", 43 | "webpack": "^1.9.11", 44 | "webpack-dev-server": "^1.9.0" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015, indexiatech 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | * Neither the name of re-notif nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /src/utils/createReducer.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'immutable'; 2 | 3 | /** 4 | * Create a handler (action) map reducer for the given list of handlers 5 | * 6 | * @param {object} initialState The initial state of the reducer, expecting an Immutable.Iterable instance, 7 | * otherwise given initialState is converted to immutable. 8 | * @param {object} handlers A map of actions where key is action name and value is a reducer function 9 | * @param {boolean} enforceImmutable = true if to enforce immutable, in other words a TypeError is thrown in case 10 | * a handler returned anything that is not an Immutable.Iterable type. 11 | * @param {function} constructor A function to process non-immutable state, defaults to Immutable.fromJS. 12 | * @return {object} The calculated next state 13 | */ 14 | export default function createReducer(initialState, handlers, enforceImmutable = true, constructor = ::Immutable.fromJS) { 15 | return (state = initialState, action) => { 16 | // convert the initial state to immutable 17 | // This is useful in isomorphic apps where states were serialized 18 | if (!Immutable.Iterable.isIterable(state)) { 19 | state = constructor(state); 20 | } 21 | 22 | const handler = (action && action.type) ? handlers[action.type] : undefined; 23 | 24 | if (!handler) { 25 | return state; 26 | } 27 | 28 | state = handler(state, action); 29 | 30 | if (enforceImmutable && !Immutable.Iterable.isIterable(state)) { 31 | throw new TypeError('Reducers must return Immutable objects.'); 32 | } 33 | 34 | return state; 35 | }; 36 | } 37 | -------------------------------------------------------------------------------- /test/utils/combineReducers.js: -------------------------------------------------------------------------------- 1 | import expect from 'expect'; 2 | import { combineReducers } from '../../src'; 3 | import Immutable, {List, Map} from 'immutable'; 4 | const indexedOf = Immutable.Seq.Indexed.of; 5 | 6 | describe('Utils', () => { 7 | const initialState = Map(); 8 | describe('combineReducers', () => { 9 | const reducer = combineReducers({ 10 | counter: (state = 0, action = {}) => 11 | action.type === 'increment' ? state + 1 : state, 12 | stack: (state = List(), action = {}) => 13 | action.type === 'push' ? state.push(action.value) : state 14 | }); 15 | it('should return a composite reducer that maps the state keys to given reducers', () => { 16 | const s1 = reducer(initialState, { type: 'increment' }); 17 | expect(Immutable.Map.isMap(s1)).toBe(true); 18 | expect(s1.get('counter')).toBe(1); 19 | const s2 = reducer(s1, { type: 'push', value: 'a' }); 20 | expect(Map.isMap(s2)).toBe(true); 21 | expect(s2.get('counter')).toBe(1); 22 | expect(List.isList(s2.get('stack'))).toBe(true); 23 | expect(s2.get('stack').equals(List('a'))).toBe(true); 24 | }); 25 | 26 | it('ignores all props which are not a function', () => { 27 | const reducer = combineReducers({ 28 | fake: true, 29 | broken: 'string', 30 | another: { nested: 'object' }, 31 | stack: (state = Map()) => state 32 | }); 33 | 34 | expect(reducer(initialState, { type: 'push' }).keySeq().equals(indexedOf('stack'))).toBe(true); 35 | }); 36 | 37 | it('returns the initial state when nothing changes', () => { 38 | const s1 = reducer(initialState, { type: 'increment' }); 39 | const s2 = reducer(s1); 40 | expect(s1).toBe(s2); 41 | }) 42 | 43 | it('includes alll keys from original state', () => { 44 | const unexpectedKeys = Map({ unexpected: true }); 45 | const reduced = reducer(unexpectedKeys, { type: 'INIT' }); 46 | 47 | expect( reduced.get('unexpected') ).toBe(true); 48 | }); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `redux-immutablejs` 2 | 3 | Redux & Immutable integration 4 | 5 | This is a small library that aims to provide integration tools between [Redux](https://github.com/rackt/redux) 6 | & [ImmutableJs](https://facebook.github.io/immutable-js/) that fully conforms Redux _actions_ & _reducers_ standards. 7 | 8 | 1. An alternative to [combineReducers](http://rackt.github.io/redux/docs/api/combineReducers.html) that supports 9 | [ImmutableJs](https://facebook.github.io/immutable-js/) for store initial state. 10 | 1. An optional handler map reducer creator with immutable support. 11 | 12 | 13 | # Setup 14 | 15 | ## Initial State 16 | 17 | Using `combineReducers` it is possible to provide `createStore` with initial state using Immutable [Iterable](https://facebook.github.io/immutable-js/docs/#/Iterable) type, i.e: 18 | 19 | ```js 20 | import { createStore } from 'redux'; 21 | import { combineReducers } from 'redux-immutablejs'; 22 | 23 | import Immutable from 'immutable'; 24 | import * as reducers from './reducers'; 25 | 26 | const reducer = combineReducers(reducers); 27 | const state = Immutable.fromJS({}); 28 | 29 | const store = reducer(state); 30 | export default createStore(reducer, store); 31 | ``` 32 | 33 | ## Immutable Handler Map reducer creator 34 | 35 | Using `createReducer` is an optional function that creates a reducer from a collection of handlers. In addition to 36 | getting rid of the _switch_ statement, it also provides the following benefits: 37 | 38 | 1. If the given `initialState` type is mutated, it will get converted to an immutable type. 39 | 1. An error is produced in case a reducer handler returns a mutated state (not recommended but this behavior can be disabled) 40 | 41 | ```js 42 | import { createReducer } from 'redux-immutablejs' 43 | const initialState = Immutable.fromJS({ isAuth: false }) 44 | 45 | /** 46 | * Reducer domain that handles authentication & authorization. 47 | **/ 48 | export default createReducer(initialState, { 49 | [LOGIN]: (state, action) => state.merge({ 50 | isAuth: true, 51 | token: action.payload.token 52 | }), 53 | 54 | [LOGOUT]: (domain) => domain.merge({ 55 | isAuth: false, 56 | current_identity: {}, 57 | token: undefined 58 | }) 59 | }) 60 | ``` 61 | 62 | If you want to specify the Immutable type to be used for implicit conversion, pass an constructor function at the end: 63 | 64 | ```js 65 | export default createReducer([], { 66 | [ADD_STUFF]: (state, { stuff }) => state.add(stuff) 67 | }, true, ::Immutable.OrderedSet); 68 | 69 | ``` 70 | 71 | Please note that this is optional and `combineReducers` should work just fine if you prefer the old `switch` way. 72 | 73 | 74 | # FAQ 75 | 76 | ## How this library is different from 'redux-immutable' ? 77 | 78 | This library doesn't dictate any specific reducer structure. 79 | While `redux-immutable` focuses on [CRC](https://github.com/gajus/canonical-reducer-composition), this library 80 | provides some [conversion middlewares](https://github.com/gajus/redux-immutable/issues/3) from FSA to CCA 81 | and vise versa. If you feel like going with _Redux's vanilla_ is the right approach, then consider using our library. 82 | -------------------------------------------------------------------------------- /src/utils/combineReducers.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'immutable'; 2 | 3 | // TODO need to find a way to reference Redux's init for compatability 4 | const ActionTypes = { INIT: 'INIT' }; 5 | const isImmutable = (obj) => { 6 | return Immutable.Iterable.isIterable(obj); 7 | }; 8 | 9 | /* eslint-disable no-console */ 10 | 11 | function getErrorMessage(key, action) { 12 | var actionType = action && action.type; 13 | var actionName = actionType && `"${actionType.toString()}"` || 'an action'; 14 | 15 | return ( 16 | `Reducer "${key}" returned undefined handling ${actionName}. ` + 17 | `To ignore an action, you must explicitly return the previous state.` 18 | ); 19 | } 20 | 21 | function verifyStateShape(initialState, currentState) { 22 | var reducerKeys = currentState.keySeq(); 23 | 24 | if (reducerKeys.size === 0) { 25 | console.error( 26 | 'Store does not have a valid reducer. Make sure the argument passed ' + 27 | 'to combineReducers is an object whose values are reducers.' 28 | ); 29 | return; 30 | } 31 | 32 | if (!isImmutable(initialState)) { 33 | console.error( 34 | 'initialState has unexpected type of "' + 35 | ({}).toString.call(initialState).match(/\s([a-z|A-Z]+)/)[1] + 36 | '". Expected initialState to be an instance of Immutable.Iterable with the following ' + 37 | `keys: "${reducerKeys.join('", "')}"` 38 | ); 39 | return; 40 | } 41 | 42 | const unexpectedKeys = initialState.keySeq().filter( 43 | key => reducerKeys.indexOf(key) < 0 44 | ); 45 | 46 | if (unexpectedKeys.size > 0) { 47 | console.error( 48 | `Unexpected ${unexpectedKeys.length > 1 ? 'keys' : 'key'} ` + 49 | `"${unexpectedKeys.join('", "')}" in initialState will be ignored. ` + 50 | `Expected to find one of the known reducer keys instead: "${reducerKeys.join('", "')}"` 51 | ); 52 | } 53 | } 54 | 55 | /** 56 | * Turns an object whose values are different reducer functions, into a single 57 | * reducer function. It will call every child reducer, and gather their results 58 | * into a single state object, whose keys correspond to the keys of the passed 59 | * reducer functions. 60 | * 61 | * @param {Object} reducers An object whose values correspond to different 62 | * reducer functions that need to be combined into one. One handy way to obtain 63 | * it is to use ES6 `import * as reducers` syntax. The reducers may never return 64 | * undefined for any action. Instead, they should return their initial state 65 | * if the state passed to them was undefined, and the current state for any 66 | * unrecognized action. 67 | * 68 | * @returns {Function} A reducer function that invokes every reducer inside the 69 | * passed object, and builds a state object with the same shape. 70 | */ 71 | 72 | export default function combineReducers(reducers) { 73 | reducers = isImmutable(reducers) ? reducers : Immutable.fromJS(reducers); 74 | const finalReducers = reducers.filter(v => typeof v === 'function'); 75 | 76 | finalReducers.forEach((reducer, key) => { 77 | if (typeof reducer(undefined, { type: ActionTypes.INIT }) === 'undefined') { 78 | throw new Error( 79 | `Reducer "${key}" returned undefined during initialization. ` + 80 | `If the state passed to the reducer is undefined, you must ` + 81 | `explicitly return the initial state. The initial state may ` + 82 | `not be undefined.` 83 | ); 84 | } 85 | 86 | var type = Math.random().toString(36).substring(7).split('').join('.'); 87 | if (typeof reducer(undefined, { type }) === 'undefined') { 88 | throw new Error( 89 | `Reducer "${key}" returned undefined when probed with a random type. ` + 90 | `Don't try to handle ${ActionTypes.INIT} or other actions in "redux/*" ` + 91 | `namespace. They are considered private. Instead, you must return the ` + 92 | `current state for any unknown actions, unless it is undefined, ` + 93 | `in which case you must return the initial state, regardless of the ` + 94 | `action type. The initial state may not be undefined.` 95 | ); 96 | } 97 | }); 98 | 99 | var defaultState = finalReducers.map(r => undefined); 100 | var stateShapeVerified; 101 | 102 | return function combination(state = defaultState, action) { 103 | 104 | let finalState = state; 105 | finalReducers.forEach( ( reducer, key ) => { 106 | const oldState = state.get( key ); 107 | const newState = reducer( oldState, action ); 108 | 109 | if (typeof newState === 'undefined') { 110 | throw new Error(getErrorMessage(key, action)); 111 | } 112 | 113 | finalState = finalState.set( key, newState ); 114 | }); 115 | 116 | if (( 117 | // Node-like CommonJS environments (Browserify, Webpack) 118 | typeof process !== 'undefined' && 119 | typeof process.env !== 'undefined' && 120 | process.env.NODE_ENV !== 'production' 121 | ) || 122 | // React Native 123 | typeof __DEV__ !== 'undefined' && 124 | __DEV__ // eslint-disable-line no-undef 125 | ) { 126 | if (!stateShapeVerified) { 127 | verifyStateShape(state, finalState); 128 | stateShapeVerified = true; 129 | } 130 | } 131 | 132 | return finalState; 133 | }; 134 | } 135 | --------------------------------------------------------------------------------