Refactoring Reducer Logic Using Functional Decomposition and Reducer Composition

It may be helpful to see examples of what the different types of sub-reducer functions look like and how they fit together. Let's look at a demonstration of how a large single reducer function can be refactored into a composition of several smaller functions.

Note: this example is deliberately written in a verbose style in order to illustrate the concepts and the process of refactoring, rather than perfectly concise code.

Initial Reducer

Let's say that our initial reducer looks like this:

  1. const initialState = {
  2. visibilityFilter: 'SHOW_ALL',
  3. todos: []
  4. }
  5. function appReducer(state = initialState, action) {
  6. switch (action.type) {
  7. case 'SET_VISIBILITY_FILTER': {
  8. return Object.assign({}, state, {
  9. visibilityFilter: action.filter
  10. })
  11. }
  12. case 'ADD_TODO': {
  13. return Object.assign({}, state, {
  14. todos: state.todos.concat({
  15. id: action.id,
  16. text: action.text,
  17. completed: false
  18. })
  19. })
  20. }
  21. case 'TOGGLE_TODO': {
  22. return Object.assign({}, state, {
  23. todos: state.todos.map(todo => {
  24. if (todo.id !== action.id) {
  25. return todo
  26. }
  27. return Object.assign({}, todo, {
  28. completed: !todo.completed
  29. })
  30. })
  31. })
  32. }
  33. case 'EDIT_TODO': {
  34. return Object.assign({}, state, {
  35. todos: state.todos.map(todo => {
  36. if (todo.id !== action.id) {
  37. return todo
  38. }
  39. return Object.assign({}, todo, {
  40. text: action.text
  41. })
  42. })
  43. })
  44. }
  45. default:
  46. return state
  47. }
  48. }

That function is fairly short, but already becoming overly complex. We're dealing with two different areas of concern (filtering vs managing our list of todos), the nesting is making the update logic harder to read, and it's not exactly clear what's going on everywhere.

Extracting Utility Functions

A good first step might be to break out a utility function to return a new object with updated fields. There's also a repeated pattern with trying to update a specific item in an array that we could extract to a function:

  1. function updateObject(oldObject, newValues) {
  2. // Encapsulate the idea of passing a new object as the first parameter
  3. // to Object.assign to ensure we correctly copy data instead of mutating
  4. return Object.assign({}, oldObject, newValues)
  5. }
  6. function updateItemInArray(array, itemId, updateItemCallback) {
  7. const updatedItems = array.map(item => {
  8. if (item.id !== itemId) {
  9. // Since we only want to update one item, preserve all others as they are now
  10. return item
  11. }
  12. // Use the provided callback to create an updated item
  13. const updatedItem = updateItemCallback(item)
  14. return updatedItem
  15. })
  16. return updatedItems
  17. }
  18. function appReducer(state = initialState, action) {
  19. switch (action.type) {
  20. case 'SET_VISIBILITY_FILTER': {
  21. return updateObject(state, { visibilityFilter: action.filter })
  22. }
  23. case 'ADD_TODO': {
  24. const newTodos = state.todos.concat({
  25. id: action.id,
  26. text: action.text,
  27. completed: false
  28. })
  29. return updateObject(state, { todos: newTodos })
  30. }
  31. case 'TOGGLE_TODO': {
  32. const newTodos = updateItemInArray(state.todos, action.id, todo => {
  33. return updateObject(todo, { completed: !todo.completed })
  34. })
  35. return updateObject(state, { todos: newTodos })
  36. }
  37. case 'EDIT_TODO': {
  38. const newTodos = updateItemInArray(state.todos, action.id, todo => {
  39. return updateObject(todo, { text: action.text })
  40. })
  41. return updateObject(state, { todos: newTodos })
  42. }
  43. default:
  44. return state
  45. }
  46. }

That reduced the duplication and made things a bit easier to read.

Extracting Case Reducers

Next, we can split each specific case into its own function:

  1. // Omitted
  2. function updateObject(oldObject, newValues) {}
  3. function updateItemInArray(array, itemId, updateItemCallback) {}
  4. function setVisibilityFilter(state, action) {
  5. return updateObject(state, { visibilityFilter: action.filter })
  6. }
  7. function addTodo(state, action) {
  8. const newTodos = state.todos.concat({
  9. id: action.id,
  10. text: action.text,
  11. completed: false
  12. })
  13. return updateObject(state, { todos: newTodos })
  14. }
  15. function toggleTodo(state, action) {
  16. const newTodos = updateItemInArray(state.todos, action.id, todo => {
  17. return updateObject(todo, { completed: !todo.completed })
  18. })
  19. return updateObject(state, { todos: newTodos })
  20. }
  21. function editTodo(state, action) {
  22. const newTodos = updateItemInArray(state.todos, action.id, todo => {
  23. return updateObject(todo, { text: action.text })
  24. })
  25. return updateObject(state, { todos: newTodos })
  26. }
  27. function appReducer(state = initialState, action) {
  28. switch (action.type) {
  29. case 'SET_VISIBILITY_FILTER':
  30. return setVisibilityFilter(state, action)
  31. case 'ADD_TODO':
  32. return addTodo(state, action)
  33. case 'TOGGLE_TODO':
  34. return toggleTodo(state, action)
  35. case 'EDIT_TODO':
  36. return editTodo(state, action)
  37. default:
  38. return state
  39. }
  40. }

Now it's very clear what's happening in each case. We can also start to see some patterns emerging.

Separating Data Handling by Domain

Our app reducer is still aware of all the different cases for our application. Let's try splitting things up so that the filter logic and the todo logic are separated:

  1. // Omitted
  2. function updateObject(oldObject, newValues) {}
  3. function updateItemInArray(array, itemId, updateItemCallback) {}
  4. function setVisibilityFilter(visibilityState, action) {
  5. // Technically, we don't even care about the previous state
  6. return action.filter
  7. }
  8. function visibilityReducer(visibilityState = 'SHOW_ALL', action) {
  9. switch (action.type) {
  10. case 'SET_VISIBILITY_FILTER':
  11. return setVisibilityFilter(visibilityState, action)
  12. default:
  13. return visibilityState
  14. }
  15. }
  16. function addTodo(todosState, action) {
  17. const newTodos = todosState.concat({
  18. id: action.id,
  19. text: action.text,
  20. completed: false
  21. })
  22. return newTodos
  23. }
  24. function toggleTodo(todosState, action) {
  25. const newTodos = updateItemInArray(todosState, action.id, todo => {
  26. return updateObject(todo, { completed: !todo.completed })
  27. })
  28. return newTodos
  29. }
  30. function editTodo(todosState, action) {
  31. const newTodos = updateItemInArray(todosState, action.id, todo => {
  32. return updateObject(todo, { text: action.text })
  33. })
  34. return newTodos
  35. }
  36. function todosReducer(todosState = [], action) {
  37. switch (action.type) {
  38. case 'ADD_TODO':
  39. return addTodo(todosState, action)
  40. case 'TOGGLE_TODO':
  41. return toggleTodo(todosState, action)
  42. case 'EDIT_TODO':
  43. return editTodo(todosState, action)
  44. default:
  45. return todosState
  46. }
  47. }
  48. function appReducer(state = initialState, action) {
  49. return {
  50. todos: todosReducer(state.todos, action),
  51. visibilityFilter: visibilityReducer(state.visibilityFilter, action)
  52. }
  53. }

Notice that because the two "slice of state" reducers are now getting only their own part of the whole state as arguments, they no longer need to return complex nested state objects, and are now simpler as a result.

Reducing Boilerplate

We're almost done. Since many people don't like switch statements, it's very common to use a function that creates a lookup table of action types to case functions. We'll use the createReducer function described in Reducing Boilerplate:

  1. // Omitted
  2. function updateObject(oldObject, newValues) {}
  3. function updateItemInArray(array, itemId, updateItemCallback) {}
  4. function createReducer(initialState, handlers) {
  5. return function reducer(state = initialState, action) {
  6. if (handlers.hasOwnProperty(action.type)) {
  7. return handlers[action.type](state, action)
  8. } else {
  9. return state
  10. }
  11. }
  12. }
  13. // Omitted
  14. function setVisibilityFilter(visibilityState, action) {}
  15. const visibilityReducer = createReducer('SHOW_ALL', {
  16. SET_VISIBILITY_FILTER: setVisibilityFilter
  17. })
  18. // Omitted
  19. function addTodo(todosState, action) {}
  20. function toggleTodo(todosState, action) {}
  21. function editTodo(todosState, action) {}
  22. const todosReducer = createReducer([], {
  23. ADD_TODO: addTodo,
  24. TOGGLE_TODO: toggleTodo,
  25. EDIT_TODO: editTodo
  26. })
  27. function appReducer(state = initialState, action) {
  28. return {
  29. todos: todosReducer(state.todos, action),
  30. visibilityFilter: visibilityReducer(state.visibilityFilter, action)
  31. }
  32. }

Combining Reducers by Slice

As our last step, we can now use Redux's built-in combineReducers utility to handle the "slice-of-state" logic for our top-level app reducer. Here's the final result:

  1. // Reusable utility functions
  2. function updateObject(oldObject, newValues) {
  3. // Encapsulate the idea of passing a new object as the first parameter
  4. // to Object.assign to ensure we correctly copy data instead of mutating
  5. return Object.assign({}, oldObject, newValues)
  6. }
  7. function updateItemInArray(array, itemId, updateItemCallback) {
  8. const updatedItems = array.map(item => {
  9. if (item.id !== itemId) {
  10. // Since we only want to update one item, preserve all others as they are now
  11. return item
  12. }
  13. // Use the provided callback to create an updated item
  14. const updatedItem = updateItemCallback(item)
  15. return updatedItem
  16. })
  17. return updatedItems
  18. }
  19. function createReducer(initialState, handlers) {
  20. return function reducer(state = initialState, action) {
  21. if (handlers.hasOwnProperty(action.type)) {
  22. return handlers[action.type](state, action)
  23. } else {
  24. return state
  25. }
  26. }
  27. }
  28. // Handler for a specific case ("case reducer")
  29. function setVisibilityFilter(visibilityState, action) {
  30. // Technically, we don't even care about the previous state
  31. return action.filter
  32. }
  33. // Handler for an entire slice of state ("slice reducer")
  34. const visibilityReducer = createReducer('SHOW_ALL', {
  35. SET_VISIBILITY_FILTER: setVisibilityFilter
  36. })
  37. // Case reducer
  38. function addTodo(todosState, action) {
  39. const newTodos = todosState.concat({
  40. id: action.id,
  41. text: action.text,
  42. completed: false
  43. })
  44. return newTodos
  45. }
  46. // Case reducer
  47. function toggleTodo(todosState, action) {
  48. const newTodos = updateItemInArray(todosState, action.id, todo => {
  49. return updateObject(todo, { completed: !todo.completed })
  50. })
  51. return newTodos
  52. }
  53. // Case reducer
  54. function editTodo(todosState, action) {
  55. const newTodos = updateItemInArray(todosState, action.id, todo => {
  56. return updateObject(todo, { text: action.text })
  57. })
  58. return newTodos
  59. }
  60. // Slice reducer
  61. const todosReducer = createReducer([], {
  62. ADD_TODO: addTodo,
  63. TOGGLE_TODO: toggleTodo,
  64. EDIT_TODO: editTodo
  65. })
  66. // "Root reducer"
  67. const appReducer = combineReducers({
  68. visibilityFilter: visibilityReducer,
  69. todos: todosReducer
  70. })

We now have examples of several kinds of split-up reducer functions: helper utilities like updateObject and createReducer, handlers for specific cases like setVisibilityFilter and addTodo, and slice-of-state handlers like visibilityReducer and todosReducer. We also can see that appReducer is an example of a "root reducer".

Although the final result in this example is noticeably longer than the original version, this is primarily due to the extraction of the utility functions, the addition of comments, and some deliberate verbosity for the sake of clarity, such as separate return statements. Looking at each function individually, the amount of responsibility is now smaller, and the intent is hopefully clearer. Also, in a real application, these functions would probably then be split into separate files such as reducerUtilities.js, visibilityReducer.js, todosReducer.js, and rootReducer.js.