Translator: is_january

The original link

Redux is an excellent tool for managing application state. This one-way flow of data and its focus on immutable state makes it easy to speculate about state changes. Each update to our status is caused by a dispatched action, which causes our Reducer function to return a new state with the changes we need. When our customers manage their ads or launch their products on our platform, a lot of the PARTS of the UI that we create with Redux in AppNexus handle a lot of data and very complex user interactions. In the process of developing their interfaces, we have developed a useful set of rules and considerations to keep Redux manageable. The following discussions should help anyone developing large, strong data applications using Redux:

  • Part 1: Using indexes and selectors to store and access state

  • Part two: Separate the data objects, the editing of those data objects, and other UI states

  • Part 3: Sharing state across multiple screens of a single-page application, and when not to do so

  • Part FOUR: Reuse the public Reducer between different locations of the state

  • Part 5: Best practices for connecting React components to the Redux state

1. Store data by index. Access with a selector

Choosing the right data structure can make a big difference in the organization and performance of our application. Storing serialized data from the API greatly benefits from index-based storage. Index is a javascript object whose key is the ID of our stored data object and whose value is the actual data object itself. This template is much like using a HashMap to store data, and we get the same benefits when querying. This may not come as a surprise to those who are proficient in Redux. Indeed, Redux creator Dan Abramov recommends this data structure in his Redux tutorial.

Imagine that you have a list of data objects from a REST API, for example, from the/Users service. Suppose we decide to simply store the ordinary array in our state as if it were in Response. What happens when we need to read a particular User object? We may have to traverse all users in the state. If there are many users, this can be an expensive operation. What happens if we want to track a subset of users, possibly selected and unselected? We can store the data in two separate arrays, or we can choose to track indexes in the main array of selected and unselected users.

Instead, we decided to refactor our code to store the data in an index, which we might store 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 deal with these problems? If we want to query a specific User object, we can simply access the state like this:

const user = state.usersById[userId]
Copy the code

This method of reading saves time and simplifies reading by eliminating the need to traverse the entire list

Now you might be wondering how we actually used these data structures to render a simple list of users. To do this, we’ll use a selector, which is a function that reads the state (as an argument) and then returns the data you want. An obvious example might be to get all the user information in our state: To do so, we will use a selector, which is a function that takes the state and returns your data. A simple example would be a function to get all the users in our state:

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

In our View layer code, we call that function with the state (as an argument) to generate a list of users. Then we can iterate over those users and generate our view. We could write another function just to get the selected user from our state, like this:

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

Selector templates also improve the maintainability of code. Imagine that next, we want to change the structure of our state. Without a selector, we might have to modify all the View layer code to match our new state structure. As the number of view components increases, the burden of changing the state structure increases dramatically. To avoid this problem, we use a selector to access the state in the view. If the underlying state structure changes, we just need to update the selector correctly to read the data. All the components that use the service still get their data, and we don’t have to update them. For all of these reasons, large Redux applications benefit from index and selector data store templates.

2. Separate canonical States from view and edit states

In real life, Redux applications usually need to fetch some data from other services, such as REST apis, and when we receive that data, we dispatch an action with a payload for all the returned data. We call the data returned from a service “canonical State,” for example, the current correct state of the data as if it had been stored in our database. Our state also contains some other data, such as the state of the UI component, or the state of the entire application, and is contained together as a whole. The first time we read the canonical state from the API, we might be tempted to store that state in the same Reducer file as the rest of the page state. While this approach may be convenient, it is difficult to control the scale when you need multiple data sources.

Instead, we split the standard states into their own reducer files, which encourages a more elegant code organization and modular system. Vertical management (adding more lines of code to the same file) Reducer files are harder to maintain than horizontal management (adding more reducer files to the call point combineReducers). Splitting the reducers into their own files makes it easier to reuse those reducers (for more, go to Section 3). Furthermore, it prevents developers from adding non-canonical states to the Data object reducer.

Why not store other types of state as standardized state? Imagine that we had the same list of users from the REST API, and using indexes to store templates, we would put the data into the reducer like this:

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

Now suppose our UI allows the user to be edited in the view, and when the edit icon is clicked by a user, we need to update the state so that the view renders that user’s edit control area. Instead of storing our view state outside the standard state, we decided to place it in a new field of the object in the Users/BY-ID index. Now our state looks like this:

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

We make some edits, click the submit button, and the changes are returned to the REST service as a PUT request. The REST service returns the new state of the usersById object, but how do we merge the new standard state back into our store? If we just store the new User objects in the Users/BY-ID index by their key ID, then our isEditing identifier bit is not in there. Now we need to manually specify which fields on the API payload we want to put back into the Store, which complicates our update logic. You may have multiple booleans, strings, arrays, or other new fields necessary for UI state that will be appended to the standard state. In this case, you could easily add a new action to modify the standard state, forgetting to reset other UI fields to the data object, which would result in an invalid state. We should store the standard data in its own separate data store in the Reducer and make our actions simpler and easier to deduce.

Another benefit of keeping the edit state independent is that if the user cancels the edit, we can easily reset the standard state. If we’ve already clicked on a user’s edit icon, and we’ve already edited the user’s name and email address, now we don’t need to keep those changes, so we hit cancel, which should cause the changes we made in the view to roll back to their previous state, but, Because we overwrite our standard state with edit state, we no longer have the old data state, and we will be forced to crawl data from REST apis to get our standard state again. Now our state might look 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

Because we have a copy of the object’s edit state and standard state, it’s easy to reset after you hit Cancel. We only need to display standard state in the view rather than edit state, and no further calls to the REST API are required. As a bonus, we still track edit status in the Store. If we decide that we really need to keep the edits, we simply click the Edit button again and the edit status will now be displayed with our old changes. In summary, separating edit state and view state from standard state and starting with code organization and maintainability, as well as a better user experience for interacting with forms, provides a better developer experience for both.

3. Share state between views properly

Many applications may start with a single store and user interface, and as we develop our applications to extend their features, we need to manage state across multiple views and stores. To extend our Redux application, it might be helpful to create a top-level Reducer on each page. Each page and top Reducer corresponds to a view in our application. For example, the user page captures user data from the API and puts it in the Users Reducer, while another page tracking the current user domain captures and stores data from our domain API. The state now looks like this:

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

Organizing pages like this ensures that the data behind the view is decoupled and independent. Each page tracks its own state, and our Reducer files can be put together with our view files. As we expand our application, we may find that we need to share a state between two views that depend on that data (state). Consider the following when sharing state:

  • How many views or other Reducer will depend on this data?

  • Does every page need a copy of this data?

  • How often does this figure change?

For example, our application needs to display some information of the current logged user on each page. We need to obtain the user information from the API and store it in our Reducer. We know that each page needs to rely on this data, so it is not appropriate to have a top-level reducer strategy for each page. We know that each page does not necessarily need a unique copy of the data, because most data pages do not grab other users or modify the current user, and the data of the currently logged user is unlikely to change unless they are doing some editing on the user page.

Sharing the current user’s state between pages seems like a good plan, so we’ll pull it out and put it into the top-level reducer of its own files. Now the first user visits a page that checks if the current User Reducer is loaded and requests data from the API without being loaded. Any view connected to the Redux Store can access information about the currently logged in user.

So in what cases is there no reason to use shared dissociation? Let’s think of another example. Assuming that each user domain also has several subdomains, we add a subdomain page to the application that displays a list of all user subdomains. The (main) domain page also has an option to display subdomains for a given domain. We now have two pages that rely on subdomain data. We also know that domains change on a somewhat frequent basis ————— users may add, delete, or edit domains or subdomains at any time, and each page may require its own copy of data. Subdomain pages will allow reads and writes to subdomain apis and may require paging based on different pages of data, whereas (main) domain pages will only need to fetch subsets of subdomains once (subdomains of a given domain). Sharing subdomain state between these views may not be an appropriate use, but it is clear that each page should archive its own copy of subdomain data.

4. Reuse the common cross-state Reducer function

After writing some reducer functions, we might decide to try to reuse our Reducer logic at different locations in the state. For example, we could create a Reducer to fetch user data from an API that only returns 100 users at a time, but we might have thousands or more in the system. To do this, Our Reducer also needs to keep track of which page of data is currently displayed. Our fetching logic reads from the reducer to determine paging parameters (such as page_number) to be sent to the next API request, and then when we need to fetch the domain list we end up with exactly the same fetching and storing domain logic. With a different API endpoint and a different object schema, the paging behavior is the same. Experienced developers realize that we might be able to modularize this Reducer and share this logic across all reducers that need to be paginated.

Sharing reducer logic can be tricky in Redux. By default, all reducer functions are called when a new reducer action is dispatched. If we share a Reducer function with multiple other reducer functions, So when we dispatch the action, it causes all of the reducer’s to be triggered, but this is not the behavior we want to reuse the Reducer. When we grab the user and get a total of 500, we don’t want the field count to be 500 as well.

We recommend two different approaches to share the Reducer, both using special scope or type prefixes. The first method passes a constraint field to the payload of an action that uses Type to infer the key in the state to be updated. To explain this more clearly, imagine a web page with several different parts that are loaded asynchronously from different API endpoints. The state we use to track loads looks like this:

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

With such a state, we need the Reducer and Action to set the load state for each view section. We can write four different Reducer functions for four different actions ———— Each reducer will use its own action type. This creates a lot of duplicate code! Instead, let’s try a Reducer and action with scoped. We just created an action type SET_LOADING and a reducer that looks like this:

const loadingReducer = (state = initialLoadingState, action) => { const { type, payload } = action; if (type === SET_LOADING) { return Object.assign({}, state, { // sets the loading boolean at this scope [`${payload.scope}Loading`]: payload.loading, }); } else { return state; }}Copy the code

We also need to provide a restricted-domain action creator function to call our restricted-domain reducer. The action should look like this:

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

By using such a reducer with a limited domain, we no longer need to duplicate the reducer logic in multiple actions and reducer functions. This greatly reduced code duplication and helped us write smaller action and Reducer files. If you need to add other parts to the live view, we simply provide a new key to our Initial state, make a separate dispatch call, and pass a different scope into the setLoading when calling this Dispatch. This scheme works well when we have several similar, juxtaposed fields that need to be updated in the same way.

However, there are times when we need to share reducer logic between different locations of the state. We want a reusable Reducer function that we can use the Reducer call combineReducers to embed different positions of the state, rather than using a Reducer and action to write multiple fields to a position in the state. This reducer will be returned by a call to a Reducer factory function, which returns a new reducer with a new prefixed type.

A good example of reusing reducer logic is when it comes to paging information. Going back to our example of fetching user data, our API may contain thousands or more users, and most likely our API will provide some paging information through the user’s multiple pages. You might receive an API result like this:

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

If we want the data for the next page, we can send a GET request with the startElement=100 query parameter. We just need to construct a reducer function for each API service we interact with, but this does not repeat the same logic in many places in our code. Instead, We will create a separate paging reducer that will return from a Reducer factory that receives a prefix type and returns a new Reducer function:

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: return state; }}; return paginationReducer; }; // example usages const usersReducer = combineReducers({ usersData: usersDataReducer, paginationData: paginationReducerFor('USERS_'), }); const domainsReducer = combineReducers({ domainsData: domainsDataReducer, paginationData: paginationReducerFor('DOMAINS_'), });Copy the code

The Reducer factory paginationReducerFor receives a prefixed Type that will be added to all the reducer matched types. The factory returns a new Reducer with prefixed types. Now when we dispatch an action, such as USERS_SET_PAGINATION, it will only cause the user’s paging reducer update and the domain’s paging reducer will not change. This effectively allows us to reuse the common Reducer function at multiple locations in the store. For completeness, here is an action creator factory that handles 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;
};
// example usages
const setUsersPagination = setPaginationFor('USERS_');
const setDomainsPagination = setPaginationFor('DOMAINS_');
Copy the code

Store. Dispatch (setuserination (response))

5. React Integration and packaging

Some Redux applications may never render a view to the user (like an API), but most of the time you want to render your data in some view. The most popular library for rendering UI with Redux is React, and that’s the one we’ll use to show how to integrate with Redux. We can use many of the strategies we’ve learned above to make it easier to build our view code. To do this integration, we’ll use the React-Redux library.

A useful template for UI integration is to use selectors to access data in state from our view components. A handy place to use selectors in React-Redux is the mapStateToProps function, which is passed in a call to the connect function (which you’ll call to connect your React component to the Redux store), This is where you will map the data in the state to your component receiving props. This is a perfect place to use the selector to receive the data in the state and pass it to the component as props. A sample integration might look like this:

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

The React and Redux integration also gives us a convenient place to wrap actions with scope or Type. We need to install the component’s processing handle to actually call the Store’s Dispatch using the Action creator. To do this in react-Redux, we use the mapDispatchToProps function, which is also passed to the call point of connect. This mapDispatchToProps function is where we normally call Redux’s bindActionCreators to achieve binding each action from the Store to the Dispatch method. When we do this, we can also bind the scope to the action, as shown in Section 4. For example, if we wanted to use a scoped Reducer template with the pager for the user page, we could write:

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

Now, from the UsersPage component’s perspective, it only accepts the user list and other parts of the state as props, with the bound Action creator in the props. This component doesn’t need to care about which domain-bound actions it needs, or how to access the state. We’ve dealt with this in the integration layer, which allows us to create very decoupled components that don’t necessarily depend on the internal operations of our state. Hopefully, by following the templates discussed here, we can all create Redux applications in a scalable, maintainable, and reasonable way.

Advanced reading:

  • Redux: The state management library discussed in this article

  • Reselect: Library to create selectors

  • Normalizr: Library for Schema regularized JSON data, useful for storing data indexed

  • Redux-thunk: Middleware for Redux asynchronous actions

  • Redux-saga: Another asynchronous action middleware written with ES2016 Generator

The author Chris Dopuch