Chapter 8. Structuring a Redux store

This chapter covers

  • Structuring relational data in Redux
  • Learning the pros and cons of nested data versus normalized data
  • Using the normalizr package
  • Organizing different types of state within Redux

Parsnip is built using sound Redux fundamentals, but the app’s data requirements up to this point are simple. You have a single tasks resource, meaning you haven’t had any opportunities to deal with relational data. That’s what this chapter is for! You’ll double the number of resources in the app (from one to two!) by adding the concept of projects to Parsnip, meaning tasks will belong to a project. You’ll explore the pros and cons of two popular strategies for structuring relational data: nesting and normalization.

This is one of the hottest topics for debate in the Redux community. For better or worse, the library offers no restrictions to how you organize data within the store. Redux provides the tools to store and update data and leaves it up to you to decide the shape of that data. The good news is that over time, and after no short period of trial and error, best practices started to emerge. One strategy emerged as a clear winner: normalization. It’s not a concept that’s unique to Redux, but you’ll look at how to normalize data in Parsnip and the benefits normalization provides.

When you only had one resource, tasks, organizing the data wasn’t a question you had to labor over. With the addition of projects, that changes. What’s the most efficient way to store the data? Can you stick with one reducer or should you use multiple reducers? What strategy will make the data easier to update? What if you have duplicate data? Chapter 8 holds the answers to these questions and many more.

8.1. How should I store data in Redux?

The question of how and what to store in Redux surfaces regularly in the community. As usual, the answer varies, depending on whom you ask. Before you dig into the options, it’s worth remembering that Redux doesn’t necessarily care which strategy you decide on. If your data fits in an object, it can go in the Redux store. It’s up to you to decide how to maintain order within that object.

Note

You may wonder if there’s a limit to what you can keep in the Redux store. Official documentation strongly recommends storing only serializable primitives, objects, and arrays. Doing so guarantees the reliable use of surrounding tools, such as the Redux DevTools. If you’d like to handle a unique use case by storing non-serializable data in Redux, you’re free to do so at your own peril. This discussion can be found in the documentation at http://redux.js.org/docs/faq/OrganizingState.html#can-i-put-functions-promises-or-other-non-serializable-items-in-my-store-state.

Up to now you’ve chosen a simple and common pattern for storing the task-related data currently in your system. You have one reducer which has a list of task objects, along with metadata: isLoading and error. These specific property names aren’t significant; you may see many variations of them in the wild. You also introduced a searchTerm key in the last chapter to facilitate a filter feature.

With this pattern, the reducers (top-level state keys) tend to mimic a RESTful API philosophy. You can generally expect the tasks key to contain the data required to populate a tasks index page. Conversely, if you had a task show page, it would likely pull its data from a task (singular) reducer.

Having a single tasks reducer makes sense for Parsnip in its current state. Within connected components, you can easily read and display the tasks by referencing the attributes within the top-level tasks key. Parsnip is a small application, but this pattern scales efficiently, too. As each new domain is introduced to Parsnip, a new reducer is added to the store and contains its own resource data and metadata. If you decide you want to render a list of users, you can as easily introduce a users reducer that manages an array of users and metadata.

It’s not so difficult to work with a Redux store like this, but things can start to get hairy once you introduce relationships between resources. Let’s use Parsnip and projects as an example. The application may contain many projects, and each project may contain many tasks. How should you represent the relationship between projects and tasks in the Redux store?

As a starting point, let’s consider the response from the server. If you make a GET request to /projects/1, at a minimum, you can probably expect to receive an ID, a project name, and a list of tasks associated with the project, as shown in the following listing.

Listing 8.1. Example API response
{
 id: 1,
 title: 'Short-Term Goals',
 tasks: [
   { id: 3, title: 'Learn Redux' },
   { id: 5, title: 'Defend shuffleboard world championship title' },
 ],
}

Having received this payload from a successful GET request, the next logical thing to do is stuff it into a project reducer, right? When viewing a project, you can iterate through and render each of its tasks. The entirety of the store may look something like this example.

Listing 8.2. An example Redux store with projects and tasks
{
  project: {
    id: 1,
    title: 'Short-Term Goals',
    tasks: [
      { id: 3, title: 'Learn Redux' },
      { id: 5, title: 'Defend shuffleboard world championship title' },
    ],
    isLoading: false,
    error: null,
    searchTerm: '',
  },
}

Not so fast. This pattern has a couple of shortcomings. First, your React components that render task data may require safeguards. Referencing nested data, such as task titles in this instance, requires that each of the parent keys exist. In similar scenarios, you may find yourself needing to guard against the nonexistence of parent keys.

What would it take to update a task? You now have to deal with tasks being nested one level deep, which requires you to navigate through a project object.

Managing one layer of nesting is generally manageable, but what happens when you want to list the user that each task is assigned to? The problem only worsens. See the following listing for the new Redux state.

Listing 8.3. Adding a user to each task
{
  project: {
    id: 1,
    title: 'Short-Term Goals',
    tasks: [
      {
        id: 5,
        title: 'Learn Redux',
        user: {
          id: 1,
          name: 'Richard Roe',
        }
      },
    ],
    isLoading: false,
    error: null,
    searchTerm: '',
  }
}

You haven’t yet touched on the most egregious shortcoming of this pattern: duplicate data. If Richard is assigned to three different tasks, his user data appears in the state three times. If you want to update Richard’s name to John, that user object needs to be updated in three locations. Not ideal. What if you could treat tasks and projects more like you would in a relational database? You could store tasks and projects separately but use foreign key IDs to maintain any relationships. Enter normalization, which prescribes exactly that.

8.2. An introduction to normalized data

Normalization can also be thought of as flattening a nested data structure. In a flat hierarchy, each domain receives its own top-level state property. Tasks and projects can be managed independently rather than as children of projects, and object IDs can be used to express relationships. The data would then be considered normalized, and this type of architecture is the recommendation found in the Redux documentation at http://redux.js.org/docs/faq/OrganizingState.html#how-do-i-organize-nested-or-duplicate-data-in-my-state.

What does this flat Redux store look like? It resembles a relational database architecture, with a table for each resource type. As discussed, projects and tasks each get a top-level key in the store, and relationships are linkable by foreign keys. You know which tasks belong to which projects, because each task contains a projectId. Here’s an example of what a normalized store with projects and tasks looks like.

Listing 8.4. A normalized Redux store
{
  projects: {
    items: {
      '1': {                          1
        id: 1,
        name: 'Short-Term Goals',
        tasks: [ 1, 3 ]               2
      },
      '2': {
        id: 2,
        name: 'Long-Term Goals',
        tasks: [ 2 ]                  2
     }
    },
    isLoading: false,
    error: null
  },
  tasks: {
    items: {
      '1': { id: 1, projectId: 1, ... },
      '2': { id: 2, projectId: 2, ... },
      '3': { id: 3, projectId: 1, ... },
    },
    isLoading: false,
    error: null
  },
}

  • 1 Resources are stored in an object keyed by ID, instead of in an array.
  • 2 Each project has a list of task IDs, which can be used to reference objects in another part of the store.

Projects and tasks are now stored in an object and keyed by ID, instead of an array. This makes any future lookups much less of a chore. Say you’re updating a task: instead of looping over the entire array until you find the correct task, you can find the task immediately via its ID. Instead of nesting tasks within their respective projects, you now maintain relationships using a tasks array on each project, which stores an array of associated task IDs.

You’ll find a few key benefits with normalized data that you’ll explore over the course of the chapter:

  • Reduced duplication. What if you allow tasks to belong to more than one project? With a nested structure, you’d wind up with multiple representations of a single object. To update a single task, you now must hunt down every individual representation of that task.
  • Easier update logic. Instead of digging through nested objects, a normalized flat structure means you have to work only one level deep.
  • Performance. With tasks in a totally separate section of the state tree, you can update them without triggering updates in unrelated parts of the store.

The decision to nest or normalize data depends on the situation. You’d typically opt to normalize your data in Redux. But it’s not an open-and-shut case. You’ll start implementing projects using nested data to get a sense of the benefits and costs.

8.3. Implementing projects with nested data

With all this talk around projects and actions, it’s finally time to implement them and see what our store might look like beyond a single tasks reducer. Again, you chose to add projects because it means you now have relational data to manage. Instead of having a standalone tasks resource, tasks now belong to projects.

As far as user-facing changes, you’ll add a new drop-down menu where users can choose between projects. Each task will belong to a project. When a user chooses a new project, you’ll render the corresponding list of tasks. An example of what the UI might look like is shown in figure 8.1.

Figure 8.1. Allowing users to pick from a list of all projects

In the interest of time, you won’t implement the ability to create new projects or update existing projects. To compensate for this, you’ll use json-server and db.json to predefine a few projects, and assign them tasks.

We’ve talked about relational data and the two common methods for storing relational data in Redux: nested and normalized. We’ve already mentioned our preference for working with normalized data. It’s becoming a standard community recommendation, particularly with apps of a certain size with a large amount of relational data.

This isn’t to say that you should never store relational data in Redux in a nested fashion. For smaller apps, normalizing data can feel like overkill. Nested data structures are intuitive and can often make rendering friendlier. Take a blog application, for example. Instead of having to fetch an article, comments, and comment authors from three different sections of the store, you could pass into the view a single article object that contains all the necessary data. In this section, you’ll implement a nested data structure without normalization to illustrate the pros and cons of the strategy.

When you have an API that needs to return relational data, such as with projects and tasks, it’s a common strategy to nest any child data within its parent. You’ll update your API to return nested data, and as a result, you’ll end up storing roughly the same structure within the Redux store. Pretty intuitive, right? The API has defined a structure, so why not use that same structure in Redux? Any relationships between data are expressed using nesting, meaning you don’t have to maintain any foreign key IDs manually. You don’t need to do any translation between the API and Redux, and less code to maintain is often a good thing.

You’ll see several of these benefits in the implementation, but in the process, the shortcomings will reveal themselves, too. You might see duplication, update logic could become more difficult, and you’ll see decreased render performance due to re-rendering sections of the page unnecessarily.

8.3.1. Overview: fetching and rendering projects

Your new spec is to modify Parsnip to include projects. The main page should render the tasks for a given project, and users can choose which project to view using a drop-down menu.

These are the only user-facing changes. Turns out there’s work you’ll need to do under the hood to get things working, though. You need to update your data structure on the server as well as on the client. In addition to new features, you need to ensure existing functionality (creating or editing tasks, filtering) still works correctly. You’ll break the work into three big chunks, based on the major events that take place in Parsnip:

  • Fetch projects on initial load and render the main page (including the projects menu, and each column of tasks).
  • Allow users to choose which project to display.
  • Update task creation to use the new store structure and reducers.

Here’s a high-level view of the changes you’ll make, in order. You’ll start with the initial page load and follow the flow of data through the entire stack, ultimately ending back at the view, where you’ll render both projects and tasks. You’ll loosely follow this path as you continue to make progress toward projects:

  • As a prerequisite, update the server, which means modifying db.json. Add projects and update each task to belong to a project.
  • Dispatch a yet-to-be-created fetchProjects action creator on initial page load.
  • Create and implement fetchProjects, which is responsible for any actions related to fetching projects and loading them into the store.
  • Replace the tasks reducer with a projects reducer.
  • Update the reducer to handle projects coming from the server.
  • Add the concept of a currentProjectId, so you know which project’s tasks to render.
  • Update any connected components and selectors to handle a new Redux store structure with both projects and tasks.
  • Create the new projects drop-down menu and connect it to Redux.

To make these changes, you’re truly hitting all parts of the stack. Take this as an opportunity to reinforce how you visualize data flowing through a Redux application. Figure 8.2 is a review of your architecture diagram, and where each change will fit in. It can be helpful to think about web applications such as Parsnip in terms of individual events. An event can be made up of multiple Redux actions, but typically it represents one thing that users can do (creating a task) or that can happen (initial page load) in your application. Here, you’re visualizing what the initial page load looks like.

Figure 8.2. An overview of changes required to fetch and render projects

Listing 8.5 is a look at the desired store structure. You’ll use this structure as a guide to create any actions you need (including payloads) and to create a particular reducer structure. Keep an eye on three things: adding a new top-level projects property, nesting tasks within each project, and the addition of a new top-level page property for storing currentProjectId. Note that you moved and renamed the search filter portion of your state. It’s common to have UI state live in a different part of the tree as you add more typical application state such as projects and tasks. As a result, you renamed the property from searchTerm to tasksSearchTerm, because searchTerm is too generic, given that this piece of state isn’t living directly under tasks.

Listing 8.5. The Redux store’s structure using nested data
{
  projects: {
    isLoading: false,
    error: null,
    items: [                              1
      {
        id: 1,
        name: 'Short-term goals',
        tasks: [                          2
          { id: 1, projectId: 1, ... },
          { id: 2, projectId: 1, ... }
        ]
      },
      {
        id: 2,
        name: 'Short-term goals',
        tasks: [
          { id: 3, projectId: 2, ... },
          { id: 4, projectId: 2, ... }
        ]
      }
    ]
  },
  page: {                                 3
    currentProjectId: null,               4
    tasksSearchTerm: null                 5
  }
}

  • 1 Items hold each project object.
  • 2 Each project’s tasks are now nested.
  • 3 Adds a new page property
  • 4 currentProjectId will be used to know which project is active.
  • 5 Moves the search filter text for tags into the page reducer

Let’s get the server updates out of the way so you can focus on the client.

8.3.2. Updating the server with projects

As a prerequisite before you get into the normal flow of the application, let’s update the API to return both projects and tasks. As a refresher, you want your server to return a nested data structure when you make a GET request to /projects (as shown in the following listing).

Listing 8.6. An example API response
[
  {
    id: 1,
    name: 'Short-term goals',
    tasks: [
      { id: 1, title: 'Learn Redux', status: 'In Progress' },
      { id: 2, title: 'Defend shuffleboard championship title', status: 'Unstarted' }
    ]
  },
  {
    id: 1,
    name: 'Short-term goals',
    tasks: [
      { id: 3, title: 'Achieve world peace', status: 'In Progress' },
      { id: 4, title: 'Invent Facebook for dogs', status: 'Unstarted' }
    ]
  }
]

json-server makes achieving this trivial for us. You don’t have to bother writing any code, you only need to update db.json with two things: projects and a projectId for every task, as shown in the following listing.

Listing 8.7. Updating db.json
{
  "projects": [                           1
    {
      "id": 1,
      "name": "Short-Term Goals"
    },
    {
      "id": 2,
      "name": "Long-Term Goals"
    }
  ],
  "tasks": [
    {
      "id": 1,
      "title": "Learn Redux",
      "description": "The store, actions, and reducers, oh my!",
      "status": "Unstarted",
      "timer": 86,
      "projectId": 1                      2
    },
    {
      "id": 2,
      "title": "Peace on Earth",
      "description": "No big deal.",
      "status": "Unstarted",
      "timer": 132,
      "projectId": 2                      2
    },
    {
      "id": 3,
      "title": "Create Facebook for dogs",
      "description": "The hottest new social network",
      "status": "Completed",
      "timer": 332,
      "projectId": 1                      2
    }
  ]
}

  • 1 Adds a new top-level projects field, and a few project objects
  • 2 Adds a projectId to each task

Believe it or not, that’s all you need on the server end. In an upcoming section, you’ll use json-server’s query syntax to get the nested response you’re looking for.

8.3.3. Adding and dispatching fetchProjects

Because you’re trying to model a single event—an initial page render—you’ll start with the UI (see listing 8.8). Previously, you dispatched the fetchTasks action creator when the connected component App was initially mounted to the page (using the aptly named componentDidMount lifecycle hook). The only change you’ll make here is to replace fetchTasks with fetchProjects, because you’re moving to project-based API endpoints.

Listing 8.8. Importing and dispatching fetchProjects – src/App.js
...
import {
  ...
  fetchProjects,
} from './actions';
...

class App extends Component {
  componentDidMount() {
    this.props.dispatch(fetchProjects());
  }
  ...
}

Before you get to the meat of the fetchProjects action creator, you’ll make a quick quality-of-life update, shown in listing 8.9. Add a new function to your API client to deal with the specifics of the /projects endpoint. The actual request URL here may look foreign, but this is part of json-server’s query language. This syntax will tell json-server to embed a project’s tasks directly within each project object before it sends a response—exactly what you’re looking for.

Listing 8.9. Updating the API client – src/api/index.js
...
export function fetchProjects() {
  return client.get('/projects?_embed=tasks');      1
}
...

  • 1 Uses json-server’s query language to specify that tasks should be embedded within projects in the API response

Next, head to src/actions/index.js and implement fetchProjects. Like the old fetchTasks, fetchProjects will be an async action creator, responsible for orchestrating an API request and any related Redux actions. You won’t use something like the API middleware you built in chapter 5. Instead, you’ll use redux-thunk by returning a function from fetchProjects. Within that function, you’ll make the API request and create/dispatch the three standard request actions: request start, success, and failure. See the following listing.

Listing 8.10. Creating fetchProjects – src/actions/index.js
function fetchProjectsStarted(boards) {
  return { type: 'FETCH_PROJECTS_STARTED', payload: { boards } };
}

function fetchProjectsSucceeded(projects) {
  return { type: 'FETCH_PROJECTS_SUCCEEDED', payload: { projects } };
}

function fetchProjectsFailed(err) {
  return { type: 'FETCH_PROJECTS_FAILED', payload: err };
}

export function fetchProjects() {
  return (dispatch, getState) => {
    dispatch(fetchProjectsStarted());                       1

    return api
      .fetchProjects()
      .then(resp => {
        const projects = resp.data;

        dispatch(fetchProjectsSucceeded(projects));         2
      })
      .catch(err => {
        console.error(err);

        fetchProjectsFailed(err);
      });
  };
}

  • 1 Dispatches an action to indicate the request has started
  • 2 Dispatches an action with the projects from the response body

This pattern should start to look familiar. When using redux-thunk for async actions, most operations that involve an AJAX request consist of these same three request-based actions. Let’s assume you’re getting a successful response. Using fetchProjects dispatches two new actions, FETCH_PROJECTS_STARTED and FETCH_PROJECTS_SUCCEEDED. Like any other action, you need to add code to a reducer to handle any update logic.

8.3.4. Updating the reducer

You need to make a few major changes to the existing reducer code to fit projects and the new nested structure of the store. You’ll take care of housekeeping first by renaming the tasks reducer to projects and updating the reducer’s initial state to match the projects structure. Update the reducer code and update any imports and references in src/index.js, as shown in the following listing.

Listing 8.11. Updating the tasks reducer – src/reducers/index.js
...

const initialState = {
  items: [],
  isLoading: false,
  error: null,
};

export function projects(state = initialState, action) {       1
  switch (action.type) {
    ...
  }
}
...

  • 1 Renames the reducer

Next let’s handle two new actions (as shown in listing 8.12):

  • FETCH_PROJECTS_STARTED—Handles loading state.
  • FETCH_PROJECTS_SUCCEEDED—This payload is a list of projects from the server, which you’ll have the reducer load into the store.

Listing 8.12. Handling new actions in the projects reducer – src/reducers/index.js
...
export function projects(state = initialState, action) {
  switch (action.type) {
    case 'FETCH_PROJECTS_STARTED': {
      return {
        ...state,
        isLoading: true,                      1
      };
    }
    case 'FETCH_PROJECTS_SUCCEEDED': {
      return {
        ...state,
        isLoading: false,
        items: action.payload.projects,       2
      };
    }
    ...
  }
}
...

  • 1 Sets the isLoading flag to true now that the request is in progress
  • 2 Loads the projects into the store when the request is complete

This is standard Redux, so we won’t spend too much time here. The reducers are calculating state correctly, so the next step is to update any code that connects Redux to React.

You added a new page property to the state tree to handle page-level state the same way as the current project and the current search term. Accordingly, you’ll need a new reducer to handle updating this data in response to actions. Add the new page reducer as shown in the following listing.

Listing 8.13. Adding the page reducer – src/reducers/index.js
const initialPageState = {                                    1
  currentProjectId: null,                                     1
  searchTerm: '',                                             1
};                                                            1

export function page(state = initialPageState, action) {
  switch (action.type) {
    case 'SET_CURRENT_PROJECT_ID': {                          2
      return {
        ...state,
        currentProjectId: action.payload.id,
      };
    }
    case 'FILTER_TASKS': {                                    3
      return { ...state, searchTerm: action.searchTerm };
    }
    default: {
      return state;
    }
  }
}

  • 1 Declares the initial state for this part of the state tree
  • 2 Updates currentProjectId when users swap over to a new project
  • 3 Updates searchTerm when users filter tasks

If you end up using Redux to handle UI-related state such as searchTerm, consider adding a ui reducer. You’re only managing a few pieces of state here, so purely for convenience you can group them together within a single reducer/concept, page.

The page reducer won’t be too useful on its own, so you also need to import and use it when you create the store in src/index.js, as shown in the following listing.

Listing 8.14. Update createStore – src/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import { createStore, applyMiddleware } from 'redux';
import { composeWithDevTools } from 'redux-devtools-extension';
import thunk from 'redux-thunk';
import createSagaMiddleware from 'redux-saga';
import { projects, tasks, page } from './reducers';       1
import App from './App';
import rootSaga from './sagas';
import './index.css';

const rootReducer = (state = {}, action) => {
  return {
    projects: projects(state.projects, action),
    tasks: tasks(state.tasks, action),
    page: page(state.page, action),                       2
  };
};

const sagaMiddleware = createSagaMiddleware();

const store = createStore(
  rootReducer,
  composeWithDevTools(applyMiddleware(thunk, sagaMiddleware)),
);
...

  • 1 Adds page to the reducers import
  • 2 Adds page to the rootReducer

8.3.5. Updating mapStateToProps and selectors

The role of selectors is to translate data between the Redux store and React components. Because you updated the structure of the store, you need to update your selectors to handle this new structure, as shown in the following listing. Your point of connection is mapStateToProps in the App component, Parsnip’s only connected component to date.

Listing 8.15. Connecting projects to React – src/App.js
function mapStateToProps(state) {
  const { isLoading, error, items } = state.projects;    1

  return {
    tasks: getGroupedAndFilteredTasks(state),            2
    projects: items,                                     3
    isLoading,
    error,
  };
}

  • 1 Grabs relevant data from projects state
  • 2 Uses the same selector to retrieve tasks, which you’ll update in a moment
  • 3 Passes in the list of projects to eventually render in a drop-down menu

You needed only minimal changes within mapStateToProps, the point of connection between Redux and React, but the getGroupedAndFilteredTasks isn’t functioning correctly in its current state. It still expects the old Redux store with a single tasks property. You need to modify a few of the existing selectors to handle a new store structure. Because you no longer have a single list of actions, you also need to add a new selector to find the right tasks to pass into the UI to render given the currentProjectId. Most noteworthy here is the addition of getTasksByProjectId, which replaces getTasks. The getFilteredTasks requires two input selectors: one to retrieve tasks and one to retrieve the current search term, as shown in the following listing.

Listing 8.16. Updating selectors to handle projects – src/reducers/index.js
const getSearchTerm = state => state.page.tasksSearchTerm;        1

const getTasksByProjectId = state => {                            2
  if (!state.page.currentProjectId) {                             3
    return [];                                                    3
  }                                                               3

  const currentProject = state.projects.items.find(               4
    project => project.id === state.page.currentProjectId,        4
  );                                                              4

  return currentProject.tasks;
};

export const getFilteredTasks = createSelector(
  [getTasksByProjectId, getSearchTerm],                           5
  (tasks, searchTerm) => {
    return tasks.filter(task => task.title.match(new RegExp(searchTerm, 'i')));
  },
);

  • 1 Updates getSearchTerm to use the new page reducer
  • 2 Adds a new selector to get tasks based on a project ID
  • 3 If no project is currently selected, it returns early with an empty array.
  • 4 Finds the correct project from the list
  • 5 Updates getFilteredTasks input selectors

Now that you’re rendering tasks for a specific project, you need additional logic to find the tasks for a given project.

Parsnip was in a broken state while you made these updates, but now things should be up and running again.

8.3.6. Adding the projects drop-down menu

Before you get into creating and editing tasks, let’s do one last thing to make the app feel more complete, by allowing users to choose which project to display from a drop-down. Up to now, you’ve had only a single connected component, App, and one major page section that displayed a list of tasks. Now you have projects, and along with them comes another major page section to maintain.

You’ll add the drop-down in a new Header component (see figure 8.17). Create a new file in the src/components directory named Header.js. Based on Header’s requirements, you’ll need at least two props:

  • projects—A list of projects to render
  • onCurrentProjectChange—A callback to fire when a new project is selected
Listing 8.17. The Header component – src/components/Header.js
import React, { Component } from 'react';

class Header extends Component {
  render() {
    const projectOptions = this.props.projects.map(project =>      1
      <option key={project.id} value={project.id}>
        {project.name}
      </option>,
    );

    return (
      <div className="project-item">
        Project:
        <select onChange={this.props.onCurrentProjectChange}
 className="project-menu">                                       2
          {projectOptions}
        </select>
      </div>
    );
  }
}

export default Header;

  • 1 Renders an option for each project
  • 2 Hooks up the onCurrentProjectChange callback

Now that you have a Header component, you need to render it. This includes not only including it somewhere in the component tree but also passing Header its required data. Because App is the only connected component, it needs to pass in projects and define an onCurrentProjectChange handler that dispatches the correct action, as shown in the following listing.

Listing 8.18. Rendering Header – src/App.js
import React, { Component } from 'react';
import { connect } from 'react-redux';
import Header from './components/Header';
import TasksPage from './components/TasksPage';
import {
  ...
  setCurrentProjectId,
} from './actions';

class App extends Component {
  ...
  onCurrentProjectChange = e => {                                      1
    this.props.dispatch(setCurrentProjectId(Number(e.target.value)));  1
  };                                                                   1

  render() {
       return (
      <div className="container">
        {this.props.error && <FlashMessage message={this.props.error} />}
        <div className="main-content">
          <Header                                                      2
            projects={this.props.projects}                             2
            onCurrentProjectChange={this.onCurrentProjectChange}       2
          />                                                           2
          <TasksPage
            tasks={this.props.tasks}
            onCreateTask={this.onCreateTask}
            onSearch={this.onSearch}
            onStatusChange={this.onStatusChange}
            isLoading={this.props.isLoading}
          />
        </div>
      </div>
    );
  }
}
...

  • 1 Adds an event handler for changing the current project
  • 2 Renders the header with the necessary data

The app won’t run in its current state because you need to do one last thing: define the setCurrentProjectId action creator. It’s a simple, synchronous action creator that accepts a project ID as an argument and returns an action object with the correct type and payload. Head to src/actions/index.js, where you defined all your action-related things so far, and add the code from the following listing.

Listing 8.19. Adding setCurrentProjectId – src/actions/index.js
...
export function setCurrentProjectId(id) {       1
  return {
    type: 'SET_CURRENT_PROJECT_ID',             2
    payload: {                                  3
      id,                                       3
    },                                          3
  };
}
...

  • 1 Exports a synchronous action creator that accepts a project ID as an argument
  • 2 Sets the correct action type
  • 3 Sets the correct payload, a single ID property

You can now switch between projects by choosing from the drop-down menu. The role of the App component is varied at this point. It needs to get the right data from Redux, render any children, and implement small wrappers around action dispatches. Its purpose could be described as orchestrator. This is fine for the time being, but it’s something to keep an eye on. If this component becomes too bloated, Parsnip could become more difficult to maintain.

At this point, you have two major pieces of functionality left to update: creating and updating tasks. Loading initial projects data was relatively straightforward. Projects as returned from the API are already in a shape that’s friendly to render. All you needed to do was load the data into the store, adjust part of the code that connects Redux and React, and everyone’s happy.

With creating tasks, you’ll get your first taste of making updates to nested data. To fetch projects, you threw an entire API response directly in the store. Now you need to pluck out individual tasks and operate on them, which won’t be as easy.

Recall one of the major downsides of having a store with nested data—updating nested data comes with a high-complexity cost. At the risk of spoilers, the next few sections are meant to demonstrate these issues first-hand.

You won’t need to make any UI changes to get task creation working again, you need to change only how things work under the hood. You’ll make two changes:

  • Ensure the current project ID is passed to the createTask action creator.
  • Update the projects reducer to add the task to the correct project.

That last bullet is the most significant change. As a refresher, let’s look at the existing implementation for handling CREATE_TASK_SUCCEEDED, which is responsible for taking the newly created task and adding it to the store. This is about as straightforward as it gets. You take the new task from the action’s payload and add it to the existing list of tasks, as shown in the following listing.

Listing 8.20. Existing code for CREATE_TASK_SUCCEEDED – src/reducers/index.js
export function projects(state = initialState, action) {
  switch (action.type) {
    ...
    case 'CREATE_TASK_SUCCEEDED': {
      return {
        ...state,
        tasks: state.tasks.concat(action.payload.task),
      };
    }
    ...
  }
}

With projects, you need one additional step: finding the project that the task belongs to. You first use the projectId from the task as part of the action payload to find the project that you need to update. In the following listing, you start to see real shortcomings with storing lists of objects in an array.

Listing 8.21. Updating the projects reducer – src/reducers/index.js
export function projects(state = initialState, action) {
  switch (action.type) {
    ...
    case 'CREATE_TASK_SUCCEEDED': {
      const { task } = action.payload;
      const projectIndex = state.items.findIndex(     1
        project => project.id === task.projectId,     1
      );                                              1
      const project = state.items[projectIndex];      1

      const nextProject = {                           2
        ...project,                                   2
        tasks: project.tasks.concat(task),            2
      };                                              2

      return {
        ...state,                                     3
        items: [                                      3
          ...state.items.slice(0, projectIndex),      3
          nextProject,                                3
          ...state.items.slice(projectIndex + 1),     3
        ],
      };
    }
    ...
  }
}

  • 1 Finds the right project to update
  • 2 Merges the new tasks array into the project
  • 3 Inserts the updated project at the correct place in the array

The immutability restriction of reducers enables great features within Redux, such as time travel, but it can make updating nested data structures harder. Instead of modifying a project’s task array in place, you must take care to always create new copies of objects. Using arrays to store lists of objects makes this extra problematic, because you have to loop through the entire list to find the project you’d like to work with.

8.3.7. Editing tasks

With creating tasks, you started to see how updating nested data in a way that preserves immutability can become tricky. Editing tasks will be even more complex, because you can’t take a new task and add it to a list. Now you need to find the right project and find the right task to update—all while avoiding mutating any existing data in place. Again, let’s look at the following listing to see the existing implementation before you introduced projects. In this instance, you chose to map over the list of tasks and return the updated task when you found the task being updated.

Listing 8.22. Existing EDIT_TASKS_SUCCEEDED code – src/reducers/index.js
export function projects(state = initialState, action) {
  switch (action.type) {
    ...
    case 'EDIT_TASK_SUCCEEDED': {
      const { payload } = action;
      const nextTasks = state.tasks.map(task => {
        if (task.id === payload.task.id) {             1
          return payload.task;
        }

        return task;
      });
      return {
        ...state,
        tasks: nextTasks,                              2
      };
    }
    ...
  }
}

  • 1 Finds and replaces the desired task
  • 2 Returns the updated tasks

As with task creation, you also have to find the project for the task being updated, as shown in the following listing.

Listing 8.23. Finding the project -- src/reducers/index.js
export function projects(state = initialState, action) {
  switch (action.type) {
    ...
    case 'EDIT_TASK_SUCCEEDED': {
      const { task } = action.payload;
      const projectIndex = state.items.findIndex(
        project => project.id === task.projectId,
      );
      const project = state.items[projectIndex];                         1
      const taskIndex = project.tasks.findIndex(t => t.id === task.id);

      const nextProject = {                                              2
        ...project,
        tasks: [
          ...project.tasks.slice(0, taskIndex),
          task,
          ...project.tasks.slice(taskIndex + 1),
        ],
      };

      return {                                                           3
        ...state,
        items: [
          ...state.items.slice(0, projectIndex),
          nextProject,
          ...state.items.slice(projectIndex + 1),
        ],
      };
    }
  }
}

  • 1 Determines the project to update
  • 2 Updates the project
  • 3 Returns the updated projects list

If you think this code is dense, it’s not you. You had to

  • Find the associated project for the task being updated.
  • Replace the updated task at the correct index.
  • Replace the updated project at the correct index.

All this to update a single task! Granted, tools such as Immutable.js are designed to make updating nested data structures easier. If you find that nested data works for you, it can be worth considering such a tool to help save you the boilerplate you wrote. As you’ll see later in the chapter, using a normalized state shape can remove the need for this kind of logic entirely.

8.3.8. Unnecessary rendering

It’s obvious that updating nested data is more complex than updating flat structures. Another downside exists to nested data that we covered earlier in the chapter but haven’t had a chance to look at in detail yet: updates to nested data causing parent data to change. It’s not something that’s going to make or break an app such as Parsnip, but it’s something to be aware of.

Most design-related problems that require nuance are hot topics of debate within the React community. As you’ve see in this chapter, how to structure data within a store is a big point of debate. Related but distinct are strategies around how to best connect Redux and React. How you decide what the entry points are for your app has a big effect on overall architecture and data flow.

You know nested data makes updates harder. Recall that one of the other big downsides of nested data is performance. Here’s what we mean by that.

Figure 8.3 is a diagram of your current component structure and how it connects to Redux. You have two major page sections to manage, Header and TasksPage, and App is still the lone connected component.

Figure 8.3. An overview of Parsnip’s components and how they connect to Redux

Let’s look at the Header and TasksPage components in terms of their data dependencies. Header requires projects, TasksPage requires tasks. You have two resources in Redux, projects and tasks. App will re-render if any contained within state.projects changes, including tasks.

Let’s say you’re updating a task, where ideally only that particular Task component should re-render. Whenever App receives new data from the store, it will automatically re-render all of its children, including Header and TasksPage. See figure 8.4.

Figure 8.4. Updates to any part of projects’ state will cause App to re-render.

Because you only have one connected component, you can’t be any more granular about which components should be notified when certain data changes. And because you have nested data, any updates to tasks will automatically update the entire projects portion of the state tree.

8.3.9. Summary—nested data

Now you have a good sense of what it’s like to fetch, create, and update nested data. In certain ways, it’s an intuitive way to structure data. It’s tricky to express relationships between data, so leaving tasks nested within their respective projects saves you from having to manage any relationships in a more manual way.

We also reinforced a few of the shortcomings of nested data:

  • Update logic became more complex. Instead of adding or updating, you had to find the relevant project for each operation.
  • Because your data was nested, you were forced to re-render the entire app whenever any projects-related state changed. For example, updating a task also required the header to update, even though the header didn’t rely on tasks.

Now we’ll look at normalization, one of the most effective ways for managing relational data in Redux.

8.4. Normalizing projects and tasks

The alternative to nesting relational data in Redux is normalization. Instead of using nesting to express relationships between projects and tasks, you’ll treat the Redux store more like a relational database. Instead of having one projects reducer, you’ll split things up into two reducers, one per resource. You’ll use selectors to fetch and transform the necessary data for the React components.

This allows you to keep a flat structure, which means you won’t need to bother with updating nested resources. It also means you can adjust how you connect your React components for improved performance.

To accomplish this, you’ll use the popular normalizr package. You’ll pass into normalizr a nested API response and a user-defined schema, and it will return a normalized object. You could do the normalization yourself, but normalizr is a great tool and you don’t need to reinvent the wheel. Note that you’ll be using the finished code from section 8.3 as a base.

Figure 8.5 shows the current structure of the store, side-by-side with the structure you’ll end up with after you finish this section.

Figure 8.5. The transition from nested to normalized

Note that tasks is back to being a top-level property in your normalized state tree. This means when you’re creating or editing tasks, you no longer have to dig through a list of projects to find the task you want to update; you can look at each task using its ID.

You’re also no longer storing lists of tasks or projects in arrays. Now you’re using objects with the ID of the resource as a key. Regardless of whether you choose to nest or normalize your data, we usually recommend storing your lists in this way. The reason is simple: it makes lookups roughly a thousand times easier. The following listing shows the difference.

Listing 8.24. Why objects with IDs make lookups easier
const currentProject = state.projects.items.find(project =>
  project.id === action.payload.id
);

versus

const currentProject = state.projects.items[action.payload.id];

This not only simplifies lookups, it improves performance by removing unnecessary loops.

8.4.1. Defining a schema

The first step on the road to normalization is to define a normalizr schema. Schemas are how you tell normalizr what shape to give back when you run the API response through the normalize function. You’ll put your schemas along with the actions in src/actions/index.js for now, but it’s also common to use a separate file for schemas. The following listing tells normalizr that you have two top-level entities, tasks and projects, and that tasks belong to projects.

Listing 8.25. Adding normalizr schemas – src/actions/index.js
import { normalize, schema } from 'normalizr';

...

const taskSchema = new schema.Entity('tasks');
const projectSchema = new schema.Entity('projects', {
  tasks: [taskSchema],
});

Next you run the API response from the /projects endpoint through normalizr’s normalize function, which takes an object and a schema and returns a normalized object. This is the piece that transforms the nested API response into the normalized structure you’ll use in Redux. Also, create a new receiveEntities action creator, which will return a RECEIVE_ENTITIES action. You’ll then handle this action in both reducers, tasks and projects, as shown in the following listing. This helps you reduce boilerplate by not having to dispatch multiple actions, such as FETCH_PROJECTS_SUCCEEDED and FETCH_TASKS_SUCCEEDED.

Listing 8.26. Normalizing the response – src/actions/index.js
function receiveEntities(entities) {                                     1
  return {
    type: 'RECEIVE_ENTITIES',
    payload: entities,
  };
}

...

export function fetchProjects() {
  return (dispatch, getState) => {
    dispatch(fetchProjectsStarted());

    return api
      .fetchProjects()
      .then(resp => {
        const projects = resp.data;

        const normalizedData = normalize(projects, [projectSchema]);     2

        dispatch(receiveEntities(normalizedData));                       3

        if (!getState().page.currentProjectId) {                         4
          const defaultProjectId = projects[0].id;                       4
          dispatch(setCurrentProjectId(defaultProjectId));               4
        }                                                                4
      })
      .catch(err => {
        fetchProjectsFailed(err);
      });
  };
}

  • 1 Creates a generic receiveEntities action
  • 2 Passes the response and the schema into normalizr
  • 3 Dispatches the normalized result
  • 4 Sets a default project ID

You added only one new step to your existing code within the fetchProjects action creator, but because it fundamentally changes the structure of the data within the store, it will require profound changes to your reducers.

8.4.2. Updating reducers for entities

You currently have a single projects reducer that handles everything related to both projects and tasks. Now that you’re normalizing your data and tasks are back to being a top-level property in your store, you need to bring back the tasks reducer. Rumors of its demise have been greatly exaggerated.

You’ll split up the existing actions you have per resource. Task-related actions will go in the tasks reducer, while project-related actions go in the projects reducer. Note that you’ll change the implementation for many of these actions to support this new normalized structure. In the process, you’ll see a big improvement in terms of complexity, especially for any code that handles modification of nested tasks. Both the tasks and projects reducers need to handle RECEIVE_ENTITIES. To be extra safe, in each reducer you’ll check if the action payload includes relevant entities and load them in if so, as shown in the following listing.

Listing 8.27. Creating the tasks reducer and RECEIVE_ENTITIES – src/reducers/index.js
const initialTasksState = {
  items: [],
  isLoading: false,
  error: null,
};

export function tasks(state = initialTasksState, action) {    1
  switch (action.type) {
    case 'RECEIVE_ENTITIES': {
      const { entities } = action.payload;
      if (entities && entities.tasks) {                       2
        return {
          ...state,
          isLoading: false,
          items: entities.tasks,
        };
      }

      return state;
    }
    case 'TIMER_INCREMENT': {
      const nextTasks = Object.keys(state.items).map(taskId => {
        const task = state.items[taskId];

        if (task.id === action.payload.taskId) {
          return { ...task, timer: task.timer + 1 };
        }

        return task;
      });
      return {
        ...state,
        tasks: nextTasks,
      };
    }
    default: {
      return state;
    }
  }
}

const initialProjectsState = {
  items: {},
  isLoading: false,
  error: null,
};

export function projects(state = initialProjectsState, action) {
  switch (action.type) {
    case 'RECEIVE_ENTITIES': {
      const { entities } = action.payload;
      if (entities && entities.projects) {                  3
        return {
          ...state,
          isLoading: false,
          items: entities.projects,
        };
      }

      return state;
    }

    ...

    default: {
      return state;
    }
  }
}

  • 1 Creates new tasks reducers, including any initial state
  • 2 If tasks are part of this RECEIVE_ENTITIES action, it loads them into the store.
  • 3 Repeats the same process for projects

You also need to include the new tasks reducer when you create the store. Head to src/index.js and set up the tasks reducer. You need to import the reducer and take care of passing the relevant state slice, as shown in the following listing.

Listing 8.28. Using the tasks reducer – src/index.js
...

import { projects, tasks, page } from './reducers';      1

...

const rootReducer = (state = {}, action) => {
  return {
    projects: projects(state.projects, action),
    tasks: tasks(state.tasks, action),                   2
    page: page(state.page, action),
  };
};

  • 1 Imports the reducer
  • 2 Passes the relevant state slice and action

You’re not quite to the point of rendering tasks: you still need to get the data out of the store. That’s where selectors and mapStateToProps come in.

8.4.3. Updating selectors

You changed the structure of the store yet again, and that means you need to update any selectors that may reference outdated data structures. First, you’ll make one quick change to mapStateToProps in the connected App component, as described by the following listing. You’ll import a new getProjects selector (that you’ll define in a moment) that will return an array of projects.

Listing 8.29. Updating App – src/App.js
import { getGroupedAndFilteredTasks, getProjects } from './reducers/';   1

...

function mapStateToProps(state) {
  const { isLoading, error } = state.projects;

  return {
    tasks: getGroupedAndFilteredTasks(state),
    projects: getProjects(state),                                        2
    isLoading,
    error,
  };
}

...

  • 1 Imports a new getProjects reducer
  • 2 Uses getProjects to pass an array of projects into the UI

Minus the new getProjects, you’ve left everything else as is. You won’t need to change how the React portion of the app works. It will still accept the same props. This is decoupling between Redux and React in action. You can radically change how you manage state under the hood, and it won’t affect any UI code. Pretty cool.

Next, update the existing getGroupedAndFilteredTasks selector to handle normalized data and implement getProjects. Arrays are much easier to work with in React, so a simple getProjects selector handles the simple transform of an object of projects keyed by ID to an array of project objects.

The high-level logic behind getTasksByProjectId is the same—it uses projects, tasks, and a currentProjectId and returns an array of tasks for the current project. The difference here is that you’re operating on normalized data. Instead of having to loop over all projects to find the correct object, you can look up the current project by ID. Then you use its array of task IDs to subsequently look up each task object, as shown in the following listing.

Listing 8.30. Updating selectors – src/reducers/index.js
...

export const getProjects = state => {                                    1
  return Object.keys(state.projects.items).map(id => {                   1
    return state.projects.items[id];                                     1
  });                                                                    1
};                                                                       1


const getTasksByProjectId = state => {
  const { currentProjectId } = state.page;

  if (!currentProjectId || !state.projects.items[currentProjectId]) {    2
    return [];
  }

  const taskIds = state.projects.items[currentProjectId].tasks;          3

  return taskIds.map(id => state.tasks.items[id]);                       4
};

...

  • 1 Creates a selector to convert the object containing all projects back into an array
  • 2 If there’s no current project, or no project matching the currentProjectId, it returns early.
  • 3 Gets the list of task IDs from the project
  • 4 For each task ID, it gets its corresponding object.

8.4.4. Creating tasks

The only things you haven’t ported over to this new normalized structure are creates and updates. You’ll leave updates as an exercise toward the end of the chapter. Creating tasks is an interesting case, because it’s the first example where you have to handle a single action in multiple reducers. When you dispatch CREATE_TASK_SUCCESS, you need to do a few things:

  • In the tasks reducer, add the new task to the store.
  • In the projects reducer, add the ID of the new task to the corresponding reducer.

Recall that because you’re tracking related entities in different sections of the store, you need to use IDs to maintain these relationships. Each project has a tasks property that’s an array of tasks belonging to that project. When you create a new task, you have to add the task’s ID to the correct project.

Listing 8.31. Handling CREATE_TASK_SUCCEEDED – src/reducers/index.js
...

export function tasks(state = initialTasksState, action) {
  switch (action.type) {
    ...

    case 'CREATE_TASK_SUCCEEDED': {
      const { task } = action.payload;

      const nextTasks = {                                   1
        ...state.items,                                     1
        [task.id]: task,                                    1
      };                                                    1

      return {
        ...state,
        items: nextTasks,
      };
    }

    ...

  }
}

...

export function projects(state = initialProjectsState, action) {
  switch (action.type) {
    ...
    case 'CREATE_TASK_SUCCEEDED': {
      const { task } = action.payload;

      const project = state.items[task.projectId];          2

      return {
        ...state,
        items: {
          ...state.items,
          [task.projectId]: {
            ...project,
            tasks: project.tasks.concat(task.id),
          },
        }
         };
    }
    ...
  }
}

  • 1 Adds the new task object
  • 2 Finds the project that the task belongs to and adds the task’s ID

You don’t have to dig through nested data anymore, but you do have to handle the same action in multiple reducers. Working in multiple reducers requires complexity overhead, but the benefits of storing data in a flat manner consistently outweigh the costs.

8.4.5. Summary—normalized data

Notice how you’re not making any major updates to the UI? That’s by design! One of the core ideas with Redux is that it allows you to separate state management from UI. Your React components don’t know or care what’s going on behind the scenes. They expect to receive data that has a certain shape, and that’s it. You can make drastic changes to how you store and update application state, and those changes are isolated to Redux. You’ve successfully separated how Parsnip looks from how it works. This kind of decoupling is a powerful idea and is one of the keys to scaling any application.

8.5. Organizing other types of state

The chapter so far has been devoted to relational data. You had a series of related resources, projects, and tasks, and the goal was to explore different structures and how they affect the way you create and update different resources. Another kind of organization is worth talking about, and that’s grouping certain data in Redux in conceptual groups. Up to now, you’ve stored what we’d call application state.

Assuming you kept developing Parsnip, your store would no doubt grow. You’d add new resources such as users, but you’d also start to see new kinds of state in the store. Here are a few examples of reducers you’ll commonly find in Redux apps:

  • SessionLogin state, session duration to handle forced sign-in.
  • UIIf you store a significant amount of UI state in Redux, it’s helpful to give it its own reducer.
  • Features/experimentsIn production apps it’s common for the server to define a list of active features or experiments, which a client app uses to know what to render.

8.6. Exercise

One major thing left to update to support normalized data is editing tasks. This is another place where normalized data shines. You can skip the step of finding the task’s associated project and update the correct object in the tasks reducer. Because the project already has a reference to each of its tasks using IDs, you’ll automatically reference the updated task object when you render that project’s tasks.

Your mission: modify the code so editing tasks (modifying their statuses) works correctly, given the new normalized state structure from section 8.4.

8.7. Solution

You’ll need to do a few things, as shown in listing 8.32:

  • Move the EDIT_TASK_SUCCESS handler from the projects reducer to the tasks reducer.
  • Update the reducer code to find the object by ID, instead of looping through an array of tasks until the correct object is found. The action’s payload has the task object you need to load into the store. Because each task is keyed by ID, you can replace the old task with the new task, and that’s it!
Listing 8.32. Updating normalized tasks – src/reducers/index.js
export function tasks(state = initialTasksState, action) {
  switch (action.type) {
    ...
    case 'EDIT_TASK_SUCCEEDED': {
      const { task } = action.payload;

      const nextTasks = {
        ...state.items,
        [task.id]: task,
      };

      return {
        ...state,
        items: nextTasks,
      };
    }
    default: {
      return state;
    }
  }
}

Notice any similarities between the CREATE_TASK_SUCCEEDED handler and the EDIT_TASK_SUCCEEDED handler in the tasks reducer? The code is identical! If you want to save a few lines of code, you combine the action-handling code, as demonstrated in the following listing.

Listing 8.33. Refactoring the tasks reducer – src/reducers/index.js
export function tasks(state = initialTasksState, action) {
  switch (action.type) {
    ...
    case 'CREATE_TASK_SUCCEEDED':
    case 'EDIT_TASK_SUCCEEDED': {
      const { task } = action.payload;

      const nextTasks = {
        ...state.items,
        [task.id]: task,
      };

      return {
        ...state,
        items: nextTasks,
      };
    }
    default: {
      return state;
    }
  }
}

Normalized data is only one strategy for storing application state in Redux. Like all things programming, no tool is a silver bullet. You won’t catch us using the normalizr package in a quick prototype, for example, but hopefully you can see the value of using normalized data in appropriate circumstances. At a minimum, you’re now aware of more choices available to you when architecting your next Redux application.

The next chapter is fully dedicated to testing Redux applications. You’ll finally circle back to how to test components, actions, reducers, sagas, and the rest of the pieces you’ve assembled over the last eight chapters.

Summary

In this chapter you learned the following:

  • Multiple strategies exist for how to store data in Redux.
  • Normalizing data helps flatten out deeply nested relationships and removes duplicate resources.
  • Selectors can simplify the usage of the normalized data in your application.
  • The normalizr package provides helpful abstractions for normalizing data in a Redux application.
..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.191.236.255