Practical Redux, Part 6: Connected Lists, Forms, and Performance

This is a post in the Practical Redux series.


Connecting lists and forms, performance guidelines, editing features, and UI state

Intro

In Part 5, we added sourcemap support, used Redux-ORM to define and load data, and added some basic item selection tracking. This time, we'll connect lists and forms directly to Redux, discuss performance considerations, implement basic editing capabilities, and add conditional UI state handling.

The code for this project is on Github at github.com/markerikson/project-minimek. The original WIP commits I made for this post can be seen in PR #6: Practical Redux Part 6 WIP, and the final "clean" commits can be seen in in PR #5: Practical Redux Part 6 Final.

I'll be linking to each "final" commit as I go through the post, as well as specific files in those commits. I won't paste every changed file in here or show every single changed line, to save space, but rather try to show the most relevant changes for each commit as appropriate.

Table of Contents

Connecting Additional Components

Picking up where we left off, we can see that each of our main "panel" components are connected to Redux, as well as the TabBar component. However, none of the components within those panels are directly connected. Instead, we're passing data and action creators down as props from each panel to its children. It's perfectly fine to only have a few connected components, but as an app grows, this can become a pain point.

The Redux FAQ covers this topic in the question "Should I only connect my top component, or can I connect multiple components in my tree?". Quoting the answer:

The current suggested best practice is to categorize your components as “presentational” or “container” components, and extract a connected container component wherever it makes sense:

Emphasizing “one container component at the top” in Redux examples was a mistake. Don't take this as a maxim. Try to keep your presentation components separate. Create container components by connecting them when it's convenient. Whenever you feel like you're duplicating code in parent components to provide data for same kinds of children, time to extract a container. Generally as soon as you feel a parent knows too much about “personal” data or actions of its children, time to extract a container.

In fact, benchmarks have shown that more connected components generally leads to better performance than fewer connected components.

In general, try to find a balance between understandable data flow and areas of responsibility with your components.

Following this idea, our next step will be to connect more individual components at a finer-grained level of detail.

Connecting the PilotDetails Component

We'll start with the <PilotDetails> component. Right now, the <Pilots> component is retrieving a list of all Pilot objects in its mapState function, plus the currentPilot ID value. Then, when it renders, it does a lookup to find which Pilot entry matches the selected ID, and passes that object to <PilotDetails>. We can connect <PilotDetails> directly, and remove that logic from <Pilots>.

This is a straightforward transformation. We'll add a mapState function to <PilotDetails>, look up the right Pilot object by ID if available, and return that entry. While we're at it, we'll also tweak the input components to be disabled, so that the user knows they can't actually interact with them.

Commit 7663759: Update PilotDetails to be connected

features/pilots/PilotDetails/PilotDetails.jsx

import React from "react";
+import {connect} from "react-redux";
import {Form, Dropdown} from "semantic-ui-react";

+import schema from "app/schema";
+import {selectCurrentPilot} from "../pilotsSelectors";

+const mapState = (state) => {
+    let pilot;
+    
+    const currentPilot = selectCurrentPilot(state);
+    
+    const session = schema.from(state.entities);
+    const {Pilot} = session;
+    
+    if(Pilot.hasId(currentPilot)) {
+        pilot = Pilot.withId(currentPilot).ref;
+    }
+    
+    return {pilot}
+}

// Omit component code for space

-export default PilotDetails;
+export default connect(mapState)(PilotDetails);

features/pilots/Pilots/Pilots.jsx

    render() {
        const {pilots = [], selectPilot, currentPilot} = this.props;

-       const currentPilotEntry = pilots.find(pilot => pilot.id === currentPilot) || {}

// Omit irrelevant rendering code
-                           <PilotDetails pilot={currentPilotEntry} />
+                           <PilotDetails />

The mapState connection replaces the logic we had in <Pilots>.

Connecting the PilotsList Component

Next up is the <PilotsList> component. The <Pilots> component currently extracts the actual Pilot objects from the store, passes them as an array to <PilotsList>, and then each individual plain Pilot object is passed as a prop to the "presentational" list items. We could just move the current mapState logic from <Pilots> to <PilotsList> and leave it at that, but instead, we're going to implement one of the most useful Redux techniques: a connected list that passes item IDs to connected list items. Let's look at the implementation, then discuss some of the details of the specific approach we're using.

The mapState for <PilotsList> will need to return an array of IDs for all Pilot entries. <PilotsList> will then render its list of <PilotsListRow> components, and pass the appropriate pilot ID into each list item. Either the list or the list item will need to determine if that list item is currently selected. There's valid arguments either way, but since we're already passing a selected flag into each list item, we'll leave that in place.

Commit dc2c6ab: Update PilotsList to be connected

features/pilots/PilotsList/PilotsList.jsx

+import {selectPilot} from "../pilotsActions";
+import {selectCurrentPilot} from "../pilotsSelectors";
+
+
+const mapState = (state) => {
+    const session = schema.from(state.entities);
+    const {Pilot} = session;
+
+    // Extract a list of IDs for each Pilot entry
+    const pilots = Pilot.all().withModels.map(pilotModel => pilotModel.getId());
+
+    const currentPilot = selectCurrentPilot(state);
+
+    // Return the list of pilot IDs and the current pilot ID as props
+    return {pilots, currentPilot};
+}
+
+// Make an object full of action creators that can be passed to connect
+// and bound up, instead of writing a separate mapDispatch function
+const actions = {
+    selectPilot,
+};
+

export class PilotsList extends Component {
    render() {
-      const {pilots, onPilotClicked, currentPilot} = this.props;
+       const {pilots = [], selectPilot, currentPilot} = this.props;

-       const pilotRows = pilots.map(pilot => (
+       const pilotRows = pilots.map(pilotID => (
            <PilotsListRow
-               pilot={pilot}
-               key={pilot.name}
-               onPilotClicked={onPilotClicked}
-               selected={pilot.id === currentPilot}
+               pilotID={pilotID}
+               key={pilotID}
+               onPilotClicked={selectPilot}
+               selected={pilotID === currentPilot}
            />
        ));

The Model.getId() method is useful if you don't happen to know the exact name of the ID field for a model type. Maybe it's actually name, or guid, or something else. We can declare the ID field name as part of the model declaration, and the getId() method will use that to look up the right field when asked.

There's a few other ways we could come up with the list of Pilot IDs. Since we do know the ID field name in the plain Pilot objects, we could do Pilots.all().map(pilot => pilot.id). Another approach involves digging into Redux-ORM's internals just a bit. The QuerySet class keeps an array of IDs for the entries it's encapsulating, as a field named idArr. So, we could in theory do const pilotIDs = Pilot.all().idArr, and return that. Finally, if we wanted to bypass using Redux-ORM's API, we could directly access the state.entities.Pilot.items array, where Redux-ORM keeps a list of all Pilot IDs in the state.

pilots/PilotsList/PilotsListRow.jsx

+const mapState = (state, ownProps) => {
+    const session = schema.from(state.entities);
+    const {Pilot} = session;
+
+    let pilot;
+
+    if(Pilot.hasId(ownProps.pilotID)) {
+        const pilotModel = Pilot.withId(ownProps.pilotID);
+
+        // Access the underlying plain JS object using the "ref" field,
+        // and make a shallow copy of it
+        pilot = {
+            ...pilotModel.ref
+        };
+
+        // We want to look up pilotModel.mech.mechType.  Just in case the
+        // relational fields are null, we'll do a couple safety checks as we go.
+
+        // Look up the associated Mech instance using the foreign-key
+        // field that we defined in the Pilot Model class
+        const {mech} = pilotModel;
+
+        // If there actually is an associated mech, include the
+        // mech type's ID as a field in the data passed to the component
+        if(mech && mech.type) {
+            pilot.mechType = mech.type.id;
+        }
+    }
+
+    return {pilot};
+}

For <PilotsListRow>, we just copy over the lookup logic we had in <Pilots>, except that now we're only looking up one entry instead of all of them. Also, we're using the pilotID prop that the <PilotsList> parent component is passing down. The connected wrapper component for <PilotsListRow> makes all passed-in props available to mapState if we declare that mapState takes two arguments. By convention, the second argument is referred to as ownProps.

Note that when mapState is declared to take two arguments, it will be called more often. This is in case a change of passed-in props would result in a change to the values returned from mapState.

features/pilots/Pilots/Pilots.jsx

- // Delete the existing mapState function entirely

-export class Pilots extends Component {
+export default class Pilots extends Component {
    render() {
-        const {pilots = [], selectPilot, currentPilot} = this.props;

        return (
            <Segment>
                <Grid>
                    <Grid.Column width={10}>
                        <Header as="h3">Pilot List</Header>
-                       <PilotsList
-                           pilots={pilots}
-                           onPilotClicked={selectPilot}
-                           currentPilot={currentPilot}
-                       />
+                       <PilotsList />
                    </Grid.Column>

// Omit other rendering logic


-export default connect(mapState, actions)(Pilots);

With those changes in place, the <Pilots> component is no longer connected, and is actually now back to being entirely presentational. It renders several layout-related components, and two connected containers: <PilotsList> and <PilotDetails>.

Connected Components and Performance

The changes to <PilotsList> bring up a key topic: performance. There's several things that are valuable to understand here.

Basic Performance Considerations

By default, whenever a React component re-renders, React will re-render all of its descendents. That means if the root component were to call this.setState(), the entire component tree will re-render. It's very likely that the majority of components in the tree would receive the exact same data as before and render the same output. React still has to diff the virtual DOM tree to determine if anything changed, so any render output that didn't change is effectively "wasted" effort. (React's shouldComponentUpdate method can be used to skip rendering for a component and its descendents, usually by doing comparisons to see if its props have really changed.)

Redux helps with this by limiting the sub-trees that actually need to re-render. connect generates wrapper components that manage subscriptions to the store, and each individual connected component instance is a separate subscriber. After each dispatch, every connected component will re-run its mapState function, and do shallow equality checks on the result to see if the returned values have changed since the last time. If the values returned by mapState are different, then the wrapper component will re-render the "real" component.

Note: "shallow equality" means doing === reference comparisons on each individual field within the object returned from mapState. The return object itself will always be different - what matters is if currentResult.someField === lastResult.someField, and so on for each field in the object.

Keys to Good Redux Performance

This has some important implications:

First, mapState functions should run as fast as possible. This means that a mapState function should minimize the amount of work it has to do, and do that work quickly. Avoid doing very expensive work in mapState unless absolutely necessary! This includes complex filtering and transformations. Memoized selector functions, such as the ones created by reselect, can ensure that expensive work is only done when something actually changed.

There's one very specific performance anti-pattern that involves use of Immutable.js. According to its author, Lee Byron, calling toJS() is extremely expensive, and should therefore NOT be done in mapState!. (There's several other performance concerns to take into consideration with Immutable.js - see the list of links at the end of this post for more information.)

Second, returning the same variable references as part of the mapState result is necessary to eliminate wasted re-renders. That means that if you're returning the same types of data at the same keys from mapState, but the keys point to different variable references each time, connect will think things have changed and re-render your component. One of the most common examples of this is using Array.map() inside of mapState. Every time you use map(), you create a new array reference. Again, memoized selectors can help with this by ensuring that the same values are returned.

Third, overall performance is a balance between the overhead of more mapState calls, and time spent by React re-rendering. Redux subscriptions are O(n) - every additional subscriber means a bit more work every time an action is dispatched. Fortunately, per the earlier quote from the FAQ, benchmarks have shown that the cost of more connected components is generally less than the cost of more wasted re-rendering.

Connected Performance Example

The classic example for examining Redux performance would be a list of 10,000 Todo items. The basic setup would have only the parent component connected, and directly passing a Todo object to each child. In this case, editing the text of one Todo will cause the list to re-render, and thus all 10,000 children to re-render as well.

However, if the list component only passes IDs to each child, and each child is connected and looks up its own Todo item by ID, then most of the time that Todo item will be the same and the list item component won't need to re-render. This is one of several reasons why normalized data is so useful in Redux, because normalized data makes it easy to look up a specific item by its type and ID.

There's an excellent slideshow called High Performance Redux that discusses this concept in detail, with demos of varying approaches and their performance.

Performance Concerns with Project Mini-Mek

Based on all that information, let's do a quick review of our implementation of a connected <PilotsList>.

The good news is that we are using the "connected list passing IDs to connected chilren" pattern. The bad news is there's several aspects that are not fully optimized yet:

  • The mapState for <PilotsList> is using Array.map() to create a list of all Pilot IDs. That list will be a different array reference every time, causing <PilotsList> to re-render.
  • Meanwhile, the mapState for <PilotsListRow> is creating a new object for the pilot prop each time as well, so <PilotsListRow> will also keep re-rendering
  • Neither <PilotsList> nor <PilotsListRow> are using any memoized selector functions at all
  • We're also creating a new Redux-ORM Session instance every time mapState is run, for each connected component.

Fortunately, given the size and scope of Project Mini-Mek, performance isn't actually a real concern right now. Because of that, we'll skip performance optimizations for now, and investigate those at a later time. For now, we've at least examined some of the major performance concerns to be aware of, and know where to look when it's time to actually implement optimizations.

Connecting the Mechs Components

We'll wrap up this section by applying the same sets of changes to the various components in the "Mechs" panel as well.

Commit 60c3f29: Update MechDetails to be connected

Commit bba6aa9: Update MechsList to be connected

Connecting Form Components

Thus far, our application has been non-interactive. We can currently click on Pilot and Mech list items to select them, but there's no way to modify anything. It's time to start implementing some interactivity.

Creating the Form Update Logic

Our first task is to hook up the <UnitInfo> form so that we can edit the current unit's name and change what Battletech House or mercenary group they're affiliated with. We'll need to add an action type and a case reducer to handle those updates, then modify <UnitInfo> so that it dispatches the action in response to onChange callbacks from the inputs.

Commit 7352c4f: Implement initial unit info update logic

The action/reducer changes are simple. New action type, a matching action creator, and a case reducer:

features/unitInfo/unitInfoReducer.js

import {DATA_LOADED} from "features/tools/toolConstants";
+import {UNIT_INFO_UPDATE} from "./unitInfoConstants";

+function updateUnitInfo(state, payload) {
+   return {
+       ...state,
+       ...payload,
+   };
+}

export default createReducer(initialState, {
    [DATA_LOADED] : dataLoaded,
+   [UNIT_INFO_UPDATE] : updateUnitInfo,
});

We could create entirely separate action types and reducers for updating the "Name" field and the "Affiliation" field, but that would be a waste of effort. Defining action payloads and reducer logic involves tradeoffs, and it's up to you to decide when actions should be more specific or more general. I usually avoid reducers that just blindly copy whatever the action contains, but in this case it's easy enough to just copy over the payload, and let the dispatching code ensure that the payload is formatted correctly.

Connecting a Controlled Input

One of the most important concepts to understand when learning React is the idea of "controlled inputs". If you're not familiar with controlled inputs, go read Gosha Arinich's article Controlled and uncontrolled form inputs in React don't have to be complicated, or the additional articles on forms in React linked at the end of the post.

As a quick summary, a controlled input is an input with a value prop and an onChange handler. That means that the input is being told what its value is at all times, instead of the application asking the input for its value when it's time to submit the form. Managing controlled inputs does take additional work, but ultimately makes the application much easier to think about, since all the form data is already being stored by the application.

Values for controlled inputs can be stored by a React component, or passed all the way back to a Redux store. Since the <UnitInfo> component is already connected, we just need to pass in the action creator, add an onChange handler for the "Affiliation" dropdown, and dispatch the action appropriately:

Commit dc4d179: Implement initial change handling for UnitInfo

features/unitInfo/UnitInfo/UnitInfo.jsx

+import {updateUnitInfo} from "../unitInfoActions";

+const actions = {
+   updateUnitInfo,
+};

class UnitInfo extends Component {
+   onAffiliationChanged = (e, result) => {
+       const {name, value} = result;
+
+       const newValues = { [name] : value};
+       this.props.updateUnitInfo(newValues);
+   }

// Omit unrelated rendering 

                        <Dropdown
+                           name="affiliation"
                            selection
                            options={FACTIONS}
                            value={affiliation}
+                           onChange={this.onAffiliationChanged}
                        />

// Omit rest of component

-export default connect(mapState)(UnitInfo);
+export default connect(mapState, actions)(UnitInfo);

A few things to note about the onAffiliationChanged handler:

First, we're using the stage 2 Class Properties syntax to define an auto-bound method using an arrow function, so that this inside the callback correctly refers to the component instance.

Second, while Semantic-UI-React's component props documentation is excellent, they don't seem to formally document the signature for the <Dropdown>'s onChange callback. After checking some issues such as SUI-React #581, I've confirmed that the <Dropdown> passes two arguments to its onChange callback: some event object, and a result object that contains the name of the component and its new value (like {name : "affiliation", value : "wd"}). We want to reshape that into something like {affiliation : "wd"}, so we use the ES6 object computed properties syntax to create the new object.

Finally, since we used the object shorthand syntax for binding up action creators with connect(), calling this.props.updateUnitInfo(newValues) immediately dispatches the action.

Now, if we go to the Unit Info tab and select "Draconis Combine" from the dropdown, we should see the dispatched action in our DevTools:

And the dropdown should now read "Draconis Combine":

From there, we can enable editing the "Name" field with just another change handler:

Commit 2cca3ea: Hook up unit info name input

features/unitInfo/UnitInfo/UnitInfo.jsx

+   onNameChanged = (e) => {
+       const {name, value} = e.target;
+
+       const newValues = { [name] : value};
+       this.props.updateUnitInfo(newValues);
+   }
    
// Omit other rendering

                    <Form.Field name="name" width={6}>
                        <label>Unit Name</label>
-                       <input placeholder="Name" name="name" value={name}/>
+                       <input
+                           placeholder="Name"
+                           name="name"
+                           value={name}
+                           onChange={this.onNameChanged}
+                       />
                    </Form.Field>

And now we can happily type some gibberish into the "Unit Name" field, and see it show up:

So, this is great progress! We can edit the name and affiliation of our combat unit.

Retrieving Values from Input Events

Right now we're manually extracting the name and value fields from the text input's onChange event. There's some differences in how HTML inputs structure their events. Checkboxes in particular use a different field name ( checked instead of value). We can write a small utility function to extract the name and value from events, and do the object formatting for us.

Commit 745eda8: Add a utility function to extract values from input events

common/utils/clientUtils.js

import {isObject} from "lodash";

export function getValueFromEvent(e) {
    const {target} = e;

    let newValues;

    if(target) {
        const value = (target.type === "checkbox") ? target.checked : target.value;
        newValues = {
            [target.name] : value,
        };
    }
    else if(isObject(e)) {
        newValues = e;
    }

    return newValues;
}

And that simplifies our code in <UnitInfo> a bit:

features/unitInfo/UnitInfo/UnitInfo.jsx

+import {getValueFromEvent} from "common/utils/clientUtils";


    onNameChanged = (e) => {
-       const {name, value} = e.target;
-
-       const newValues = { [name] : value};
+       const newValues = getValueFromEvent(e);
        this.props.updateUnitInfo(newValues);
    }

Type in the input, we get back a name/value object as needed, and we dispatch it. Looks great.

There is one problem with our text input that we need to address, but we'll deal with that next time.

Pilot Form UI State

Now that we can edit the basic attributes for our combat unit, it's time to move on to the Pilots panel. We want to add the ability to edit the attributes for our individual Pilot entries. As part of that, it would be nice if we actually could toggle whether we're in "edit mode" or not. For now, let's implement logic to track "editing mode" for pilots, and hold off on actually connecting the inputs until next time.

We already have logic for tracking which pilot is selected. To add to that, we should only be able to start editing if a pilot is selected. If we're editing one pilot, and click to select another, editing mode should be turned off.

Tracking Editing State for the UI

Let's start by adding some logic to track whether we're editing a pilot or not. We'll create a couple new action types (PILOT_EDIT_START and PILOT_EDIT_STOP), and update our pilots reducer with a new flag and the logic to update it appropriately:

Commit 09fda20: Add logic to track if a pilot is being edited

features/pilots/pilotsReducer.js

import {
    PILOT_SELECT,
+   PILOT_EDIT_START,
+   PILOT_EDIT_STOP,
} from "./pilotsConstants";

const initialState = {
    currentPilot : null,
+   isEditing : false,
};

export function selectPilot(state, payload) {
    const prevSelectedPilot = state.currentPilot;
    const newSelectedPilot = payload.currentPilot;

    const isSamePilot = prevSelectedPilot === newSelectedPilot;
    
    return {
        ...state,
        // Deselect entirely if it's a second click on the same pilot,
        // otherwise go ahead and select the one that was clicked
        currentPilot : isSamePilot ? null : newSelectedPilot,
+       // Any time we select a different pilot, we stop editing
+       isEditing : false,
    };
}

+export function startEditingPilot(state, payload) {
+   return {
+       ...state,
+       isEditing : true,
+   };
+}

+export function stopEditingPilot(state, payload) {
+   return {
+       ...state,
+       isEditing : false,
+   };
+}


export default createReducer(initialState, {
    [PILOT_SELECT] : selectPilot,
+   [PILOT_EDIT_START] : startEditingPilot,
+   [PILOT_EDIT_STOP] : stopEditingPilot,
});

The reducer logic is straightforward. We respond to "start" and "stop" by setting the isEditing flag appropriately, and also reset it to false whenever a pilots list entry is clicked.

Adding Edit Mode Toggles

Our next step is adding a pair of "Start / Stop Editing" buttons to the <PilotDetails> form, and hooking them up. We also want to add some conditional logic so that they're only enabled if appropriate.

Commit ab6f27e: Add "Start/Stop Editing" buttons to PilotDetails

features/pilots/PilotDetails/PilotDetails.jsx

-import {selectCurrentPilot} from "../pilotsSelectors";
+import {selectCurrentPilot, selectIsEditingPilot} from "../pilotsSelectors";

+import {
+   startEditingPilot,
+   stopEditingPilot,
+} from "../pilotsActions";


const mapState = (state) => {
    // Omit Pilot object lookup code
 
+   const pilotIsSelected = Boolean(currentPilot);
+   const isEditingPilot = selectIsEditingPilot(state);

-   return {pilot}
+   return {pilot, pilotIsSelected, isEditingPilot}
}

+const actions = {
+    startEditingPilot,
+    stopEditingPilot,
+}


-const PilotDetails = ({pilot={}}) =>{
+const PilotDetails = ({pilot={}, pilotIsSelected = false, isEditingPilot = false, ...actions }) =>{
// Omit attribute lookups

+    const canStartEditing = pilotIsSelected && !isEditingPilot;
+    const canStopEditing = pilotIsSelected && isEditingPilot;

    return (
        <Form size="large">
            <Form.Field name="name" width={16}>
                <label>Name</label>
                <input
                    placeholder="Name"
                    value={name}
-                   disabled={true}
+                   disabled={!canStopEditing}
                />
            </Form.Field>
// Omit other fields
+           <Grid.Row width={16}>
+               <Button
+                   primary
+                   disabled={!canStartEditing}
+                   type="button"
+                   onClick={actions.startEditingPilot}
+               >
+                   Start Editing
+               </Button>
+               <Button
+                   secondary
+                   disabled={!canStopEditing}
+                   type="button"
+                   onClick={actions.stopEditingPilot}
+               >
+                   Stop Editing
+               </Button>
+           </Grid.Row>

In our mapState function, we look at the currentPilot flag to determine if a pilot is selected or not, and pass that as a prop. In the component, we look at isEditing and pilotIsSelected, and derive two new flags to determine if the "Start" and "Stop" buttons should be enabled. We also use those to appropriately enable and disable the inputs.

One other useful note: by default, clicking an HTML <button> inside of a <form> will auto-submit the form. To avoid that, you have to give the button a type="button" attribute. Real pain in the neck, but now you know :)

Let's check out how the form looks now. If we have data loaded, select a pilot, and click "Start Editing", we should now see this:

That's probably a good place to wrap up the work for this post.

Final Thoughts

It took the first few posts to lay a foundation, but we're now seeing some progress. We've got a good pattern for connecting list components and list items. We've looked at some important performance considerations, and know where we can make performance improvements in the future. We can now do our first data editing, and we've added the ability to toggle the status of some UI components.

I had to split this post into two parts due to its size, so Part 7 should follow within the next few days. Part 7 will dig into some advanced techniques for managing form inputs and structuring reducer logic, so be sure to check that out soon!

Further Information


This is a post in the Practical Redux series. Other posts in this series:


Author Avatar

Mark Erikson

Collector of interesting links, answerer of questions