• Five Tips for Working with Redux in Large Applications
  • By AppNexus Engineering
  • The Nuggets translation Project
  • Permanent link to this article: github.com/xitu/gold-m…
  • Translator: loveky
  • Proofread by: Stormrabbit

Five tips for using Redux in large applications

Redux is a great tool for managing your application’s “state.” The unidirectional data flow and focus on immutable data make it easy to infer changes in state. Each state change is triggered by an action, which causes the Reducer function to return a new state after the change. Many user interfaces created with Redux on AppNexus require a lot of data and complex interactions as clients manage or publish advertising resources on our platform. In developing these interfaces, we found some useful rules and tricks to keep Redux manageable. The following discussions should help any developer using Redux in a large, data-intensive application:

  • First point: Use indexes and selectors when storing and accessing state
  • Second point: Distinguish between data objects, changes to data objects, and other UI states
  • Third point: Sharing data between different pages of a single-page application and when not to do so
  • Fourth point: Reuse the common Reducer function across the different nodes in the state
  • Point five: Best practices for connecting the React component to the Redux state

1. Use indexes to save data, and use selectors to read data

Choosing the right data structure can have a big impact on the structure and performance of a program. You can greatly benefit from the use of indexes when storing serializable data from the API. An index is a JavaScript object whose key is the ID of the data objects we want to store, and whose value is the data objects themselves. This pattern is very similar to using hashMap to store data and has the same advantages in terms of query efficiency. This should come as no surprise to anyone who has mastered Redux. In fact, Dan Abramov, the author of Redux, recommends this data structure in his Redux tutorial.

Imagine that you have a set of data objects retrieved from a REST API, such as data from the/Users service. Suppose we decide to store this plain array in the state just as it does in the response. What happens when we need to get a specific user object? We need to iterate over all the users in the state. If there are many users, this can be an expensive operation. What if we want to keep track of a small subset of users, such as selected and unselected users? We either need to store the data in two arrays, or we need to record the indexes of the selected and unselected users in the main array.

However, we decided to refactor the code to store the data as an index. We can store data in the Reducer as follows:

{
 "usersById": {
    123: {
      id: 123.name: "Jane Doe".email: "[email protected]".phone: "555-555-5555". },... }}Copy the code

So how does this data structure help us solve these problems? If you want to find a particular user, you can read the state directly with const user = state.usersbyId [userId]. This way we don’t need to traverse the entire list, saving time and simplifying the code.

At this point you may be wondering how we can present a simple list of users with this data structure. To do this, we need to use a selector, which is a function that receives the state and returns the required data. A simple example is a function that returns all users in a state:

const getUsers = ({ usersById }) = > {
  return Object.keys(usersById).map((id) = > usersById[id]);
}Copy the code

In our view code, we call this method to get the list of users. You can then iterate through these user-generated views. We can create another function to get the specified user from the state:

const getSelectedUsers = ({ selectedUserIds, usersById }) = > {
  return selectedUserIds.map((id) = > usersById[id]);
}Copy the code

The selector pattern also increases the maintainability of the code. Suppose we want to change the structure of the state in the future. Without using a selector, we had to update all of our view code to accommodate the new state structure. As the number of view components increases, the burden of modifying the state structure increases dramatically. To avoid this, we read the state through a selector in the view. Even if the underlying state structure changes, we only need to update the selector. All state-dependent components will still be able to get their data, and we won’t have to update them. For all these reasons, large Redux applications will benefit from the index and selector data store pattern.

2. Separate standard state from view state and edit state

Real-world Redux applications typically need to read data from some service, such as a REST API. After receiving the data, we send an action containing the received data. We refer to this data returned from the service as the “standard state” — the correct state of the data currently stored in our database. Our state also contains other types of data, such as the state of a user interface component or the state of the entire application. When we first read the standard state from the API, we might want to save it in the same Reducer file as the rest of the page’s state. This approach may be simple, but it becomes difficult to scale when you need to get multiple data from different sources.

Instead, we keep the standard state in its separate Reducer file. This will force you to write better-organized, more modular code. Extending the Reducer vertically (increasing the number of lines of code) is less maintainable than extending the Reducer horizontally (introducing more reducer into combineReducers calls). Splitting reducers into their own files helps reuse these reducers (discussed in more detail in Point 3). In addition, this prevents the developer from adding non-standard state to the data object Reducer.

Why not save other types of state with standard state? Suppose we get a set of user data from the REST API as we did in Part 1. Using the index storage mode, we will store it in the Reducer as follows:

{
 "usersById": {
    123: {
      id: 123,
      name: "Jane Doe",
      email: "[email protected]",
      phone: "555-555-5555". },... }}Copy the code

Now assume that our interface allows editing of user information. When a user’s edit icon is clicked, we need to update the state so that the view renders the edit control for that user. Instead of storing view state and standard state separately, we decided to add a new field to the data objects stored in the Users/BY-ID index. Now our state looks something like this:

{
 "usersById": {
    123: {
      id: 123,
      name: "Jane Doe",
      email: "[email protected]",
      phone: "555-555-5555". isEditing:true,},... }}Copy the code

We make some changes, click the Submit button, and the changes are submitted back to the REST service as PUT. The service returns the latest status of the user. But how do we incorporate the latest standard state into the Store? If we store the new object directly under the corresponding ID in the Users/BY-ID index, the isEditing tag is lost. We had to manually specify which fields of the data from the API needed to be stored in the Store. This complicates the update logic. You may want to append multiple bools, strings, arrays, or other types of new fields to the standard state to maintain view state. In this case, when adding an action that changes the standard state, it’s easy to forget to reset these UI fields and end up with invalid state. Instead, we should keep the standard state in its own data store in the Reducer and keep our actions simpler and easier to understand.

Another benefit of keeping edit status separate is that if the user cancels the edit we can easily reset it back to the standard state. Suppose we click on the edit icon for a user and change the user’s name and E-mail address. Now suppose we don’t want to save these changes, so we click the Cancel button. This should cause the changes we made in the view to revert to the previous state. However, because we overwrite the standard state with the edit state, we no longer have data for the old state. We had to request the REST API again to get the standard state. Instead, let’s store the edit state separately. Now our state looks something like this:

{
 "usersById": {
    123: {
      id: 123,
      name: "Jane Doe",
      email: "[email protected]",
      phone: "555-555-5555". },... },"editingUsersById": {
    123: {
      id: 123,
      name: "Jane Smith",
      email: "[email protected]",
      phone: "555-555-5555",}}}Copy the code

Since we have two copies of the object in both the edit state and the standard state, it is easy to reset the state after clicking Cancel. Instead of editing the state, we simply show the standard state in the view and don’t have to call the REST API again. As a bonus, we still track the editing status of the data in the Store. If we decide that we really need to keep the changes, we can click the Edit button again and the status of the previous changes will be displayed again. Overall, keeping edit and view state separate from standard state provides a better development experience in terms of code organization and maintainability, and a better user experience in terms of form manipulation.

3. Share state between views properly

Many apps start with a single store and a single user interface. As we expand the application to extend functionality, we’ll be managing state across multiple different views and stores. Creating a top Reducer for each page might help extend our Redux application. Each page and the top Reducer correspond to a view in our application. For example, a user page gets user information from the API and stores it in the Users Reducer, while another page that presents domain name information for the current user accesses data from the domain name API. The state would look like this:

{
  "usersPage": {
    "usersById": {...},
    ...
  },
  "domainsPage": {
    "domainsById": {... },... }}Copy the code

Organizing pages like this helps keep the data behind them decoupled and independent. Each page tracks its own state, and our Reducer file can even be saved in the same location as the view file. As we expand the application, we may find that we need to share some state between the two views. Consider the following questions when considering shared state:

  • How many views or other Reducer depend on this data?
  • Does every page need a copy of this data?
  • How often do these data change?

For example, our application displays some information about the currently logged in user on each page. We need to get the user information from the API and save it in the Reducer. We know that each page will depend on this part of data, so it does not seem to conform to our strategy of having one reducer per page. We know it’s not necessary to have a copy of this data for every page, because most pages don’t fetch other users or edit current users. In addition, information about the currently logged in user is unlikely to change unless the customer edits his information on the user page.

It seemed like a good idea to share current user information between pages, so we promoted this data to its own, separately saved top-level Reducer. Now, the page the user visits for the first time checks to see if the current user information is loaded, and if not, the API is called to get the information. Any view connected to Redux can access information about the currently logged in user.

What about situations where shared states are not appropriate? Let’s consider another case. Imagine that each domain name under a user name also contains a series of subdomains. We added a subdomain page to display all subdomains under a user’s name. The domain page also has an option to display subdomains under the domain name. Now we have two pages that rely on subdomain data. We also know that domain names can change frequently — users can add, delete, or edit domain names and subdomains at any time. Each page may also need its own copy of data. Subdomain pages allow data to be read and written through the subdomain API, and may require data to be paginated. The domain page only needs to fetch a subset of subdomains (subdomains of a particular domain) at a time. Clearly, sharing subdomain data between these views is not appropriate. Each page should store subdomain data separately.

4. Reuse the Reducer function between states

After writing some reducer functions, we might want to reuse the Reducer logic between the different nodes in the state. For example, we might create a Reducer to read user information from the API. The API returns 100 users at a time, whereas our system may have tens of thousands of users. To resolve this problem, our Reducer also needs to record which page is currently being displayed. Our read logic needs to visit the Reducer to determine paging parameters (such as page_number) for the next API request. Later, when we need to read the domain name list, we end up writing almost exactly the same logic to read and store the domain name information, with a different API and data structure.

Multiplexing reducer logic in Redux can be a little tricky. By default, all reducers are executed when an action is triggered. If we share a Reducer function among the reducer functions, all these reducer functions will be called when an action is triggered. But that’s not what we want. When we read the users and we get 500, we don’t want the count of the domain to be 500.

We recommend two different ways to solve this problem, using special scopes or type prefixes. The first involves adding a type message to the data passed by the action. The action uses this type to determine which data is in the updated state. To demonstrate this approach, suppose we have a page containing multiple modules, each loaded asynchronously from a different API. The state of our tracking load process might look like this:

const initialLoadingState = {
  usersLoading: false,
  domainsLoading: false,
  subDomainsLoading: false,
  settingsLoading: false};Copy the code

With this state, we need to set reducer and action of each module’s load state. We might write four different Reducer functions with four action types — each with its own action type. This leads to a lot of duplicate code! Instead, let’s try a Reducer and action with scope. We create only one action type, SET_LOADING, and a reducer function:

const loadingReducer = (state = initialLoadingState, action) => {
  const { type, payload } = action;
  if (type === SET_LOADING) {
    returnObject.assign({}, state, {// Set the load state in this scope ['${payload.scope}Loading`]: payload.loading,
    });
  } else {
    returnstate; }}Copy the code

We also need a scoped action generator to call our scoped Reducer. The action generator looks like this:

const setLoading = (scope, loading) => {
  return {
    type: SET_LOADING, payload: { scope, loading, }, }; } // Call the sample store.dispatch(setLoading('users'.true));Copy the code

By using a scoped Reducer like this, we eliminate the need to duplicate the reducer logic between multiple actions and reducer functions. This greatly reduced code duplication and helped us write smaller action and Reducer files. If we need to add a module to the view, we simply add a field in the initial state and pass in a new scope type when we call setLoading. This scheme works well when we have several similar fields updated in the same way.

Sometimes we also need to share reducer logic across multiple nodes in state. We need a Reducer function that can be used multiple times by combineReducers on different nodes in the state, rather than maintaining multiple fields with a reducer and action on a single node in the state. This Reducer is generated by calling a Reducer factory function, which returns a Reducer function with a type prefix.

A good example of reusing reducer logic is paging information. Going back to our previous example of reading user information, our API may contain thousands of user information. Our API will most likely provide some information for paging between multi-page users. The API response we receive might look something like this:

{
  "users":... ."count": 2500, // The total number of users contained in the API"pageSize": 100, // The number of users returned by the interface per page"startElement": 0, // index of the first user in this response]}Copy the code

If we want to read the next page of data, we send a GET request with the startElement=100 query parameter. We could write a reducer function for each API, but that would create a lot of repetitive logic in the code. Instead, we create a separate page Reducer. This Reducer is generated by a Reducer factory that receives a prefix of type parameter and returns a new Reducer:

const initialPaginationState = {
  startElement: 0,
  pageSize: 100,
  count: 0,
};
const paginationReducerFor = (prefix) => {
  const paginationReducer = (state = initialPaginationState, action) => {
    const { type, payload } = action;
    switch (type) {
      case prefix + types.SET_PAGINATION:
        const {
          startElement,
          pageSize,
          count,
        } = payload;
        return Object.assign({}, state, {
          startElement,
          pageSize,
          count,
        });
      default:
        returnstate; }};returnpaginationReducer; }; // Example const usersReducer = combineReducers({usersData: usersDataReducer, paginationData: paginationReducerFor('USERS_')}); const domainsReducer = combineReducers({ domainsData: domainsDataReducer, paginationData: paginationReducerFor('DOMAINS_')});Copy the code

The Reducer factory function paginationReducerFor receives a prefix type as a parameter that will be used as a prefix for all action types matched by the Reducer. This factory function returns a new Reducer that has been prefixed with the type. Now, when we send an action of type USERS_SET_PAGINATION, it only triggers a reducer update to maintain user pagination information. The Reducer of domain name paging information is not affected. This allows us to effectively reuse the common Reducer function in the store. For completeness, here is an action generator factory that works with our Reducer factory, again using prefixes:

const setPaginationFor = (prefix) => {
  const setPagination = (response) => {
    const {
      startElement,
      pageSize,
      count,
    } = response;
    return {
      type: prefix + types.SET_PAGINATION,
      payload: {
        startElement,
        pageSize,
        count,
      },
    };
  };
  return setPagination; }; // Use the example constsetUsersPagination = setPaginationFor('USERS_');
const setDomainsPagination = setPaginationFor('DOMAINS_');Copy the code

5. React Integration and packaging

Some Redux apps may never need to present a view to the user (such as an API), but most of the time you will want to render data into some form of view. The most popular library for Redux rendering pages is React, which we will also use to demonstrate integration with Redux. We can use the strategies we learned in the previous points to simplify the process of creating view code. For integration, we’ll use the React-Redux library. This is where the data in the state is mapped to the props of your component.

A useful pattern for UI integration is to use selectors in view components to access data in state. It is convenient to use a selector in the mapStateToProps function in React-Redux. This function is passed in as an argument when you call the Connect method, which connects your React component to the Redux store. This is a great place to use selectors to get data from the state and pass it to the component via props. Here is an example of integration:

const ConnectedComponent = connect(
  (state) => {
    return{ users: selectors.getCurrentUsers(state), editingUser: selectors.getEditingUser(state), ... // Other props} from the state; }, mapDispatchToProps // another connect function)(UsersComponent);Copy the code

The React/Redux integration also provides a convenient place to encapsulate actions we create by scope or type. We have to wire our component’s event handler to use our Action generator when calling the Store’s Dispatch method. To do this in react-Redux, we use the mapDispatchToProps function, which is also passed in as a parameter when the connect method is called. This mapDispatchToProps method is where we normally call Redux’s bindActionCreators method to bind each action to the Store dispatch method. While we are doing this, we can also bind the scope to the action as we did in point 4. For example, if we wanted to use the Reducer mode paging with scope on a user page, we could write:

const ConnectedComponent = connect( mapStateToProps, (dispatch) => { const actions = { ... actionCreators, // other normal actionssetPagination: actionCreatorFactories.setPaginationFor('USERS_'),};return bindActionCreators(actions, dispatch);
  }
)(UsersComponent);Copy the code

Now, from our UsersPage component’s perspective, it only accepts a list of users, a portion of the state, and the bound action generator as props. A component doesn’t need to know which scoped action it needs to use or how to access state; We have addressed these issues at the integration level. This allows us to create very independent components that are not dependent on the details inside the state. Hopefully, by following the patterns discussed in this article, we can all develop Redux applications in a scalable, maintainable, and sensible way.

Read more:

  • Redux The state management library discussed in this article
  • Reselect a library for creating selectors
  • Normalizr is a library for normalizing JSON data against schemas that help store data in indexes
  • Redux-thunk A middleware for processing asynchronous actions in Redux
  • Redux-saga is another middleware that uses ES2016 generators to handle asynchronous actions

The Nuggets Translation Project is a community that translates quality Internet technical articles from English sharing articles on nuggets. Android, iOS, React, front end, back end, product, design, etc. Keep an eye on the Nuggets Translation project for more quality translations.