Practical Redux, Part 11: Nested Data and Trees
This is a post in thePractical Redux series.
Intro 🔗︎
Last time in Part 10, we built modal dialog and context menu systems that were driven by Redux. This time, we're going toadd 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 atgithub.com/markerikson/project-minimek. The original WIP commits I made for this post can be seen inPR #14: Practical Redux Part 11 WIP, and the final "clean" commits can be seen in inPR #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
- Adding New Pilots
- Reorganizing the Unit Info Display
- Loading Nested Unit Data
- Displaying Unit Data as a Tree
- Final Thoughts
- Further Information
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 wepreviously 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 thereact-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.2There's one small code tweak we need to make, which is changingPortal 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.77As 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 runningyarn 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'sDropdown wants. We can turn that into a simple constants array, and then derive the display data as needed.
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 thestate.entities "current values" slice, and that editing an item should copy that item fromstate.entities to thestate.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 theentities slice.
We need some additional support for creating new entities directly into theeditingEntities 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 inentities, and tries to look up thatModel instance to apply the updates from the edited model. We need to update the save logic to handle this case as well.
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;TheEDIT_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 fromentities toeditedEntities. We can easily add that logic in here so that saving new items creates them inentities.
Generating New Pilot Entries 🔗︎
The next step is to actually generate a new Pilot entry and dispatchEDIT_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 - theeditingEntities.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 ofstate.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 postsRoll the Dice: Random Numbers in Redux andRandom 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 staticgenerate() 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 memoizedgetEntitiesSession selector that ensures we only create a single Redux-ORMSession instance for each update to thestate.entities slice, just so we aren't creating separateSession instances every time a connected component'smapState function re-runs. If we were to use that selector and get that "shared"Session instance, then use that to generate a newPilot instance, the sharedSession would swap out itssession.state field with the updated data. That could potentially cause problems, so we really want to avoid reusing thatSession in this process. My solution is to create agetUnsharedEntitiesSession selector that just creates a newSession, which can then be safely used for manipulating data instead of just reading values.
features/entities/entitySelectors.js
+export const getUnsharedEntitiesSession = (state) => {+ const entities = selectEntities(state);+ return orm.session(entities);+}+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 thecuid module for generating IDs. We can finally make use of that here.
OuraddNewPilot() thunk first creates aSession instance we can use for modifying data. We generate a new ID value, then pass the ID as a field toPilot.generate(), which returns aPilot 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 calladdNewPilot(). We'll add a new section below the<PilotDetails> form with a button that lets us add and start editing a new pilot.
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 havestate.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 thestopEditingPilot() andcancelEditingPilot() 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.
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 thecurrentPilot 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.
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 renamingUnitInfo.jsx toUnitInfoForm.jsx, removing a couple of components from its render method, and then creating a newUnitInfo.jsx file to replace it.
Commit 0ec6f08: Move UnitOrganization under UnitInfo and remove unused tab
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 insidefeatures/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.
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 intocontainers andcomponents folders, or having separateSomeComponent andSomeComponentContainer files - it's very possible that a component's usage could change over time. Now, Iwill say that there's a difference between "app-specific" components andtruly generic components. We do have acommon/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 havea 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. ThesampleData.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 inPart 0 andPart 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 goquite that far yet. For now, we're going to start treating the unit as another model type in our database, and store thepilots andmechs arrays inside of the unit definition. We'll also add alances array that stores which pilots belong to which lances. Thedesigns array will stay outside theunit 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 ourUnit model.
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 thefields definition asstatic get fields() {}, and usually defined themodelName field separately, likePilot.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 theseshould be equivalent:
// 1) Field declarations added to the class laterclass Pilot extends Model {}Pilot.fields = { id : attr()};// 2) Static gettersclass Pilot extends Model { static get fields() { return { id : attr() }; }}// 3) The Stage 3 Class Properties syntaxclass Pilot extends Model { static fields = { id : attr() };}Going forward, I'll stick with the Class Properties syntax, and definefields andmodelName inside the class body.
Next, for the first time we're using Redux-ORM'smany() 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 calledUnitPilots andUnitMechs, and a sampleUnitPilots entry might look like{id : 2, fromUnitId : 1, toPilotId : 3}. If we have an instance of aUnit, theunitModel.pilots field will be a Redux-ORMQuerySet that can be turned into an array ofPilot models or plain JS objects.
Finally, notice that theUnit.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 otherparse() methods have just looked likereturn this.create(data) orreturn this.upsert(data). Now, we need to continue recursing down through the nested data to parse anyPilot orMech entries.
Our data loading reducer already runspilots.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 aSession. Those subclasses are attached to theSession instance, and any operations involving those subclasses are applied to that specificSession. So, we can't just doimport Pilot from "features/pilots/Pilot" here - we need to get a reference to the specificPilot subclass on the currentSession instance related to whereUnit.parse() is being called.
Fortunately, the actual solution is pretty easy. When this code runs,this inside the static method will refer to theSession-specific subclass ofUnit, and Redux-ORM makes theSession available asthis.session. So, in the same way that we didconst {Pilot} = session over in the reducer, we can doconst {Pilot} = this.session here inside the static class method.
From there, we callPilot.parse() andMech.parse() as we map over the arrays. Those create the proper entries inside theSession, and then passing those newly-created model entries intothis.upsert() instead of the original plain objects will tell Redux-ORM to set up the associations between theUnit 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 callUnit.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 aFaction 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 theFaction entries.
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); }} 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, ourUnit.affiliation field needs to change from a simple attribute to a foreign key reference to theFaction table.
Commit 740e602: Update Unit affiliation to point to Factions
-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 fromstate.unitInfo, andstate.unitInfo in turn contains the entireunit 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, likestate.unitInfo.pilots.
In addition, the inputs are also hooked up to apply updates to thestate.unitInfo section. We really need to change that so that the form displays the values from theUnit 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 singleUnit 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 currentUnit entry and display its values instead of fromstate.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 currentUnit. 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, orundefined 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 wecould 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 theif clause, there's a neat little trick you can use to do destructuring assignment to variables that have already been declared usinglet orvar - put parentheses around the entire statement. We could also have possibly handled theisDisplayingUnit check by doingconst {unitInfo = {}} = this.props, and checking to see if it actually had any fields inside.
Now things get a bit interesting. Currently, ourunitInfoReducer is a slice reducer that handles updates tostate.unitInfo. What we need instead is a feature reducer that handles updates to theUnit entry that's stored instate.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_LOADEDconstant and case reducer that just copied over theunitsection from the sample data - Both
updateUnitInfo()andsetUnitColor()case reducers now expect thatstateis actually the entirestate.entitiesslice. They create aSessioninstance, look up theUnitmodel, and update the appropriate fields before returning the updatedstate.entitiesdata. - The exported reducer now uses
createConditionalSliceReducer()so that our case reducers only see thestate.entitiesslice, and only if it's one of the action types this reducer knows how to handle.
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 theunitInfoReducer rewritten, we just need to update our root reducer so that we now longer have astate.unitInfo slice, and instead have theunitInfoReducer 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 tostate.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 instate.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 aLance model.
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 ofUnit.
Commit 14cd310: Add lance data and parse lances when loading a Unit
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+ ]+ }+ ],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 movedUnitOrganization.jsx fromfeatures/unitOrganization tofeatures/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.
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 apilotID prop, and use that to look up the appropriatePilot model from the store. Assuming that a pilot by that ID exists, it will also look up the relatedMech 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} /> // etcIf we refresh the page, we'll see 12 rows ofUnknown - 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 thatLance.
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 atreeview example in the Redux repo. For background, seethe 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 🔗︎
- Upgrading to React 16
- Random Numbers in Redux
- Project Structuring and Containers
- Redux Treeviews
This is a post in thePractical Redux series.Other posts in this series:
- Jan 01, 2018 -Practical Redux, Part 11: Nested Data and Trees
- Nov 28, 2017 -Practical Redux course now available on Educative.io!
- Jul 25, 2017 -Practical Redux, Part 10: Managing Modals and Context Menus
- Jul 11, 2017 -Practical Redux, Part 9: Upgrading Redux-ORM and Updating Dependencies
- Jan 26, 2017 -Practical Redux, Part 8: Form Draft Data Management
- Jan 12, 2017 -Practical Redux, Part 7: Form Change Handling, Data Editing, and Feature Reducers
- Jan 10, 2017 -Practical Redux, Part 6: Connected Lists, Forms, and Performance
- Dec 12, 2016 -Practical Redux, Part 5: Loading and Displaying Data
- Nov 22, 2016 -Practical Redux, Part 4: UI Layout and Project Structure
- Nov 10, 2016 -Practical Redux, Part 3: Project Planning and Setup
- Oct 31, 2016 -Practical Redux, Part 2: Redux-ORM Concepts and Techniques
- Oct 31, 2016 -Practical Redux, Part 1: Redux-ORM Basics
- Oct 31, 2016 -Practical Redux, Part 0: Introduction