diff --git a/docs/api/combineReducers.md b/docs/api/combineReducers.md index 01492a128c..24b14c078e 100644 --- a/docs/api/combineReducers.md +++ b/docs/api/combineReducers.md @@ -4,34 +4,84 @@ As your app grows more complex, you’ll want to split your [reducing function]( The `combineReducers` helper function turns an object whose values are different reducing functions into a single reducing function you can pass to [`createStore`](createStore.md). -The resulting reducer calls every child reducer, and gathers their results into a single state object. **The shape of the state object matches the keys of the passed `reducers`**. +The resulting reducer calls every child reducer, and gathers their results into a single state object. **The shape of the state object will match the shape of the reducer tree `reducers`**. -Consequently, the state object will look like this: +In other words, if you create a reducer as such... + +``` +const myReducer = combineReducers({ + foo: , + bar: , + baz: { + baz1: , + baz2: + } +}) +``` + +...then the shape of the resulting state when running `myReducer` would look like the following. ``` { - reducer1: ... - reducer2: ... -} + foo: , + bar: , + baz: { + baz1: , + baz2: + } ``` You can control state key names by using different keys for the reducers in the passed object. For example, you may call `combineReducers({ todos: myTodosReducer, counter: myCounterReducer })` for the state shape to be `{ todos, counter }`. A popular convention is to name reducers after the state slices they manage, so you can use ES6 property shorthand notation: `combineReducers({ counter, todos })`. This is equivalent to writing `combineReducers({ counter: counter, todos: todos })`. + + > ##### A Note for Flux Users > This function helps you organize your reducers to manage their own slices of state, similar to how you would have different Flux Stores to manage different state. With Redux, there is just one store, but `combineReducers` helps you keep the same logical division between reducers. +#### + +Using `combineReducers` can be a good way to enforce separation of concerns in your reducer graph. Each sub-reducer receives the previous state for its specific reducer path and is only responsible for returning the next state for that same path. However, as your application becomes more sophisticated sometimes you'll need to cheat and allow a sub-reducer to access some part of the global state. This in particular can happen when you manage some global cached resource in your state that you don't want to duplicate for each sub-reducer that needs it (eg. when you use [Normalizr](https://github.com/paularmstrong/normalizr)). `combineReducers` handles this use case by allowing each sub-reducer to optionally access the global state if it happens to need it. + +For example, if you had a state shape like... + +``` +{ + myCache: { ... }, + foo: { ... }, + bar: { ... } +} +``` + +... you could access the state at `myCache` in other sub-reducers like so: + +``` +const reducer = combineReducers({ + myCache: , + foo: , + bar: (state, action, prevGlobalState) => { + let someObject = prevGlobalState.myCache['some_key'] + ... + } +}) +``` + +Note that because the order of object keys in javascript is undefined, the order in which your sub-reducers are executed is also undefined. As such, the `prevGlobalState` is exactly what it sounds like and does not reflect any changes in state caused during the processing of the current action. In addition, be sure not to mutate `prevGlobalState`. + + #### Arguments -1. `reducers` (*Object*): An object whose values correspond to different reducing functions that need to be combined into one. See the notes below for some rules every passed reducer must follow. +1. `reducers` (*Object*): An object tree that defines a potentially multi-level mapping of how the sub-reducer functions are composed into a final reducer function that creates the next state. Each value in this object must either be a sub-reducer function or another object that defines that next level of state. See the notes below for some rules every passed reducer must follow. > Earlier documentation suggested the use of the ES6 `import * as reducers` syntax to obtain the reducers object. This was the source of a lot of confusion, which is why we now recommend exporting a single reducer obtained using `combineReducers()` from `reducers/index.js` instead. An example is included below. #### Returns -(*Function*): A reducer that invokes every reducer inside the `reducers` object, and constructs a state object with the same shape. +(*Function*): A reducer that recursively invokes every reducer inside the `reducers` object, and constructs a state object with the same shape. + +In addition to the required `state` and `action` arguments, this reducer can also be passed a third argument `stateWindow`. When present, `stateWindow` is passed as the third argument to each of the sub-reducers when they are executed instead of `prevGlobalState`. This can be used as a way of restricting access to the global state from the sub-reducers or as a way to chain multiple calls to `combineReducers` while still giving the subsidiary reducing functions access to a specific slice of the state. #### Notes @@ -118,4 +168,4 @@ console.log(store.getState()) * This helper is just a convenience! You can write your own `combineReducers` that [works differently](https://github.com/acdlite/reduce-reducers), or even assemble the state object from the child reducers manually and write a root reducing function explicitly, like you would write any other function. -* You may call `combineReducers` at any level of the reducer hierarchy. It doesn’t have to happen at the top. In fact you may use it again to split the child reducers that get too complicated into independent grandchildren, and so on. +* You may call `combineReducers` at any level of the reducer hierarchy. Though it can compose a multi-level reducer tree, you don't always have to use it to define just the root reducer. You could, for instance, use it to create a root reducer with a custom sub-reducer that in turn, after executing some custom logic, invokes a reducer that was created by a separate call to `combineReducers`. diff --git a/src/combineReducers.js b/src/combineReducers.js index 5a8a20a9b7..9180f1e5e6 100644 --- a/src/combineReducers.js +++ b/src/combineReducers.js @@ -33,6 +33,7 @@ function getUnexpectedStateShapeWarningMessage(inputState, reducers, action) { `keys: "${reducerKeys.join('", "')}"` ) } + var unexpectedKeys = Object.keys(inputState).filter(key => !reducers.hasOwnProperty(key)) @@ -46,32 +47,58 @@ function getUnexpectedStateShapeWarningMessage(inputState, reducers, action) { } } -function assertReducerSanity(reducers) { - Object.keys(reducers).forEach(key => { - var reducer = reducers[key] - var initialState = reducer(undefined, { type: ActionTypes.INIT }) - - if (typeof initialState === 'undefined') { - throw new Error( - `Reducer "${key}" returned undefined during initialization. ` + - `If the state passed to the reducer is undefined, you must ` + - `explicitly return the initial state. The initial state may ` + - `not be undefined.` - ) - } - var type = '@@redux/PROBE_UNKNOWN_ACTION_' + Math.random().toString(36).substring(7).split('').join('.') - if (typeof reducer(undefined, { type }) === 'undefined') { - throw new Error( - `Reducer "${key}" returned undefined when probed with a random type. ` + - `Don't try to handle ${ActionTypes.INIT} or other actions in "redux/*" ` + - `namespace. They are considered private. Instead, you must return the ` + - `current state for any unknown actions, unless it is undefined, ` + - `in which case you must return the initial state, regardless of the ` + - `action type. The initial state may not be undefined.` - ) - } - }) +function cleanReducerTree(rootReducerTree) { + + function _cleanReducerTree(reducerTree) { + let finalReducers = {} + + Object.keys(reducerTree).forEach(key => { + + if (reducerTree[key] && typeof reducerTree[key] == 'object') { + let nextLevelReducer = reducerTree[key] + + if (nextLevelReducer.length) { + throw new Error( + `Reducer object at "${key}" was empty. Every item in the ` + + `reducer tree must either be a function or a non-empty object` + ) + } + + finalReducers[key] = _cleanReducerTree(nextLevelReducer) + } else if (typeof reducerTree[key] == 'function') { + var reducer = reducerTree[key] + var initialState = reducer(undefined, { type: ActionTypes.INIT }) + + if (typeof initialState === 'undefined') { + throw new Error( + `Reducer "${key}" returned undefined during initialization. ` + + `If the state passed to the reducer is undefined, you must ` + + `explicitly return the initial state. The initial state may ` + + `not be undefined.` + ) + } + + var type = '@@redux/PROBE_UNKNOWN_ACTION_' + Math.random().toString(36).substring(7).split('').join('.') + if (typeof reducer(undefined, { type }) === 'undefined') { + throw new Error( + `Reducer "${key}" returned undefined when probed with a random type. ` + + `Don't try to handle ${ActionTypes.INIT} or other actions in "redux/*" ` + + `namespace. They are considered private. Instead, you must return the ` + + `current state for any unknown actions, unless it is undefined, ` + + `in which case you must return the initial state, regardless of the ` + + `action type. The initial state may not be undefined.` + ) + } + + finalReducers[key] = reducer + } + }) + + return finalReducers + } + + return _cleanReducerTree(rootReducerTree) } /** @@ -80,7 +107,7 @@ function assertReducerSanity(reducers) { * into a single state object, whose keys correspond to the keys of the passed * reducer functions. * - * @param {Object} reducers An object whose values correspond to different + * @param {Object} rootReducerTree An object whose values correspond to different * reducer functions that need to be combined into one. One handy way to obtain * it is to use ES6 `import * as reducers` syntax. The reducers may never return * undefined for any action. Instead, they should return their initial state @@ -90,50 +117,57 @@ function assertReducerSanity(reducers) { * @returns {Function} A reducer function that invokes every reducer inside the * passed object, and builds a state object with the same shape. */ -export default function combineReducers(reducers) { - var reducerKeys = Object.keys(reducers) - var finalReducers = {} - for (var i = 0; i < reducerKeys.length; i++) { - var key = reducerKeys[i] - if (typeof reducers[key] === 'function') { - finalReducers[key] = reducers[key] - } - } - var finalReducerKeys = Object.keys(finalReducers) +export default function combineReducers(rootReducerTree) { + var finalReducers, sanityError - var sanityError try { - assertReducerSanity(finalReducers) + finalReducers = cleanReducerTree(rootReducerTree) } catch (e) { sanityError = e } + + function reducer(topLevelState = {}, action, stateWindow) { + stateWindow = stateWindow || topLevelState - return function combination(state = {}, action) { if (sanityError) { throw sanityError } - if (process.env.NODE_ENV !== 'production') { - var warningMessage = getUnexpectedStateShapeWarningMessage(state, finalReducers, action) - if (warningMessage) { - warning(warningMessage) + function reduce(reducerTree, state) { + + if (process.env.NODE_ENV !== 'production') { + var warningMessage = getUnexpectedStateShapeWarningMessage(state, reducerTree, action) + if (warningMessage) { + warning(warningMessage) + } } - } - var hasChanged = false - var nextState = {} - for (var i = 0; i < finalReducerKeys.length; i++) { - var key = finalReducerKeys[i] - var reducer = finalReducers[key] - var previousStateForKey = state[key] - var nextStateForKey = reducer(previousStateForKey, action) - if (typeof nextStateForKey === 'undefined') { - var errorMessage = getUndefinedStateErrorMessage(key, action) - throw new Error(errorMessage) - } - nextState[key] = nextStateForKey - hasChanged = hasChanged || nextStateForKey !== previousStateForKey + let nextState = {} + let hasChanged = false + Object.keys(reducerTree).forEach((key) => { + let nextStateForKey, prevStateForKey = state[key] || {} + + if (typeof reducerTree[key] === 'object') { + nextStateForKey = reduce(reducerTree[key], prevStateForKey) + } else if (typeof reducerTree[key] === 'function') { + nextStateForKey = reducerTree[key](state[key], action, stateWindow) + } + + if (typeof nextStateForKey === 'undefined') { + var errorMessage = getUndefinedStateErrorMessage(key, action) + throw new Error(errorMessage) + } + + hasChanged = hasChanged || nextStateForKey !== prevStateForKey + nextState[key] = nextStateForKey + }) + + return hasChanged ? nextState : state } - return hasChanged ? nextState : state + + return reduce(finalReducers, topLevelState) } + + return reducer } + diff --git a/test/combineReducers.spec.js b/test/combineReducers.spec.js index d861ba371f..c0925b0044 100644 --- a/test/combineReducers.spec.js +++ b/test/combineReducers.spec.js @@ -6,29 +6,73 @@ describe('Utils', () => { describe('combineReducers', () => { it('returns a composite reducer that maps the state keys to given reducers', () => { const reducer = combineReducers({ - counter: (state = 0, action) => - action.type === 'increment' ? state + 1 : state, + counters: { + foo: (state = 0, action) => + action.type === 'increment_foo' ? state + 1 : state, + bar: (state = 0, action) => + action.type === 'increment_bar' ? state + 1 : state + }, stack: (state = [], action) => - action.type === 'push' ? [ ...state, action.value ] : state + action.type === 'push' ? [ ...state, action.value ] : state }) - const s1 = reducer({}, { type: 'increment' }) - expect(s1).toEqual({ counter: 1, stack: [] }) - const s2 = reducer(s1, { type: 'push', value: 'a' }) - expect(s2).toEqual({ counter: 1, stack: [ 'a' ] }) + const s1 = reducer({}, { type: 'increment_foo' }) + expect(s1).toEqual({ counters: { foo: 1, bar: 0 }, stack: [] }) + + const s2 = reducer(s1, { type: 'increment_bar' }) + expect(s2).toEqual({ counters: { foo: 1, bar: 1 }, stack: [] }) + + const s3 = reducer(s2, { type: 'push', value: 'a' }) + expect(s3).toEqual({ counters: { foo: 1, bar: 1 }, stack: [ 'a' ] }) + }) + + it('passes the top-level state to each reducer in the tree', () => { + var reducers = { + foo: { + bar: () => { return {} } + } + } + + const spy = expect.spyOn(reducers.foo, 'bar').andCallThrough() + const reducer = combineReducers(reducers) + + reducer({ foo: { bar: 0 } }, { type: 'QUX' }) + var lastCall = spy.calls[spy.calls.length - 1] + expect(lastCall.arguments[0]).toEqual(0) + expect(lastCall.arguments[1]).toEqual({ type: 'QUX' }) + expect(lastCall.arguments[2]).toEqual({ foo: { bar: 0 } }) + }) + + it('can be chained and still correctly pass top-level state', () => { + var subreducers = { + bar : () => { return {} } + } + const spy = expect.spyOn(subreducers, 'bar').andCallThrough() + + var reducers = { + foo: combineReducers(subreducers) + } + + const rootReducer = combineReducers(reducers) + rootReducer({ foo: { bar: 0 } }, { type: 'QUX' }) + var lastCall = spy.calls[spy.calls.length - 1] + expect(lastCall.arguments[0]).toEqual(0) + expect(lastCall.arguments[1]).toEqual({ type: 'QUX' }) + expect(lastCall.arguments[2]).toEqual({ foo: { bar: 0 } }) }) - it('ignores all props which are not a function', () => { + it('ignores all props which are not a function or a non-empty object', () => { const reducer = combineReducers({ fake: true, broken: 'string', - another: { nested: 'object' }, + counters: { increment: (state = []) => state }, stack: (state = []) => state }) - - expect( - Object.keys(reducer({ }, { type: 'push' })) - ).toEqual([ 'stack' ]) + + var stateKeys = Object.keys(reducer({ }, { type: 'push' })) + expect(stateKeys.length).toEqual(2) + expect(stateKeys).toInclude('counters') + expect(stateKeys).toInclude('stack') }) it('throws an error if a reducer returns undefined handling an action', () => { @@ -114,14 +158,16 @@ describe('Utils', () => { it('maintains referential equality if the reducers it is combining do', () => { const reducer = combineReducers({ - child1(state = { }) { - return state - }, - child2(state = { }) { - return state - }, - child3(state = { }) { - return state + foo: { + child1(state = { }) { + return state + }, + child2(state = { }) { + return state + }, + child3(state = { }) { + return state + } } }) @@ -131,24 +177,34 @@ describe('Utils', () => { it('does not have referential equality if one of the reducers changes something', () => { const reducer = combineReducers({ - child1(state = { }) { - return state - }, - child2(state = { count: 0 }, action) { - switch (action.type) { - case 'increment': - return { count: state.count + 1 } - default: - return state + foo: { + child1(state = { }) { + return state + }, + child2(state = { count: 0 }, action) { + switch (action.type) { + case 'increment': + return { count: state.count + 1 } + default: + return state + } + }, + child3(state = { }) { + return state } }, - child3(state = { }) { - return state + bar: { + child1(state = { }) { + return state + } } }) const initialState = reducer(undefined, '@@INIT') - expect(reducer(initialState, { type: 'increment' })).toNotBe(initialState) + const nextState = reducer(initialState, { type: 'increment' }) + expect(nextState).toNotBe(initialState) + expect(nextState.foo).toNotBe(initialState.foo) + expect(nextState.bar).toBe(initialState.bar) }) it('throws an error on first call if a reducer attempts to handle a private action', () => { @@ -185,54 +241,62 @@ describe('Utils', () => { it('warns if input state does not match reducer shape', () => { const spy = expect.spyOn(console, 'error') const reducer = combineReducers({ - foo(state = { bar: 1 }) { - return state - }, - baz(state = { qux: 3 }) { - return state + foo: { + bar(state = { baz: 1 }) { + return state + }, + qux(state = { corge: 3 }) { + return state + } } }) reducer() expect(spy.calls.length).toBe(0) - reducer({ foo: { bar: 2 } }) + reducer({ + foo : { + bar: { baz: 2 } + } + }) expect(spy.calls.length).toBe(0) reducer({ - foo: { bar: 2 }, - baz: { qux: 4 } + foo: { + bar: { baz: 2 }, + qux: { corge: 4 } + } }) expect(spy.calls.length).toBe(0) createStore(reducer, { bar: 2 }) expect(spy.calls[0].arguments[0]).toMatch( - /Unexpected key "bar".*createStore.*instead: "foo", "baz"/ + /Unexpected key "bar".*createStore.*instead: "foo"/ ) createStore(reducer, { bar: 2, qux: 4 }) expect(spy.calls[1].arguments[0]).toMatch( - /Unexpected keys "bar", "qux".*createStore.*instead: "foo", "baz"/ + /Unexpected keys "bar", "qux".*createStore.*instead: "foo"/ ) createStore(reducer, 1) expect(spy.calls[2].arguments[0]).toMatch( - /createStore has unexpected type of "Number".*keys: "foo", "baz"/ + /createStore has unexpected type of "Number".*keys: "foo"/ ) reducer({ bar: 2 }) expect(spy.calls[3].arguments[0]).toMatch( - /Unexpected key "bar".*reducer.*instead: "foo", "baz"/ + /Unexpected key "bar".*reducer.*instead: "foo"/ ) reducer({ bar: 2, qux: 4 }) expect(spy.calls[4].arguments[0]).toMatch( - /Unexpected keys "bar", "qux".*reducer.*instead: "foo", "baz"/ + /Unexpected keys "bar", "qux".*reducer.*instead: "foo"/ ) reducer(1) expect(spy.calls[5].arguments[0]).toMatch( - /reducer has unexpected type of "Number".*keys: "foo", "baz"/ + /reducer has unexpected type of "Number".*keys: "foo"/ ) spy.restore()