The shape of my Redux state looks like this:
{
user: {
id: 123,
items: [1, 2]
},
items: {
1: {
...
},
2: {
...
}
}
}
I think what you're doing is actually correct!
When dispatching an action, starting from the root-reducer, every "sub-reducer" will be called, passing the corresponding "sub-state" and action to the next layer of sub-reducers. You might think that this is not a good pattern since every "sub-reducer" gets called and propagates all the way down to every single leaf node of the state tree, but this is actually not the case!
If the action is defined in the switch case, the "sub-reducer" will only change the "sub-state" part it owns, and maybe passes the action to the next layer, but if the action isn't defined in the "sub-reducer", it will do nothing and return the current "sub-state", which stops the propagation.
Let's see an example with a more complex state tree!
Say you use redux-simple-router
, and I extended your case to be more complex (having data of multiple users), then your state tree might look something like this:
{
currentUser: {
loggedIn: true,
id: 123,
},
entities: {
users: {
123: {
id: 123,
items: [1, 2]
},
456: {
id: 456,
items: [...]
}
},
items: {
1: {
...
},
2: {
...
}
}
},
routing: {
changeId: 3,
path: "/",
state: undefined,
replace:false
}
}
As you can see already, there are nested layers in the state tree, and to deal with this we use reducer composition
, and the concept is to use combineReducer()
for every layer in the state tree.
So your reducer should look something like this: (To illustrate the layer by layer concept, this is outside-in, so the order is backwards)
import { routeReducer } from 'redux-simple-router'
function currentUserReducer(state = {}, action) {
switch (action.type) {...}
}
const rootReducer = combineReducers({
currentUser: currentUserReducer,
entities: entitiesReducer, // from the second layer
routing: routeReducer // from 'redux-simple-router'
})
function usersReducer(state = {}, action) {
switch (action.type) {
case ADD_ITEM:
case TYPE_TWO:
case TYPE_TREE:
return Object.assign({}, state, {
// you can think of this as passing it to the "third layer"
[action.userId]: itemsInUserReducer(state[action.userId], action)
})
case TYPE_FOUR:
return ...
default:
return state
}
}
function itemsReducer(...) {...}
const entitiesReducer = combineReducers({
users: usersReducer,
items: itemsReducer
})
/**
* Note: only ADD_ITEM, TYPE_TWO, TYPE_TREE will be called here,
* no other types will propagate to this reducer
*/
function itemsInUserReducer(state = {}, action) {
switch (action.type) {
case ADD_ITEM:
return Object.assign({}, state, {
items: state.items.concat([action.itemId])
// or items: [...state.items, action.itemId]
})
case TYPE_TWO:
return DO_SOMETHING
case TYPE_TREE:
return DO_SOMETHING_ELSE
default:
state:
}
}
redux will call every sub-reducer from the rootReducer,
passing:
currentUser: {...}
sub-state and the whole action to currentUserReducer
entities: {users: {...}, items: {...}}
and action to entitiesReducer
routing: {...}
and action to routeReducer
and...
entitiesReducer
will pass users: {...}
and action to usersReducer
,
and items: {...}
and action to itemsReducer
So you mentioned is there a way to have the root reducer handling different parts of the state, instead of passing them to separate sub-reducers. But if you don't use reducer composition and write a huge reducer to handle every part of the state, or you simply nest you state into a deeply nested tree, then as your app gets more complicated (say every user has a [friends]
array, or items can have [tags]
, etc), it will be insanely complicated if not impossible to figure out every case.
Furthermore, splitting reducers makes your app extremely flexible, you just have to add any case TYPE_NAME
to a reducer to react to that action (as long as your parent reducer passes it down).
For example if you want to track if the user visits some route, just add the case UPDATE_PATH
to your reducer switch!