Movatterモバイル変換


[0]ホーム

URL:


Mark's Dev Blog

Random musings on React, Redux, and more, by Redux maintainer Mark "acemarke" Erikson
 Sponsor @markerikson
 Home

Practical Redux, Part 11: Nested Data and Trees

Posted on
#javascript#react#redux#project-minimek#trees

This is a post in thePractical 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 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 🔗︎

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.2

Commit 415fc95: Upgrade to React 16.2

There'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.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 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.

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 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.

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;

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.

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 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.

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 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.

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 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.

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 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

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 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.

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 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.

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 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.

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, 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

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 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:

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 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.

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 ofUnit.

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 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.

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 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} />                // etc

If 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.

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 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 🔗︎


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

Recent Posts

Presentations: The State of React and the Community in 2025The State of React and the Community in 2025Presentations: Maintaining a Library and a CommunityReact Advanced 2024: Designing Effective DocumentationReact Summit 2024: Why Use Redux Today?

Top Tags

63 redux56 javascript49 react31 presentation30 greatest-hits

Greatest Hits

Greatest Hits: The Most Popular and Most Useful Posts I've WrittenRedux - Not Dead Yet!Why React Context is Not a "State Management" Tool (and Doesn't Replace Redux)A (Mostly) Complete Guide to React Rendering BehaviorPresentations: Modern Redux with Redux ToolkitWhen (and when not) to reach for ReduxThe Tao of Redux, Part 1 - Implementation and IntentThe History and Implementation of React-ReduxThoughts on React Hooks, Redux, and Separation of ConcernsReact Boston 2019: Hooks HOCs, and TradeoffsUsing Git for Version Control Effectively

Series

21 Blogged Answers4 Codebase Conversion4 Coding Career Advice2 Declaratively Rendering Earth In 3d5 How Web Apps Work8 Idiomatic Redux6 Newsletter13 Practical Redux32 Presentations5 Site Administrivia

[8]ページ先頭

©2009-2025 Movatter.jp