The following is directly from a post by the redux/normalizr creator here:
So your state would look like:
{
entities: {
plans: {
1: {title: 'A', exercises: [1, 2, 3]},
2: {title: 'B', exercises: [5, 1, 2]}
},
exercises: {
1: {title: 'exe1'},
2: {title: 'exe2'},
3: {title: 'exe3'}
}
},
currentPlans: [1, 2]
}
Your reducers might look like
import merge from 'lodash/object/merge';
const exercises = (state = {}, action) => {
switch (action.type) {
case 'CREATE_EXERCISE':
return {
...state,
[action.id]: {
...action.exercise
}
};
case 'UPDATE_EXERCISE':
return {
...state,
[action.id]: {
...state[action.id],
...action.exercise
}
};
default:
if (action.entities && action.entities.exercises) {
return merge({}, state, action.entities.exercises);
}
return state;
}
}
const plans = (state = {}, action) => {
switch (action.type) {
case 'CREATE_PLAN':
return {
...state,
[action.id]: {
...action.plan
}
};
case 'UPDATE_PLAN':
return {
...state,
[action.id]: {
...state[action.id],
...action.plan
}
};
default:
if (action.entities && action.entities.plans) {
return merge({}, state, action.entities.plans);
}
return state;
}
}
const entities = combineReducers({
plans,
exercises
});
const currentPlans = (state = [], action) {
switch (action.type) {
case 'CREATE_PLAN':
return [...state, action.id];
default:
return state;
}
}
const reducer = combineReducers({
entities,
currentPlans
});
So what's going on here? First, note that the state is normalized. We never have entities inside other entities. Instead, they refer to each other by IDs. So whenever some object changes, there is just a single place where it needs to be updated.
Second, notice how we react to CREATE_PLAN by both adding an appropriate entity in the plans reducer and by adding its ID to the currentPlans reducer. This is important. In more complex apps, you may have relationships, e.g. plans reducer can handle ADD_EXERCISE_TO_PLAN in the same way by appending a new ID to the array inside the plan. But if the exercise itself is updated, there is no need for plans reducer to know that, as ID has not changed.
Third, notice that the entities reducers (plans and exercises) have special clauses watching out for action.entities. This is in case we have a server response with “known truth” that we want to update all our entities to reflect. To prepare your data in this way before dispatching an action, you can use normalizr. You can see it used in the “real world” example in Redux repo.
Finally, notice how entities reducers are similar. You might want to write a function to generate those. It's out of scope of my answer—sometimes you want more flexibility, and sometimes you want less boilerplate. You can check out pagination code in “real world” example reducers for an example of generating similar reducers.
Oh, and I used { ...a, ...b } syntax. It's enabled in Babel stage 2 as ES7 proposal. It's called “object spread operator” and equivalent to writing Object.assign({}, a, b).
As for libraries, you can use Lodash (be careful not to mutate though, e.g. merge({}, a, b} is correct but merge(a, b) is not), updeep, react-addons-update or something else. However if you find yourself needing to do deep updates, it probably means your state tree is not flat enough, and that you don't utilize functional composition enough. Even your first example:
case 'UPDATE_PLAN':
return {
...state,
plans: [
...state.plans.slice(0, action.idx),
Object.assign({}, state.plans[action.idx], action.plan),
...state.plans.slice(action.idx + 1)
]
};
can be written as
const plan = (state = {}, action) => {
switch (action.type) {
case 'UPDATE_PLAN':
return Object.assign({}, state, action.plan);
default:
return state;
}
}
const plans = (state = [], action) => {
if (typeof action.idx === 'undefined') {
return state;
}
return [
...state.slice(0, action.idx),
plan(state[action.idx], action),
...state.slice(action.idx + 1)
];
};
// somewhere
case 'UPDATE_PLAN':
return {
...state,
plans: plans(state.plans, action)
};