In Redux, reducer composition with combineReducers
offers a powerful way to handle
complex update logic of an application. A reducer can encapsulate all the ways a part
of the state is mutated because it can react to multiple types of actions.
But in some cases there is also a need for another type of factoring: often the update logic is simple (for example setting a single value), and the there are many places in the state shape where the update logic is the same.
For example, let's take (the deadly boring) counter
demo
import { createStore } from 'redux'
function counter(state = 0, action) {
switch(action.type) {
case 'INCREMENT':
return state + 1
case 'DECREMENT':
return state - 1
default:
return state
}
}
const store = createStore(counter)
store.dispatch({type: 'INCREMENT'})
// => state = 1
Now suppose our application requires 2 counters
import { createStore, combineReducers } from 'redux'
function counter(state = 0, action) {...}
const reducer = combineReducers({
first: counter,
second: counter
})
const store = createStore(reducer)
We want to increment only one counter (eg: the first
counter). But we can't simply
dispatch an INCREMENT
action
store.dispatch(increment())
// state = {first: 1, second: 1}
BAD! both counters get incremented.
This is because of how combineReducers
works: It dispatches the actions to all child
reducers. combineReducers
works in a Brodcast
mode: It propagate the message (action) to all reducer tree owend by it.
One solution is to use namespacing. An action destined to a specific part in the state will
have a namespace
property. And a reducer in that path will react only to actions whose path match its
own path.
We're going to write a Higher Order Reducer which converts a reducer to a namespaced one
function wrapReducerWithNS(reducer, namespace) {
return function namespaced(state, action) {
// propagates the INIT action
if(state === undefined) {
return reducer(undefined, action)
}
// propagates only for matching namespaces
else if(action.namespace === namespace) {
return reducer(state, action)
}
return state
}
}
Used in our first example
import { createStore, combineReducers } from 'redux'
function counter(state = 0, action) {...}
const reducer = combineReducers({
first: wrapReducerWithNS(counter, 'first'),
second: wrapReducerWithNS(counter, 'second')
})
const store = createStore(reducer)
store.dispatch({type: 'INCREMENT', namespace: 'first'})
// state = {first: 1, second: 0}
Good. But we can do better
function combineReducersWithNS(reducers) {
const reducersWithNS = {}
Object.keys(reducers).forEach(key => {
const reducer = reducers[key]
reducersWithNS[key] = wrapReducerWithNS(reducer, key)
})
return combineReducers(reducersWithNS)
}
So we can have a more concise code
const reducer = combineReducersWithNS({
first: counter,
second: counter
})
But we can still do better
There are a couple of issues with the so far implemented solution
-
Handle deeply nested reducers
-
Avoid hardcoding namespaces in the actions
Instead of a simple string, We'll use an array to represent a complete path to our reducer.
The reducer will look at the prefix path (the first item in the array) and if it matches its own namespace then it'll forward the action down to the child reducer.
function wrapReducer(reducer, ns) {
return (state, action) => {
if(state === undefined)
return reducer(undefined, action)
if(action.ns && action.ns[0] === ns) {
return reducer(state, unwrapAction(action))
}
return state
}
}
// stripes the current namespace so the action
// can be checked by the next reducer down the tree
function unwrapAction(action) {
return {
...action,
namespace: action.namespace.slice(1)
}
}
For example, if we have
combineReducersWithNS({
first: combineReducersWithNS({
first: counter,
second: counter,
}),
second: counter
})
Then we can increment the counter in the first/first
path using
const incremntFirstOfFirst = () => ({
type: 'INCREMENT',
namespace: ['first', 'first']
})
Next we'll see how to remove the hard coded path dependency from the action.
A model is pair (reducer, actions)
.
You can see it like an encapsulation of an isolated feature inside your application
function counter(state = 0, action) {
switch(action.type) {
case 'INCREMENT':
return state + 1
case 'DECREMENT':
return state - 1
default:
return state
}
}
const counterActions = {
increment: () => ({type: 'INCREMENT'}),
decrement: () => ({type: 'DECREMENT'})
}
const counterModel = [counter, counterActions]
We'are going to implement a combineModels
function which
- takes a pair (reducer, actions)
- returns a pair
reducer
: combination of namespaced reducers from child modelsactions
: an object with the same shape as model shape with namespaced actions
For example
const [reducer, actions] = combineModels({
first: combineModels({
first: counterModel,
second: counterModel,
}),
second: counterModel
})
actions.first.first.increment()
// => {type: 'INCREMENT', namespace: ['first', 'first']}
actions.first.second.increment()
// => {type: 'INCREMENT', namespace: ['first', 'second']}
actions.second.decrement()
// => {type: 'DECREMENT', namespace: ['second']}
function combineModels(models) {
const reducersWithNS = {}
const actionsWithNS = {}
Object.keys(models).forEach(key => {
const [reducer, actions] = models[key]
reducersWithNS[key] = wrapReducerWithNS(reducer, key)
actionsWithNS[key] = wrapActions(actions, [key])
})
return [combineReducers(reducersWithNS), actionsWithNS]
}
function wrapActions(actions, prefix=[]) {
const actionsWithNS = {}
Object.keys(actions).forEach(actionKey => {
const ac = actions[actionKey]
if(typeof ac === 'function') {
actionsWithNS[actionKey] = wrapActionCreator(actions[actionKey], prefix)
} else {
actionsWithNS[actionKey] = wrapActions(ac, prefix)
}
})
return actionsWithNS
}
function wrapActionCreator(ac, ns) {
return (...args) => {
const action = ac(...args)
action.ns = action.ns ? ns.concat(action.ns) : ns
return action
}
}
function wrapReducer(reducer, ns) {
return (state, action) => {
if(state === undefined)
return reducer(undefined, action)
if(action.ns && action.ns[0] === ns) {
return reducer(state, unwrapAction(action))
}
return state
}
}
function wrapActionCreator(ac, ns) {
return (...args) => {
const action = ac(...args)
action.ns = action.ns ? ns.concat(action.ns) : ns
return action
}
}