Refactoring Reducer Logic Using Functional Decomposition and Reducer Composition

    Initial Reducer

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

    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

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

    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:

    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

    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. switch (action.type) {
    9. case 'SET_VISIBILITY_FILTER':
    10. return setVisibilityFilter(visibilityState, action)
    11. default:
    12. return visibilityState
    13. }
    14. }
    15. const newTodos = todosState.concat({
    16. id: action.id,
    17. text: action.text,
    18. completed: false
    19. })
    20. return newTodos
    21. }
    22. function toggleTodo(todosState, action) {
    23. const newTodos = updateItemInArray(todosState, action.id, todo => {
    24. return updateObject(todo, { completed: !todo.completed })
    25. })
    26. return newTodos
    27. }
    28. function editTodo(todosState, action) {
    29. const newTodos = updateItemInArray(todosState, action.id, todo => {
    30. return updateObject(todo, { text: action.text })
    31. })
    32. return newTodos
    33. }
    34. function todosReducer(todosState = [], action) {
    35. switch (action.type) {
    36. case 'ADD_TODO':
    37. return addTodo(todosState, action)
    38. case 'TOGGLE_TODO':
    39. return toggleTodo(todosState, action)
    40. case 'EDIT_TODO':
    41. return editTodo(todosState, action)
    42. default:
    43. return todosState
    44. }
    45. }
    46. function appReducer(state = initialState, action) {
    47. return {
    48. todos: todosReducer(state.todos, action),
    49. visibilityFilter: visibilityReducer(state.visibilityFilter, action)
    50. }
    51. }

    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 :

    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. const updatedItem = updateItemCallback(item)
    14. return updatedItem
    15. })
    16. return updatedItems
    17. }
    18. function createReducer(initialState, handlers) {
    19. if (handlers.hasOwnProperty(action.type)) {
    20. return handlers[action.type](state, action)
    21. } else {
    22. return state
    23. }
    24. }
    25. }
    26. // Handler for a specific case ("case reducer")
    27. function setVisibilityFilter(visibilityState, action) {
    28. // Technically, we don't even care about the previous state
    29. return action.filter
    30. }
    31. // Handler for an entire slice of state ("slice reducer")
    32. const visibilityReducer = createReducer('SHOW_ALL', {
    33. SET_VISIBILITY_FILTER: setVisibilityFilter
    34. })
    35. // Case reducer
    36. function addTodo(todosState, action) {
    37. const newTodos = todosState.concat({
    38. id: action.id,
    39. text: action.text,
    40. completed: false
    41. })
    42. return newTodos
    43. }
    44. // Case reducer
    45. function toggleTodo(todosState, action) {
    46. const newTodos = updateItemInArray(todosState, action.id, todo => {
    47. return updateObject(todo, { completed: !todo.completed })
    48. })
    49. return newTodos
    50. }
    51. // Case reducer
    52. function editTodo(todosState, action) {
    53. const newTodos = updateItemInArray(todosState, action.id, todo => {
    54. return updateObject(todo, { text: action.text })
    55. })
    56. return newTodos
    57. }
    58. // Slice reducer
    59. const todosReducer = createReducer([], {
    60. ADD_TODO: addTodo,
    61. TOGGLE_TODO: toggleTodo,
    62. EDIT_TODO: editTodo
    63. })
    64. // "Root reducer"
    65. const appReducer = combineReducers({
    66. visibilityFilter: visibilityReducer,
    67. todos: todosReducer

    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 .