11. Normalizing the State shape

Video Link

We currently represent the todos in the state tree as an array of todo objects. However, in the real app we would probably have more than a single array, and todos with the same ids in different arrays might get out of sync.

todos.js Before

const todos = (state = [], action) => {
  switch (action.type) {
    case 'ADD_TODO':
      return [
        ...state,
        todo(undefined, action),
      ];
    case 'TOGGLE_TODO':
      return state.map(t =>
        todo(t, action)
      );
    default:
      return state;
  }
};

Refactoring todos.js

We should treat our state as a database, so we are going to keep todos in an object indexed by id.

We will start by renaming the reducer to byID. Now, rather than adding a new item at the end or mapping over every item, we will change the value in the lookup table.

Now both TOGGLE_TODO and ADD_TODO have the same logic. We want to return a new lookup table where the value of action.id is going to be the result of calling the reducer on the previous action.id value and the action.

const byId = (state = {}, action) => {
  switch (action.type) {
    case 'ADD_TODO':
    case 'TOGGLE_TODO':
      return {
        ...state,
        [action.id]: todo(state[action.id], action),
      };
    default:
      return state;
  }
};

This is still reducer composition, but with an object instead of an array.


NOTE:

We are using the Object Spread operator (...state). This is not a part of ES6, so we need to install the transform-object-rest-spread Babel plugin, and add it to our .babelrc file in order for this to work.

Anytime the byId reducer receives an action, it's going to return a copy of its mapping between the ids and the actual todos with an updated todo for the current action.

Now we'll add another reducer that keeps track of all the added ids.

Adding an allIds Reducer

Now that we keep the todos themselves in the byId map, we will have the state of this reducer be an array of ids.

This reducer will switch on the action's type, and the only action I care about is 'ADD_TODO' because if a new todo is added, we want to return a new array of ids with the new id as the last item.

For any other actions, we just need to return the current state (which is the current array of ids).

const allIds = (state = [], action) => {
  switch (action.type) {
    case 'ADD_TODO':
      return [...state, action.id];
    default:
      return state;
  }
};

We still need to export the single reducer from the todos.js file, so we use combineReducers() again to combine the byId and the allIds reducers.

const todos = combineReducers({
  byId,
  allIds,
});

Note: You can use combined reducers as many times as you like. You don't have to only use it on the top-level reducer. In fact, it's very common that as your app grows, you'll use combineReducers in several places.


Updating the getVisibleTodos Selector

Now that we have changed the state shape in our reducers, we also need to update the selectors that rely on it.

The state object in getVisibleTodos is now going to contain byId and allIds fields, because it corresponds to the state of the combined reducer.

Since we don't use an array of todos anymore, we will write a getAllTodos selector to create the array for us.

getAllTodos will take the current state and return all todos by mapping allIds to the state's byId lookup table.

We won't export getAllTodos because it will only be used in the current file.

const getAllTodos = (state) =>
  state.allIds.map(id => state.byId[id]);

We will use this new selector inside our getVisibleTodo selector to obtain an array of todos that can be filtered.

allTodos is an array of todos just like the components expect, so we can return it from the selector and not worry about changing component code.

export const getVisibleTodos = (state, filter) => {
  const allTodos = getAllTodos(state);
  switch (filter) {
    case 'all':
      return allTodos;
    case 'completed':
      return allTodos.filter(t => t.completed);
    case 'active':
      return allTodos.filter(t => !t.completed);
    default:
      throw new Error(`Unknown filter: ${filter}.`);
  }
};

Extracting the todo Reducer

The todos.js file has grown quite a bit, so it's a good time to extract the todo reducer that manages a single todo into a separate file of its own.

We will create a file called todo.js in the same src/reducers folder, paste in the implementation. Now we can import it into the todos.js file.

todo.js

const todo = (state, action) => {
  switch (action.type) {
    case 'ADD_TODO':
      return {
        id: action.id,
        text: action.text,
        completed: false,
      };
    case 'TOGGLE_TODO':
      if (state.id !== action.id) {
        return state;
      }
      return {
        ...state,
        completed: !state.completed,
      };
    default:
      return state;
  }
};

export default todo;

todos.js

import { combineReducers } from 'redux';
import todo from './todo';

const byId = (state = {}, action) => {
  switch (action.type) {
    case 'ADD_TODO':
    case 'TOGGLE_TODO':
      return {
        ...state,
        [action.id]: todo(state[action.id], action),
      };
    default:
      return state;
  }
};

const allIds = (state = [], action) => {
  switch (action.type) {
    case 'ADD_TODO':
      return [...state, action.id];
    default:
      return state;
  }
};

const todos = combineReducers({
  byId,
  allIds,
});

export default todos;

const getAllTodos = (state) =>
  state.allIds.map(id => state.byId[id]);

export const getVisibleTodos = (state, filter) => {
  const allTodos = getAllTodos(state);
  switch (filter) {
    case 'all':
      return allTodos;
    case 'completed':
      return allTodos.filter(t => t.completed);
    case 'active':
      return allTodos.filter(t => !t.completed);
    default:
      throw new Error(`Unknown filter: ${filter}.`);
  }
};

Recap at 3:54 in video

results matching ""

    No results matching ""