Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature Request: Allow reducers to consult global state #1768

Closed
wants to merge 8 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 58 additions & 8 deletions docs/api/combineReducers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <function>,
bar: <function>,
baz: {
baz1: <function>,
baz2: <function>
}
})
```

...then the shape of the resulting state when running `myReducer` would look like the following.

```
{
reducer1: ...
reducer2: ...
}
foo: <result of foo reducer>,
bar: <result of bar reducer>,
baz: {
baz1: <result of baz1 reducer>,
baz2: <result of baz2 reducer>
}
```

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: <reducer function>,
foo: <reducer function>,
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

Expand Down Expand Up @@ -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`.
150 changes: 92 additions & 58 deletions src/combineReducers.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ function getUnexpectedStateShapeWarningMessage(inputState, reducers, action) {
`keys: "${reducerKeys.join('", "')}"`
)
}


var unexpectedKeys = Object.keys(inputState).filter(key => !reducers.hasOwnProperty(key))

Expand All @@ -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)
}

/**
Expand All @@ -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
Expand All @@ -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
}

Loading