Practical Redux, Part 11: Nested Data and Trees

This is a post in the Practical Redux series.


Intermediate handling of nested relational data and tree components

Intro

Last time in Part 10, we built modal dialog and context menu systems that were driven by Redux. This time, we're going to add creation of new entities, implement loading of nested relational data, and display that nested data in a tree component.

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 #14: Practical Redux Part 11 WIP, and the final "clean" commits can be seen in in PR #15: Practical Redux Part 11 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

Library Updates

It's been a few months since the last post, and both React and Semantic-UI-React have been updated. At the time of this writing, the latest versions are React 16.2 and Semantic-UI-React 0.77.

Right now, this project is on React 15.6. Since our code currently compiles and runs with no warnings, we should be able to safely upgrade to React 16 just by updating our package versions. The main concern would be making sure that our other libraries are also compatible with React 16. Since we previously updated libraries that depended on PropTypes, that's not much of an issue here. The only other issue is our use of Portals for context menus. There's a newer version of the react-portal library that supports both React 15 and React 16's differing Portal APIs, so we'll update that as well.

yarn upgrade react@16.2 react-dom@16.2 react-portal@4.1.2

Commit 415fc95: Upgrade to React 16.2

There's one small code tweak we need to make, which is changing Portal to be a named import instead of a default import.

Commit 11b7ef4: Update React-Portal usage to match version 4.x

We'll also upgrade Semantic-UI-React.

yarn upgrade semantic-ui-react@0.77

Commit 6e1be79: Update Semantic-UI-React

As a side note, I actually experienced some issues with Yarn's offline mirror feature doing these upgrades. When I upgraded React, the older package tarballs were correctly removed from the ./offline-mirror feature, but the new tarballs weren't added. Even more oddly, the SUI-React tarball showed up correctly. I eventually resolved this by running yarn cache clean, then re-attempting the package updates.

Adding New Pilots

So far, we have focused on interacting with the list of pilots. Project Mini-Mek currently has the ability to show a list of pilots, update pilot entries, and delete pilots. However, we're still missing a key capability: actually creating new pilot entries (the 'C' in "CRUD").

We'll tackle that shortly, but first we'll do a bit of code cleanup related to the pilots feature.

Pilots Code Cleanup

The <PilotDetails> form has some inputs that are in separate rows, but each only takes up a part of the row, leaving some empty space. We can consolidate those into a couple combined rows.

Commit 51377a7: Rearrange PilotDetails form to be more compact

features/pilots/PilotDetails/PilotDetails.jsx

+               <Form.Group>
                    <Form.Field
                        name="rank"
                        label="Rank"
-                       width={16}
+                       width={10}
                   </Form.Field
// omit other input values and fields
+               </Form.Group>
+              <Form.Group widths="equal">
                   <Form.Field
                        name="gunnery"
                        label="Gunnery"
                   </Form.Field>
+              </Form.Group>

This looks a bit nicer:

Also, the current list of pilot ranks is hardcoded in the format that SUI-React's Dropdown wants. We can turn that into a simple constants array, and then derive the display data as needed.

Commit 02bd796: Simplify pilot rank constant definition

features/pilots/pilotsConstants.js

export const PILOT_EDIT_STOP = "PILOT_EDIT_STOP";

+export const PILOT_RANKS = [
+   "Private",
+   "Corporal",
+   "Sergeant",
+   "Lieutenant",
+   "Captain",
+   "Major",
+   "Colonel",
+];

features/pilots/PilotDetails/PilotDetails.jsx

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

-const RANKS = [
-   {value: "Private", text : "Private"},
-   {value: "Corporal", text : "Corporal"},
-   {value: "Sergeant", text : "Sergeant"},
-   {value: "Lieutenant", text : "Lieutenant"},
-   {value: "Captain", text : "Captain"},
-   {value: "Major", text : "Major"},
-   {value: "Colonel", text : "Colonel"},
-];
+const RANKS = PILOT_RANKS.map(rank => ({value : rank, text : rank}));

Creating New Entities

We currently have the ability to edit items, and our editing feature has generic actions and reducers for updating the contents of Redux-ORM entities with new values. However, our editing feature is built around the assumption that there are existing items in the state.entities "current values" slice, and that editing an item should copy that item from state.entities to the state.editingEntities "work-in-progress" slice. That assumption no longer holds true for adding and editing new items - we can't copy them because they don't yet exist in the entities slice.

We need some additional support for creating new entities directly into the editingEntities slice so that our edit actions can work on them. In addition, our "save edits" logic also assumes that an item with that type and ID already exists in entities, and tries to look up that Model instance to apply the updates from the edited model. We need to update the save logic to handle this case as well.

Commit 1c8e64b: Add ability to edit a new entity

features/editing/editingReducer.js

import {
    EDIT_ITEM_EXISTING,
+   EDIT_ITEM_NEW,
    EDIT_ITEM_UPDATE,
    EDIT_ITEM_APPLY,
    EDIT_ITEM_STOP,
    EDIT_ITEM_RESET,
} from "./editingConstants";

export function updateEditedEntity(sourceEntities, destinationEntities, payload) {
    // Start by reading our "work-in-progress" data
    const readSession = orm.session(sourceEntities);

    const {itemType, itemID} = payload;

    // Look up the model instance for the requested item
    const model = getModelByType(readSession, itemType, itemID);


    // We of course will be updating our "current" relational data
    let writeSession = orm.session(destinationEntities);

    const ModelClass = writeSession[itemType];

    if(ModelClass.hasId(itemID)) {
        // Look up the original Model instance for the top item
        const existingItem = ModelClass.withId(itemID);

        if(existingItem.updateFrom) {
            // Each model class should know how to properly update itself and its
            // relations from another model of the same type.  Ask the original model to
            // update itself based on the "work-in-progress" model. Redux-ORM will apply
            // those changes as we go, and update `session.state` immutably.
            existingItem.updateFrom(model);
        }
    }
+    else {
+        const itemContents = model.toJSON();
+        ModelClass.parse(itemContents);
+    }

    // Return the updated "current" relational data.
    return writeSession.state;
}

+export function editItemNew(state, payload) {
+   const editingEntities = selectEditingEntities(state);
+
+   const updatedEditingEntities = createEntity(editingEntities, payload);
+   return updateEditingEntitiesState(state, updatedEditingEntities);
+}

const editingFeatureReducer = createReducer({}, {
    [EDIT_ITEM_EXISTING] : editItemExisting,
+   [EDIT_ITEM_NEW] : editItemNew,
    [EDIT_ITEM_UPDATE] : editItemUpdate,
    [EDIT_ITEM_APPLY] : editItemApply,
    [EDIT_ITEM_STOP] : editItemStop,
    [EDIT_ITEM_RESET] : editItemReset,
});

export default editingFeatureReducer;

The EDIT_ITEM_NEW action payload will contain {itemType, itemID, newItemAttributes}. Because we've been consistent about naming those fields in our other action types, the case reducer can reuse our existing helper methods for basic entity CRUD, which makes this easy to implement.

Also, we already have the ability to parse in plain JSON representations of our model classes, and are using that as the transfer mechanism to copy values from entities to editedEntities. We can easily add that logic in here so that saving new items creates them in entities.

Generating New Pilot Entries

The next step is to actually generate a new Pilot entry and dispatch EDIT_ITEM_NEW with the plain JSON representation of that pilot entry. This brings up several things we need to think about.

First, we need to have unique IDs for each Pilot entry. Thus far we've simply used hardcoded integers in our sample data, but we're going to need to generate IDs ourselves here for a couple reasons. Redux-ORM does have support for auto-incrementing numerical IDs based on the max ID value in a "table", but that won't work with our editing approach - the editingEntities.Pilots table would have no existing items, so it would likely give us a 0 or a 1 as the ID every time. We could calculate the numerical ID based on the contents of state.entities, but really, relying on the existing entries is not a great approach. It's better if we actually generate unique IDs.

However, this also has some problems. Generating unique IDs involves use of random numbers. While there's nothing preventing you from generating random numbers in a Redux reducer, doing so makes that reducer "impure", and it will return inconsistent output. A Redux reducer should never contain randomness - it should always be handled outside the reducer.

For our purposes, we can handle this by generating new pilot IDs in our action creator. If we needed to actually generate random numbers, there's a couple approaches you can use. I won't cover those here, but see the posts Roll the Dice: Random Numbers in Redux and Random in Redux for solutions and examples.

Another big question is where the logic for default model attribute values should live, and how to override those defaults when a new model instance is created. I've chosen to define a plain object of attributes in the same file as the model class, add a static generate() function to the model class, and merge together the default attributes and any attributes provided by the caller.

Finally, we're running into an issue with an optimization we applied earlier. We have a memoized getEntitiesSession selector that ensures we only create a single Redux-ORM Session instance for each update to the state.entities slice, just so we aren't creating separate Session instances every time a connected component's mapState function re-runs. If we were to use that selector and get that "shared" Session instance, then use that to generate a new Pilot instance, the shared Session would swap out its session.state field with the updated data. That could potentially cause problems, so we really want to avoid reusing that Session in this process. My solution is to create a getUnsharedEntitiesSession selector that just creates a new Session, which can then be safely used for manipulating data instead of just reading values.

Commit 0af9785: Implement logic to add and edit a new pilot

features/entities/entitySelectors.js

+export const getUnsharedEntitiesSession = (state) => {
+   const entities = selectEntities(state);
+   return orm.session(entities);
+}

features/pilots/Pilot.js

+const defaultAttributes = {
+   name : "New Pilot",
+   rank : "Private",
+   gunnery : 4,
+   piloting : 5,
+   age : 25,
+};

export default class Pilot extends Model {
+   static generate(newAttributes = {}) {
+       const combinedAttributes = {
+           ...defaultAttributes,
+           ...newAttributes,
+       };
+
+       return this.create(combinedAttributes);
+   }
}

features/pilots/pilotsActions.js

+import cuid from "cuid";

import {
    editExistingItem,
+    editNewItem,
    applyItemEdits,
    stopEditingItem
} from "features/editing/editingActions";

import {selectCurrentPilot, selectIsEditingPilot} from "./pilotsSelectors";
+import {getUnsharedEntitiesSession} from "features/entities/entitySelectors";

+export function addNewPilot() {
+   return (dispatch, getState) => {
+       const session = getUnsharedEntitiesSession(getState());
+       const {Pilot} = session;
+
+       const id = cuid();
+
+       const newPilot = Pilot.generate({id});
+
+       const pilotContents = newPilot.toJSON();
+
+       dispatch(editNewItem("Pilot", id, pilotContents));
+       dispatch(selectPilot(id));
+       dispatch({type : PILOT_EDIT_START});
+   }
=}

Way back at the start of the series, we added the cuid module for generating IDs. We can finally make use of that here.

Our addNewPilot() thunk first creates a Session instance we can use for modifying data. We generate a new ID value, then pass the ID as a field to Pilot.generate(), which returns a Pilot instance. We can serialize that to a plain JS object, and dispatch the actions to edit the item, mark it as selected, and update our state to reflect that we're currently editing a pilot.

Pilot Form Updates

With the logic in place, we need to actually add some UI to call addNewPilot(). We'll add a new section below the <PilotDetails> form with a button that lets us add and start editing a new pilot.

Commit 179cc52: Add an "Add New Pilot" button

features/pilots/PilotDetails/PilotCommands.jsx

import React from "react";
import {connect} from "react-redux";
import {Button} from "semantic-ui-react";

import {selectIsEditingPilot} from "../pilotsSelectors";
import {addNewPilot} from "../pilotsActions";

const mapState = (state) => {
    const isEditingPilot = selectIsEditingPilot(state);

    return {isEditingPilot};
}

const buttonWidth = 140;

const actions = {addNewPilot};

const PilotCommands = (props) => (
    <Button
        primary
        disabled={props.isEditingPilot}
        type="button"
        onClick={props.addNewPilot}
        style={{width : buttonWidth, marginRight : 10}}
    >
        Add New Pilot
    </Button>
);

export default connect(mapState, actions)(PilotCommands);

features/pilots/Pilots/Pilots.jsx

import PilotsList from "../PilotsList";
import PilotDetails from "../PilotDetails";
+import PilotCommands from "../PilotDetails/PilotCommands";


export default class Pilots extends Component {
    render() {
    // skip other rendering code
                         <Segment >
                            <PilotDetails />
                        </Segment>
+                       <Segment>
+                           <PilotCommands />
+                       </Segment>
                    </Grid.Column>
                </Grid>
            </Segment>
    }

And now we can hit "Add New Pilot", edit the details, save the edits, and see the pilot added to the list:

Fixing Pilot Selection Logic

There's one issue left from the current code. If we click "Add New Pilot" and then click "Cancel Edits", the "Start Editing" button is still enabled. This is because we still have state.pilots.selectedPilot set to the ID of the generated pilot and that isn't getting cleared out when we hit Cancel, so it thinks a valid pilot is still selected. We really need to clear the selection if we're canceling the edit.

There's probably a few ways we could handle this. For now, we'll implement the behavior on the action creation side, by checking to see if it's a new pilot when we stop editing.

In addition, the logic for the stopEditingPilot() and cancelEditingPilot() thunks is looking pretty similar. We can consolidate that logic into a single function with a flag that indicates whether we should apply the edits or not.

Commit 8ceeece: Extract common "stop editing pilot" logic

features/pilots/pilotsActions.js


import {selectCurrentPilot, selectIsEditingPilot} from "./pilotsSelectors";
-import {getUnsharedEntitiesSession} from "features/entities/entitySelectors";
import {getEntitiesSession, getUnsharedEntitiesSession} from "features/entities/entitySelectors";

+export function handleStopEditingPilot(applyEdits = true) {
+   return (dispatch, getState) => {
+       const currentPilot = selectCurrentPilot(getState());
+
+       // Determine if it's a new pilot based on the "current" slice contents
+       const session = getEntitiesSession(getState());
+       const {Pilot} = session;
+
+       const isNewPilot = !Pilot.hasId(currentPilot);
+
+       dispatch({type : PILOT_EDIT_STOP});
+
+       if(applyEdits) {
+           dispatch(applyItemEdits("Pilot", currentPilot));
+       }
+
+       dispatch(stopEditingItem("Pilot", currentPilot));
+
+       if(isNewPilot) {
+           dispatch({type : PILOT_SELECT, payload : {currentPilot : null}});
+       }
+   }
+}


export function stopEditingPilot() {
    return (dispatch, getState) => {
-       const currentPilot = selectCurrentPilot(getState());
-
-       dispatch({type : PILOT_EDIT_STOP});
-       dispatch(applyItemEdits("Pilot", currentPilot));
-       dispatch(stopEditingItem("Pilot", currentPilot));
+       dispatch(handleStopEditingPilot(true));
    }
}

export function cancelEditingPilot() {
    return (dispatch, getState) => {
-       const currentPilot = selectCurrentPilot(getState());
-
-        dispatch({type : PILOT_EDIT_STOP});
-        dispatch(stopEditingItem("Pilot", currentPilot));
+        dispatch(handleStopEditingPilot(false));
    }
}

We can determine if it's a new pilot by seeing whether the currentPilot ID value actually exists in the "current" Pilot table. From there, we clear out the "editing pilot" flag, save the edits if appropriate, clear out the entry from the "editing" slice, and clear the selection if necessary.

Reorganizing the Unit Info Display

Most of our work so far has focused on the "Pilots" tab, although we've worked some on the "Unit Info" and "Mechs" tabs. Meanwhile, the "Unit Organization" tab has been left alone since we put together the initial layout. In preparation for our next major chunk of feature work, we're going to consolidate things by moving the tree from the "Unit Organization" tab into our main "Unit Info" tab.

We'll start by extracting a separate <UnitInfoForm> component from the existing <UnitInfo> panel.

Commit d2ac9f6: Extract a separate UnitInfoForm component

features/unitInfo/UnitInfo/UnitInfo.jsx

import React, {Component} from "react";
import {
    Segment
} from "semantic-ui-react";

import UnitInfoForm from "./UnitInfoForm";

class UnitInfo extends Component {

    render() {
        return (
            <Segment attached="bottom">
                <UnitInfoForm />
            </Segment>
        );
    }
}

export default UnitInfo;

Code-wise, this was really more like renaming UnitInfo.jsx to UnitInfoForm.jsx, removing a couple of components from its render method, and then creating a new UnitInfo.jsx file to replace it.

Commit 0ec6f08: Move UnitOrganization under UnitInfo and remove unused tab

app/layout/App.js

import UnitInfo from "features/unitInfo/UnitInfo";
import Pilots from "features/pilots/Pilots";
import Mechs from "features/mechs/Mechs";
-import UnitOrganization from "features/unitOrganization/UnitOrganization";
import Tools from "features/tools/Tools";
import ModalManager from "features/modals/ModalManager";

class App extends Component {
    render() {
        const tabs = [
            {name : "unitInfo", label : "Unit Info", component : UnitInfo,},
            {name : "pilots", label : "Pilots", component : Pilots,},
            {name : "mechs", label : "Mechs", component : Mechs,},
-           {name : "unitOrganization", label : "Unit Organization", component : UnitOrganization},
            {name : "tools", label : "Tools", component : Tools},
        ];

Next, we remove the <UnitOrganization> component so it's no longer rendered as a separate tab, and move its file inside features/unitInfo.

Then, we can show <UnitOrganization> inside of the revamped <UnitInfo> panel:

Commit d18161b: Add UnitOrganization component to UnitInfo panel

import React, {Component} from "react";
import {
    Segment,
    Grid,
    Header,
} from "semantic-ui-react";

import UnitOrganization from "../UnitOrganization";
import UnitInfoForm from "./UnitInfoForm";

class UnitInfo extends Component {
    render() {
        return (
            <Segment>
                <Grid>
                    <Grid.Column width={10}>
                        <Header as="h3">Unit Table of Organization</Header>
                        <Segment>
                            <UnitOrganization />
                        </Segment>
                    </Grid.Column>
                    <Grid.Column width={6}>
                        <Header as="h3">Edit Unit</Header>
                        <Segment>
                            <UnitInfoForm />
                        </Segment>
                    </Grid.Column>
                </Grid>
            </Segment>
        );
    }
}

export default UnitInfo;

We'll consolidate the <UnitInfoForm> "Affiliation" and "Color" fields into one row, similar to what we did with the <PilotDetailsForm> earlier.

Commit 3c1be29: Improve UnitInfoForm layout

And finally, we'll rename <UnitOrganization> to <UnitOrganizationTree>.

Commit 361a1aa: Rename UnitOrganization to UnitOrganizationTree

Note that since we've started this project, <UnitInfo> has gone from being unconnected, to connected, and is now unconnected again. This is one of the reasons why I dislike specifically splitting code into containers and components folders, or having separate SomeComponent and SomeComponentContainer files - it's very possible that a component's usage could change over time. Now, I will say that there's a difference between "app-specific" components and truly generic components. We do have a common/components folder in this project, and it's very reasonable to put completely generic components in there. But, for anything that's actually related to app concepts, I'd rather put it in an appropriate feature folder and just keep it there, regardless of whether it's connected or not. (I have a saved Reactiflux chat log where I discuss my thoughts on Redux "container" components and structuring.)

Loading Nested Unit Data

Our data schema so far has been pretty simple. The sampleData.js file currently looks like this:

const sampleData = {
    unit : {
        name : "Black Widow Company",
        affiliation : "wd",
        color : "black"
    },
    pilots : [
        {
            id : 1,
            name : "Natasha Kerensky",
            rank : "Captain",
            gunnery : 2,
            piloting : 2,
            age : 52,
            mech : 1,
        },
   ],
   designs : [
        {
            id : "STG-3R",
            name : "Stinger",
            weight : 20,
        },
   ],
   mechs : [
        {
            id : 1,
            type : "WHM-6R",
            pilot : 1,
        },
   ]
}

We've got flat arrays for pilots, designs, and mechs, and we only have a single unit defined. The arrays themselves already reference each other by foreign key IDs, so there's not much processing needed.

It's time to start adding some additional depth to this schema. As mentioned in Part 0 and Part 3, in the Battletech game universe mechs are normally organized into "Lances" of 4 mechs, and "Companies" of 3 lances. We're going to apply that organizational pattern to our current sample data.

Long-term, it would make sense to support loading multiple units, and possibly having the pilots and mechs defined in a nested data tree that matches the organizational structure. We're not going to go quite that far yet. For now, we're going to start treating the unit as another model type in our database, and store the pilots and mechs arrays inside of the unit definition. We'll also add a lances array that stores which pilots belong to which lances. The designs array will stay outside the unit field in the data, because mech designs are universal across factions and not specific to a unit.

This also means that we're going to need to make our "parsing" logic a bit more complex, because it will need to handle the now-nested data correctly.

Parsing Unit Entries

The first step is to create our Unit model.

Commit 87a5f9e: Add a Unit model class

features/unitInfo/Unit.js

import {Model, many, attr} from "redux-orm";

export default class Unit extends Model {
    static modelName = "Unit";

    static fields = {
        id : attr(),
        name : attr(),
        affiliation : attr(),
        color : attr(),
        pilots : many("Pilot"),
        mechs : many("Mech")
    };

    static parse(unitData) {
        const {Pilot, Mech} = this.session;

        const parsedData = {
            ...unitData,
            pilots : unitData.pilots.map(pilotEntry => Pilot.parse(pilotEntry)),
            mechs : unitData.mechs.map(mechEntry => Mech.parse(mechEntry)),
        };

        return this.upsert(parsedData);
    }
}

There's a few things to note in this file.

First, for other model classes so far, I've tended to write the fields definition as static get fields() {}, and usually defined the modelName field separately, like Pilot.modelName = "Pilot";. This is somewhat for historical reasons - I first began using Redux-ORM before I had the Class Properties syntax available in my project, so I used getters and plain assignments instead. In theory, all three of these should be equivalent:

// 1) Field declarations added to the class later
class Pilot extends Model {}
Pilot.fields = {
  id : attr()
};

// 2) Static getters
class Pilot extends Model {
    static get fields() {
        return {
            id : attr()
        };
    }
}

// 3) The Stage 3 Class Properties syntax
class Pilot extends Model {
    static fields = {
        id : attr()
    };
}

Going forward, I'll stick with the Class Properties syntax, and define fields and modelName inside the class body.

Next, for the first time we're using Redux-ORM's many() relation. This will set up "through tables" that map together the related IDs from both tables. In this case, we'll have auto-generated model types called UnitPilots and UnitMechs, and a sample UnitPilots entry might look like {id : 2, fromUnitId : 1, toPilotId : 3}. If we have an instance of a Unit, the unitModel.pilots field will be a Redux-ORM QuerySet that can be turned into an array of Pilot models or plain JS objects.

Finally, notice that the Unit.parse() method is more complicated than the others we've seen thus far. Since our other classes haven't had to deal with any nesting, our other parse() methods have just looked like return this.create(data) or return this.upsert(data). Now, we need to continue recursing down through the nested data to parse any Pilot or Mech entries.

Our data loading reducer already runs pilots.map(pilotEntry => Pilot.parse(pilotEntry)), and the same for mechs. We can do that here instead, but there's a bit of a trick involved. Redux-ORM creates custom subclasses of your model classes every time you instantiate a Session. Those subclasses are attached to the Session instance, and any operations involving those subclasses are applied to that specific Session. So, we can't just do import Pilot from "features/pilots/Pilot" here - we need to get a reference to the specific Pilot subclass on the current Session instance related to where Unit.parse() is being called.

Fortunately, the actual solution is pretty easy. When this code runs, this inside the static method will refer to the Session-specific subclass of Unit, and Redux-ORM makes the Session available as this.session. So, in the same way that we did const {Pilot} = session over in the reducer, we can do const {Pilot} = this.session here inside the static class method.

From there, we call Pilot.parse() and Mech.parse() as we map over the arrays. Those create the proper entries inside the Session, and then passing those newly-created model entries into this.upsert() instead of the original plain objects will tell Redux-ORM to set up the associations between the Unit and those related models.

With that done, we can restructure the sample data to match:

Commit 58b9115: Restructure sample data to include pilots/mechs inside unit definition

And then we need to update the data loading reducer to call Unit.parse() instead of parsing the individual pilots and mechs directly:

Commit c8cabf2: Update data loading reducer to parse nested unit definition

app/reducers/entitiesReducer.js

export function loadData(state, payload) {
    // Create a Redux-ORM session from our entities "tables"
    const session = orm.session(state);
    // Get a reference to the correct version of model classes for this Session
-   const {Pilot, MechDesign, Mech} = session;
+   const {Unit, Pilot, Mech, MechDesign} = session;

-   const {pilots, designs, mechs} = payload;
+   const {unit, designs} = payload;

    // Clear out any existing models from state so that we can avoid
    // conflicts from the new data coming in if data is reloaded
-   [Pilot, Mech, MechDesign].forEach(modelType => {
+   [Unit, Pilot, Mech, MechDesign].forEach(modelType => {
        modelType.all().toModelArray().forEach(model => model.delete());
    });

    // Immutably update the session state as we insert items
+    Unit.parse(unit);
-    pilots.forEach(pilot => Pilot.parse(pilot));

    designs.forEach(design => MechDesign.parse(design));
-    mechs.forEach(mech => Mech.parse(mech));

    // Return the new "tables" object containing the updates
    return session.state;
}

Extracting Factions

The list of factions in the "Affiliation" dropdown is currently hardcoded, and also in a format specific to the SUI-React <Dropdown> component. We can turn those into a Faction model and extract those from the <UnitInfoForm> component.

First, we'll create the model class and the sample data, then update the data loading reducer to parse in the Faction entries.

Commit 29eb97b: Add a Faction model class

features/unitInfo/Faction.js

import {Model, attr} from "redux-orm";

export default class Faction extends Model {
    static modelName = "Faction";

    static fields = {
        id : attr(),
        name : attr(),
    };

    static parse(factionData) {
        return this.upsert(factionData);
    }
}

Commit da9dcbf: Add factions to sample data and parse them

data/sampleData.js

    designs : [
        // ommitted
    ],
+   factions : [
+       {id : "cc", name : "Capellan Confederation"},
+       {id : "dc", name : "Draconis Combine"},
+       {id : "elh", name : "Eridani Light Horse"},
+       {id : "fs", name : "Federated Suns"},
+       {id : "fwl", name : "Free Worlds League"},
+       {id : "hr", name : "Hansen's Roughriders"},
+       {id : "lc", name : "Lyran Commonwealth"},
+       {id : "wd", name : "Wolf's Dragoons"},
+   ]
};

app/reducers/entitiesReducer.js

export function loadData(state, payload) {
    // Create a Redux-ORM session from our entities "tables"
    const session = orm.session(state);
    // Get a reference to the correct version of model classes for this Session
-   const {Unit, Pilot, Mech, MechDesign} = session;
+   const {Unit, Faction, Pilot, Mech, MechDesign} = session;

-   const {unit, designs} = payload;
+   const {unit, factions, designs} = payload;

    // Clear out any existing models from state so that we can avoid
    // conflicts from the new data coming in if data is reloaded
-   [Unit, Faction, Pilot, Mech, MechDesign].forEach(modelType => {
+   [Unit, Faction, Pilot, Mech, MechDesign].forEach(modelType => {
        modelType.all().toModelArray().forEach(model => model.delete());
    });

    // Immutably update the session state as we insert items
    Unit.parse(unit);

+    factions.forEach(faction => Faction.parse(faction));
    designs.forEach(design => MechDesign.parse(design));

    // Return the new "tables" object containing the updates
    return session.state;
}

Next, now that factions are also a model type, our Unit.affiliation field needs to change from a simple attribute to a foreign key reference to the Faction table.

Commit 740e602: Update Unit affiliation to point to Factions

features/unitInfo/Unit.js

-import {Model, many, attr} from "redux-orm";
+import {Model, many, fk, attr} from "redux-orm";


export default class Unit extends Model {
    static modelName = "Unit";

    static fields = {
        id : attr(),
        name : attr(),
-       affiliation : attr(),
+       affiliation : fk("Faction"),
        color : attr(),
        pilots : many("Pilot"),
        mechs : many("Mech")
    };

Now we can update the <UnitInfoForm> component to read the list of factions from Redux and display them.

Commit 50aaf10: Use faction entries to populate affiliation dropdown

features/unitInfo/UnitInfo/UnitInfoForm.jsx

-const FACTIONS = [
-   {value : "cc", text : "Capellan Confederation"},
-   {value : "dc", text : "Draconis Combine"},
-   {value : "elh", text : "Eridani Light Horse"},
-   {value : "fs", text : "Federated Suns"},
-   {value : "fwl", text : "Free Worlds League"},
-   {value : "hr", text : "Hansen's Roughriders"},
-   {value : "lc", text : "Lyran Commonwealth"},
-   {value : "wd", text : "Wolf's Dragoons"},
-];

-const mapState = (state) => ({
-    unitInfo : selectUnitInfo(state),
-});

+import {getEntitiesSession} from "features/entities/entitySelectors";

+const mapState = (state) => {
+    const session = getEntitiesSession(state);
+    const {Faction} = session;
+
+    const factions = Faction.all().toRefArray();
+
+    const unitInfo = selectUnitInfo(state);
+
+    return {factions,unitInfo};
+};

class UnitInfoForm extends Component {
    render() {
-       const {unitInfo, updateUnitInfo} = this.props;
+       const {unitInfo, updateUnitInfo, factions} = this.props;
        const {name, affiliation, color} = unitInfo;

+       const displayFactions = factions.map(faction => {
+           return {
+               value : faction.id,
+               text : faction.name
+           };
+       });

Connecting the Unit Model

The <UnitInfoForm> is now in an awkward situation. It's still showing data from state.unitInfo, and state.unitInfo in turn contains the entire unit section from the sample data. If you inspect the current state tree, you'd see that we actually have all of the data arrays nested in there, like state.unitInfo.pilots.

In addition, the inputs are also hooked up to apply updates to the state.unitInfo section. We really need to change that so that the form displays the values from the Unit model we've loaded in via Redux-ORM, and the updates are applied there as well.

For now, we'll assume that we only have a single Unit model in memory. (Hopefully someday we'll get far enough that we can load multiple units at once, in which case that assumption will change, but it'll work for now.)

We'll start by changing the form to read the current Unit entry and display its values instead of from state.unitInfo.

Commit b08a248: Update unit info form to display current Unit details

features/unitInfo/unitInfoSelectors.js

import {createSelector} from "reselect";
import {getEntitiesSession} from "features/entities/entitySelectors";

export const selectUnitInfo = state => state.unitInfo;

export const selectCurrentUnitInfo = createSelector(
    getEntitiesSession,
    (session) => {
        const {Unit} = session;
        const currentUnitModel = Unit.all().first();

        let currentUnitInfo = null;

        if(currentUnitModel) {
            currentUnitInfo = currentUnitModel.ref;
        }

        return currentUnitInfo;
    }
)

We'll add a selector that knows how to read the plain JS object for the current Unit. Since we assume that there's only one unit at a time, Unit.all().first() should return us the model instance for that one unit, or undefined if it doesn't exist. Assuming the unit instance does exist, we can grab the underlying plain JS object reference from the model instance.

features/unitInfo/UnitInfo/UnitInfoForm.jsx

import {getEntitiesSession} from "features/entities/entitySelectors";

-import {selectUnitInfo} from "../unitInfoSelectors";
+import {selectCurrentUnitInfo} from "../unitInfoSelectors";
import {updateUnitInfo, setUnitColor} from "../unitInfoActions";


const mapState = (state) => {
    const session = getEntitiesSession(state);
    const {Faction} = session;

    const factions = Faction.all().toRefArray();
    
-   const unitInfo = selectUnitInfo(state);
+   const unitInfo = selectCurrentUnitInfo(state);

    return { factions, unitInfo };
};

class UnitInfoForm extends Component {
    render() {
        const {unitInfo, factions} = this.props;
+       const isDisplayingUnit = Boolean(unitInfo);
+       let name = "", affiliation = null, color = null;
+
+       if(isDisplayingUnit) {
+           ({name, affiliation, color} = unitInfo);
+       }

        // omit other rendering code
                        <input
                            placeholder="Name"
                            name="name"
+                           disabled={!isDisplayingUnit}
                        />

Within the <UnitInfoForm>, we switch up which selector we're using to retrieve the unit info values. Note that we could have used the same selector name and switched the way it was retrieving data, instead. This is an example of keeping the changing data storage abstracted from the component itself.

Inside the component, we're adding some additional logic to properly disable the input fields if there's no unit loaded into memory. If you look inside the if clause, there's a neat little trick you can use to do destructuring assignment to variables that have already been declared using let or var - put parentheses around the entire statement. We could also have possibly handled the isDisplayingUnit check by doing const {unitInfo = {}} = this.props, and checking to see if it actually had any fields inside.

Now things get a bit interesting. Currently, our unitInfoReducer is a slice reducer that handles updates to state.unitInfo. What we need instead is a feature reducer that handles updates to the Unit entry that's stored in state.entities.Unit. Fortunately, the rewrite isn't overly complicated, thanks to the other reducer utility functions we already have available.

Commit 2a6592b: Rewrite unit info reducer to update current Unit model

features/unitInfo/unitInfoReducer.js

-import {createConditionalSliceReducer} from "common/utils/reducerUtils";
-import {DATA_LOADED} from "features/tools/toolConstants";
+import orm from "app/schema";
+import {createConditionalSliceReducer} from "common/utils/reducerUtils";

import {
    UNIT_INFO_UPDATE,
    UNIT_INFO_SET_COLOR,
} from "./unitInfoConstants";

-const initialState = {
-   name : "N/A",
-   affiliation : "",
-   color : "blue"
-};

-function dataLoaded(state, payload) {
-   const {unit} = payload;
-   return unit;
-}

function updateUnitInfo(state, payload) {
-   return {
-       ...state,
-       ...payload,
-   };
+   const session = orm.session(state);
+   const {Unit} = session;
+
+   const currentUnit = Unit.all().first();
+
+   if(currentUnit) {
+       currentUnit.update(payload);
+   }
+
+   return session.state;
}

function setUnitColor(state, payload) {
    const {color} = payload;
    
-   return {
-       ...state,
-       color
-   };
+   const session = orm.session(state);
+   const {Unit} = session;
+
+   const currentUnit = Unit.all().first();
+
+   if(currentUnit) {
+       currentUnit.color = color;
+   }

+   return session.state;
}

-export default createReducer(initialState, {
-   [DATA_LOADED] : dataLoaded,
+export default createConditionalSliceReducer("entities", {
    [UNIT_INFO_UPDATE] : updateUnitInfo,
    [UNIT_INFO_SET_COLOR] : setUnitColor,
});

The diff might be a bit difficult to read, so here's what we did:

  • We removed the DATA_LOADED constant and case reducer that just copied over the unit section from the sample data
  • Both updateUnitInfo() and setUnitColor() case reducers now expect that state is actually the entire state.entities slice. They create a Session instance, look up the Unit model, and update the appropriate fields before returning the updated state.entities data.
  • The exported reducer now uses createConditionalSliceReducer() so that our case reducers only see the state.entities slice, and only if it's one of the action types this reducer knows how to handle.

app/reducers/rootReducer.js

const combinedReducer = combineReducers({
    entities : entitiesReducer,
    editingEntities : editingEntitiesReducer,
-   unitInfo : unitInfoReducer,
    pilots : pilotsReducer,
    mechs : mechsReducer,
    tabs : tabReducer,
    modals : modalsReducer,
    contextMenu : contextMenuReducer
});

const rootReducer = reduceReducers(
    combinedReducer,
    entityCrudReducer,
    editingFeatureReducer,
+   unitInfoReducer,
);

With the unitInfoReducer rewritten, we just need to update our root reducer so that we now longer have a state.unitInfo slice, and instead have the unitInfoReducer added to the sequential top-level "feature reducers" list.

At this point you might be thinking: "Hey, don't we already have a bunch of logic for updating models in the store?" Indeed, we do. However, as mentioned earlier, those assume that the model has been copied to state.editingEntities first. We'll probably tackle that in the not-too-distant future, but for now it's simpler to let this be a special case where the edits are applied directly to the data that's in state.entities.

Displaying Unit Data as a Tree

We're now ready to do something interesting and useful with the "Unit Table of Organization" tree. We're going to connect the tree so that it displays the lances in the unit and the pilots in each lance, based on the actual data in the Redux store, and we're going to load that data as a nested structure from our sample data.

Loading Lance Data

Again, our first step is to add a Lance model.

Commit 5485b10: Add Lance model

features/unitInfo/Lance.js

import {Model, many, attr} from "redux-orm";

export default class Lance extends Model {
    static modelName = "Lance";

    static fields = {
        id : attr(),
        name : attr(),
        pilots : many("Pilot")
    };

    static parse(lanceData) {
        return this.upsert(lanceData);
    }
}

Then we'll update the sample data to include info on the lance names and which pilots they contain, and parse that in as part of Unit.

Commit 14cd310: Add lance data and parse lances when loading a Unit

data/sampleData.js

const sampleData = {
    unit : {
        id : 1,
        name : "Black Widow Company",
        affiliation : "wd",
        color : "black",
-       lances : [],
+       lances : [
+           {
+               id : 1,
+               name : "Command Lance",
+               pilots : [
+                   1, 2, 3, 4
+               ]
+           },
+           {
+               id : 2,
+               name : "Fire Lance",
+               pilots : [
+                   5, 6, 7, 8
+               ]
+           },
+           {
+               id : 3,
+               name : "Recon Lance",
+               pilots : [
+                   9, 10, 11, 12
+               ]
+           }
+       ],

features/unitInfo/Unit.js

export default class Unit extends Model {
    static modelName = "Unit";

    static fields = {
        id : attr(),
        name : attr(),
        affiliation : fk("Faction"),
        color : attr(),
+       lances : many("Lance"),
        pilots : many("Pilot"),
        mechs : many("Mech")
    };

    static parse(unitData) {
-       const {Pilot, Mech} = this.session;
+       const {Pilot, Mech, Lance} = this.session;

        const parsedData = {
            ...unitData,
+           lances : unitData.lances.map(lanceEntry => Lance.parse(lanceEntry)),
            pilots : unitData.pilots.map(pilotEntry => Pilot.parse(pilotEntry)),
            mechs : unitData.mechs.map(mechEntry => Mech.parse(mechEntry)),
        };

        return this.upsert(parsedData);
    }
}

Connecting the Unit Organization Tree

We already moved UnitOrganization.jsx from features/unitOrganization to features/unitInfo. Now we'll move it into its own folder, because we're going to start extracting more components out of its current contents.

Commit 54fa32fb: Move UnitOrganizationTree into its own folder

Looking at the contents of the <UnitOrganizationTree> component, we can see a lot of repetition in the structure. A good place to start would be extracting a separate component that displays a single pilot entry.

Commit 7385be9: Add a LancePilot component

features/unitInfo/UnitOrganizationTree/LancePilot.jsx

import React from "react";
import {connect} from "react-redux";

import {
    List,
} from "semantic-ui-react";

import {getEntitiesSession} from "features/entities/entitySelectors";

const mapState = (state, ownProps) => {
    const session = getEntitiesSession(state);
    const {Pilot} = session;

    let pilot, mech;

    if(Pilot.hasId(ownProps.pilotID)) {
        const pilotModel = Pilot.withId(ownProps.pilotID);

        pilot = pilotModel.ref;

        if(pilotModel.mech) {
            mech = pilotModel.mech.type.ref;
        }
    }

    return {pilot, mech};
};

const UNKNOWN_PILOT =  {name : "Unknown", rank : ""}
const UNKNOWN_MECH = {id : "N/A", name : ""};

const LancePilot = ({pilot = UNKNOWN_PILOT, mech = UNKNOWN_MECH}) => {
    const {name, rank} = pilot;
    const {id : mechModel, name : mechName} = mech;

    return (
        <List.Item>
            <List.Icon name="user" />
            <List.Content>
                <List.Header>{rank} {name} - {mechModel} {mechName}</List.Header>
            </List.Content>
        </List.Item>
    )
};

export default connect(mapState)(LancePilot);

We connect this component in the same way that we connected the <PilotsListRow> component previously. The connected component will receive a pilotID prop, and use that to look up the appropriate Pilot model from the store. Assuming that a pilot by that ID exists, it will also look up the related Mech entry so that we can display both the name of the pilot and the type of mech assigned to that pilot.

Since we have a fixed list of pilot entries right now, we can rewrite <UnitOrganizationTree> to render individual <LancePilot> components with fixed pilot IDs:

Commit 01e506b: Show hardcoded lance members by ID in the Unit TOE tree

features/unitInfo/UnitOrganizationTree/UnitOrganizationTree.jsx

       <List.Content>
            <List.Header>Command Lance</List.Header>
            <List.List>
-               <List.Item>
-                   <List.Icon name="user" />
-                   <List.Content>
-                       <List.Header>Cpt. Natasha Kerensky - WHM-6R Warhammer</List.Header>
-                   </List.Content>
-               </List.Item>
+               <LancePilot pilotID={1} />
                // etc

If we refresh the page, we'll see 12 rows of Unknown - N/A lines inside of the tree until we load the pilot data. That's sort of good, because we know that the connected components are rendering and safely handling the case where the data doesn't exist.

The next step is to do the same thing for the lance entries. We'll extract the UI layout into a <Lance> component, and have that component render a list of <LancePilot> components based on the associated pilot IDs for that Lance.

Commit b2cba10: Add a Lance component

features/unitInfo/UnitOrganizationTree/Lance.jsx

import React from "react";
import {connect} from "react-redux";

import {
    List,
} from "semantic-ui-react";

import {getEntitiesSession} from "features/entities/entitySelectors";

import LancePilot from "./LancePilot";

const mapState = (state, ownProps) => {
    const session = getEntitiesSession(state);
    const {Lance} = session;

    let lance, pilots;

    if(Lance.hasId(ownProps.lanceID)) {
        const lanceModel = Lance.withId(ownProps.lanceID);

        lance = lanceModel.ref;
        pilots = lanceModel.pilots.toRefArray().map(pilot => pilot.id);
    }

    return {lance, pilots};
};

const UNKNOWN_LANCE =  {name : "Unknown"}

const Lance = ({lance = UNKNOWN_LANCE, pilots = []}) => {
    const {name} = lance;

    const lancePilots = pilots.map(pilotID => <LancePilot key={pilotID} pilotID={pilotID} />);

    return (
        <List.Item>
            <List.Icon name="cube" />
            <List.Content>
                <List.Header>{name}</List.Header>
                <List.List>
                    {lancePilots}
                </List.List>
            </List.Content>
        </List.Item>
    )
};

export default connect(mapState)(Lance);

Pretty similar to the <LancePilot> component, except that we now look up the list of pilots associated to the lance and extract an array of their IDs as a separate prop.

With that component created, we can simplify <UnitOrganizationTree> further.

Commit d1d5a75: Show hardcoded lances by ID in the Unit TOE tree

features/unitInfo/UnitOrganizationTree/UnitOrganizationTree.jsx

    <List.Content>
        <List.Header>Black Widow Company</List.Header>
        <List.List>
-           <List.Item>
-               <List.Icon name="cube" />
-               <List.Content>
-                   <List.Header>Command Lance</List.Header>
-                   <List.List>
-                       <LancePilot pilotID={1} />
-                       <LancePilot pilotID={2} />
-                       <LancePilot pilotID={3} />
-                       <LancePilot pilotID={4} />
-                   </List.List>
-               </List.Content>
-           </List.Item>
+           <Lance lanceID={1} />

Again, a refresh of the page will show three "Unknown" entries in the tree, with no pilot children this time. Once we load the sample data, the entire tree should populate.

We're still showing a hardcoded list of lances, and that should really be driven based on the lances that are actually in the store.

Commit eca8838: Show lances in the Unit TOE tree based on current Unit entry contents

features/unitInfo/UnitOrganization/UnitOrganizationTree.jsx

import React from "react";
+import {connect} from "react-redux";

import {
    List,
} from "semantic-ui-react";

+import {getEntitiesSession} from "features/entities/entitySelectors";

import Lance from "./Lance";

+const mapState = (state) => {
+   const session = getEntitiesSession(state);
+   const {Unit} = session;
+
+   let lances;
+
+   const unitModel = Unit.all().first();
+
+   if(unitModel) {
+       lances = unitModel.lances.toRefArray().map(lance => lance.id);
+   }
+
+   return {lances};
+}

-const UnitOrganizationTree = () => {
+const UnitOrganizationTree = ({lances = []}) => {
+    const lanceEntries = lances.map(lanceID => <Lance key={lanceID} lanceID={lanceID} />);

    return (
        <List size="large">
            <List.Item>
                <List.Icon name="cubes" />
                <List.Content>
                    <List.Header>Black Widow Company</List.Header>
                    <List.List>
-                       <Lance lanceID={1} />
-                       <Lance lanceID={2} />
-                       <Lance lanceID={3} />
+                       {lanceEntries}
                    </List.List>
                </List.Content>
            </List.Item>
        </List>
    )
}

export default connect(mapState)(UnitOrganizationTree);

Now when we refresh the page, the tree is empty except for the parent tree list item with the title "Black Widow Company". Loading the sample data will fill out the entire tree.

There's one final update to make here. Let's have that parent tree node render the current name of the unit, and show the affiliation color and values as well, so that we can see them update as we edit them in the <UnitInfoForm>.

Commit cf8e19c: Show current unit detail values in the Unit TOE tree

features/unitInfo/UnitOrganizationTree/UnitOrganizationTree.jsx

const mapState = (state) => {
    const session = getEntitiesSession(state);
    const {Unit} = session;

-   let lances;
+   let unit, faction, lances;

    const unitModel = Unit.all().first();

    if(unitModel) {
+        unit = unitModel.ref;
+        faction = unitModel.affiliation.ref;
        lances = unitModel.lances.toRefArray().map(lance => lance.id);
    }

-   return {lances};
+   return {unit, faction, lances};
}

+const UNKNOWN_UNIT = {name : "Unknown"};

-const UnitOrganizationTree = ({lances = []}) => {
+const UnitOrganizationTree = ({unit = UNKNOWN_UNIT, faction = {}, lances = []}) => {
+   const {name, color} = unit;
+   const {name : factionName} = faction;

+   const colorBlock = <div
+       style={{
+           marginLeft : 10,
+           backgroundColor : color,
+           border : "1px solid black",
+           height : 20,
+           width : 40,
+       }}
+   />;

+   const displayText = factionName ? `${name} / ${factionName}` : name;

    const lanceEntries = lances.map(lanceID => <Lance key={lanceID} lanceID={lanceID} />);

    return (
        <List size="large">
            <List.Item>
                <List.Icon name="cubes" />
                <List.Content>
-                   <List.Header>Black Widow Company</List.Header>
+                   <List.Header style={{display : "flex"}}>{displayText} {colorBlock}</List.Header>
                    <List.List>
                        {lanceEntries}
                    </List.List>
                </List.Content>
            </List.Item>
        </List>
    )
}

Notice that our tree component structure works the same way as our connected list: connected parent components using lists of IDs to render child components, and connected child components reading their own data from the store using the ID.

This tree is relatively simple, and has a fixed size, but the general pattern could be applied to a truly recursive tree as well. In fact, there's already a treeview example in the Redux repo. For background, see the PR from Dan Abramov adding the treeview example. (It's also interesting to read the discussion on that PR, as that's where the official guidance and collective wisdom really transitioned from "connect one component at the top of the component tree" to "connect many components deeper for better performance".)

Let's take a final look at the updated Unit Organization Tree:

Final Thoughts

I'm really excited to be continuing with this series. We're slowly starting to increase the level of complexity and difficulty in these examples, and I've got plenty more topics I want to cover. In the next couple parts, I hope to show how to edit complex nested/relational data, and then finally cover asynchronous logic and side effects.

As always, comments, feedback, and suggestions are greatly appreciated!

Further Information


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

Was this post useful? If so, check out my email newsletter!
It's a weekly-ish list of selected interesting articles, libraries, and discussions from the React/Redux ecosystem, plus updates from the blog and things I've been working on. No muss, no fuss, just useful and interesting stuff :)

powered by TinyLetter


Author Avatar

Mark Erikson

Collector of interesting links, answerer of questions