Practical Redux, Part 8: Form Draft Data Management

This is a post in the Practical Redux series.


Generic entity reducer logic, form current/draft data management, and form reset handling

Intro

In Part 7, we used a custom FormEditWrapper component to buffer form change events, wrote custom reducer logic for the "editing" feature, and added basic editing for Pilot entries. This time, we'll add the ability to delete Pilots, use generic entity reducer logic to implement "draft data" handling for forms, and implement form resetting and cancelation..

The code for this project is on Github at github.com/markerikson/project-minimek. The original WIP commits I made for this post can be seen in PR #8: Practical Redux Part 8 WIP, and the final "clean" commits can be seen in in PR #9: Practical Redux Part 8 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

Deleting Pilot Entries

Thus far, we can load a list of Pilots from our fake API, display them in the UI, and edit the details of an individual pilot. That covers two of the four capabilities in the term "CRUD" ("Create", "Retrieve", "Update", and "Delete"). Let's go ahead and implement the ability to delete Pilot entries from our state.

Creating a Redux-ORM Session Selector

Before we implement the actual deletion capability, we'll do a bit of cleanup first, which will also give us a minor performance improvement. As mentioned in in Part 6, many of our connected components create Redux-ORM Session instances in their mapState functions. Creating these Session instances probably isn't too expensive, but there really isn't a good reason to keep creating many Session instances every time the store updates and then throw them all away. This is especially true because a Session instance wraps up a specific state "tables" object, and since all of our components will be doing reads and not writes, they could easily all do reads from the same Session instance with no problems.

In addition, our mapState functions are currently accessing state.entities to pass that slice to the schema. Explicitly accessing slices of state isn't wrong, but it's good practice to use selector functions to encapsulate those lookups.

We can fix both of these issues at the same time, by creating a memoized selector function that will always return the same Session instance for a given version of the entities state slice. (As a reminder, "memoization" means caching inputs and outputs, so that if you see the same inputs, you can skip the work and return the previously calculated output.) The Reselect library provides a function called createSelector that will create the memoized selectors we need. That way, we only create one Session instance per update to our Model data. Again, our application is small enough that we probably wouldn't even see a difference in performance benchmarks, but it's a useful step in the right direction.

Commit 42693a3: Use a selector to have only a single Session instance per state update

features/entities/entitySelectors.js

import {createSelector} from "reselect";

import schema from "app/schema";

export const selectEntities = state => state.entities;

export const getEntitiesSession = createSelector(
    selectEntities,
    entities => schema.from(entities)
);

The selector creation is simple. We create an "input" selector that returns the state.entities slice, and pass that as the first argument to the createSelector function from Reselect. The "output" selector takes the returned entities slice, and calls schema.from(entities) to create the Session instance. That output selector will only run when the entities object changes.

From there, we update all the connected components to use that selector:

features/pilots/PilotDetails/PilotDetails.jsx

import {connect} from "react-redux";
import {Form, Dropdown, Grid, Button} from "semantic-ui-react";

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

const mapState = (state) => {
    let pilot;
    
    const currentPilot = selectCurrentPilot(state);
    
-   const session = schema.from(state.entities);    
+   const session = getEntitiesSession(state);
    const {Pilot} = session;

After updating all the components to use getEntitiesSession(), we should only be creating a single Session instance per entities update.

Adding Entity Creation and Deletion Reducers

In Part 7, we created a "feature reducer" to handle generic actions that apply to our entities slice. At the time, we only created a single case reducer, which responds to the ENTITY_UPDATE action. That allowed us to make updates to the Pilot entry that's being edited.

We're going to add two more case reducers to that section, one for creating new entities and one for deleting them. We need the deletion reducer now so that we can delete our Pilot entries, and the creation reducer will come into play a bit later.

Commit 75cc30a: Add generic entity creation and deletion handling

features/entities/entityReducer.js

-import {ENTITY_UPDATE} from "./entityConstants";
+import {
+   ENTITY_UPDATE,
+   ENTITY_DELETE,
+   ENTITY_CREATE,
+} from "./entityConstants";

export function updateEntity(state, payload) {
    const {itemType, itemID, newItemAttributes} = payload;

    const session = schema.from(state);
    const ModelClass = session[itemType];

    let newState = state;

    if(ModelClass.hasId(itemID)) {
        const modelInstance = ModelClass.withId(itemID);

        modelInstance.update(newItemAttributes);

        newState = session.reduce();
    }

    return newState;
}

+export function deleteEntity(state, payload) {
+   const {itemID, itemType} = payload;
+
+   const session = schema.from(state);
+   const ModelClass = session[itemType];
+
+   let newState = state;
+
+   if(ModelClass.hasId(itemID)) {
+       const modelInstance = ModelClass.withId(itemID);
+
+       modelInstance.delete();
+
+       // Immutably apply updates and return the new entities structure
+       newState = session.reduce();
+   }
+
+   return newState;
+}
+
+export function createEntity(state, payload) {
+   const {itemType, newItemAttributes} = payload;
+
+   const session = schema.from(state);
+   const ModelClass = session[itemType];
+
+   ModelClass.parse(newItemAttributes);
+
+   const newState = session.reduce();
+   return newState;
+}
+

const entityHandlers = {
    [ENTITY_UPDATE] : updateEntity,
+   [ENTITY_CREATE] : createEntity,
+   [ENTITY_DELETE] : deleteEntity,
};

const entityCrudFeatureReducer = createConditionalSliceReducer("entities", entityHandlers);

All of these reducers follow the same basic pattern: create a Redux-ORM session from the current state, look up a session-bound Model class by name, queue the appropriate update commands in Redux-ORM, then apply the updates immutably and return the updated state. (Yes, it would probably be possible to write some kind of wrapper function that de-duplicates some of the common behavior in these functions, but they're short enough it's not worth the effort.)

Note that the createEntity() reducer expects that each Model class will have a static parse() method. Per Part 5, that's not something that's built in to Redux-ORM, but rather a convention that we're following by writing those functions in all our Model classes.

Implementing Pilot Deletion

We should now be able to dispatch an ENTITY_DELETE action to delete a given Pilot entry from the store. All we need to do now is add delete buttons to our PilotsListRow components, and hook them up to dispatch the action.

We'll add another column to the Pilots list, and show a red circular X button for each row. Clicking the button will delete the item.

Commit 35e48d5: Add the ability to delete individual Pilot entries

features/pilots/PilotsList/PilotsListHeader.jsx

-           <Table.HeaderCell width={4}>
+           <Table.HeaderCell width={3}>
                Mech
            </Table.HeaderCell>
+           <Table.HeaderCell width={1} />

features/pilots/PilotsList/PilotsListRow.jsx

-import {Table} from "semantic-ui-react";
+import {
+    Table,
+    Button,
+    Icon,
+} from "semantic-ui-react";


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


+const actions = {
+    deleteEntity,
+};

-const PilotsListRow = ({pilot={}, onPilotClicked=_.noop, selected}) => {
+const PilotsListRow = ({pilot={}, onPilotClicked=_.noop, selected, deleteEntity}) => {

// Omit prop extraction

+   const onDeleteClicked = () => deleteEntity("Pilot", id);

// Omit row cell rendering
            <Table.Cell>
                {mechType}
            </Table.Cell>

+           <Table.Cell>
+               <Button
+                   compact
+                   basic
+                   circular
+                   size="tiny"
+                   color="red"
+                   icon={<Icon  name="delete" />}
+                   onClick={onDeleteClicked}
+               >
+               </Button>
+           </Table.Cell>
        </Table.Row>

That should give us a column of delete buttons for all our Pilot entries:

Clicking any of the delete buttons should remove the corresponding Pilot entry from the store, and that will result in the row being removed. If you play around with things a bit, you'll see there's also some interesting behavior around trying to delete any Pilot entry while a Pilot is being edited, whether it's the same Pilot or a different Pilot. Let's take a look at what's going on there.

Improving the Pilot Deletion Logic

Right now, any click on the pilots list will dispatch the PILOT_SELECT action, which also stops any active editing. Let's update the logic so that it only stops editing if the current pilot is deleted. There's probably a few different ways we could handle this. We're going to do it with two distinct changes.

First, we'll update the click handling in <PilotsListRow>. In the current code, a click on the delete button triggers the click handler for the button, but also triggers the click handler for the entire table row. We'll have the button click handler cancel the event, so that the row handler doesn't get run afterwards.

Second, we're going to have the pilots reducer listen for the ENTITY_DELETE action type. If the deleted item is a Pilot, and we're in the middle of editing that pilot, we'll go ahead and stop editing.

Commit d5da05b: Only stop editing if the current pilot is deleted

features/pilots/PilotsList/PilotsListRow.jsx

-   const onDeleteClicked = () => deleteEntity("Pilot", id);
+   const onDeleteClicked = (e) => {
+       e.stopPropagation();
+       e.preventDefault();
+       deleteEntity("Pilot", id);
+   }

+   const onRowClicked = () => onPilotClicked(id);


    return (
-       <Table.Row onClick={() => onPilotClicked(id)} active={selected}>
+       <Table.Row onClick={onRowClicked} active={selected}>

features/pilots/pilotsReducer.js

+import {
+   ENTITY_DELETE,
+} from "features/entities/entityConstants";

+export function stopEditingIfDeleted(state, payload) {
+   const {itemType, itemID} = payload;
+   const {isEditing, currentPilot} = state;
+
+   if(isEditing && itemType === "Pilot" && itemID === currentPilot) {
+       return stopEditingPilot(state, payload);
+   }
+
+   return state;
+}

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

Notice that our stopEditingIfDeleted function actually reuses the existing stopEditingPilot function to do the work. Also, this is another example of multiple reducers in different slices responding to the same action.

Now if we edit one Pilot, and delete another, we'll stay in editing mode for the current pilot. Selecting a different pilot will still cancel editing.

Managing Draft Data for Forms

As mentioned in Part 7, our current Pilot editing logic updates the actual data object for that Pilot in the store. Because both the list row and the edit form are referencing the same object, all edits in the form are immediately reflected in the list row. That looks kinda cool when you first see it, but it's not really a desired behavior. What if we wanted the user to be able to cancel the edits, or confirm they actually wanted to save the new values, or even reset the changes?

I faced this issue when working on my own application. It took me a while to figure out the approach I now use, although in hindsight, the idea sure feels obvious, and I'm kinda kicking myself for not working it out sooner. (In my defense, it was still early on in my first actual usage of Redux.) I also have to give a big shout-out and thanks to Tommi Kaikkonen, author of Redux-ORM, who took the time to listen to my use case, answer my questions, and help me work out the idea.

I summarized the basic idea in the Structuring Reducers - Normalizing State Shape section of the Redux docs. I'll quote the relevant section here:

An application that does a lot of editing of entities might want to keep two sets of "tables" in the state, one for the "current" item values and one for the "work-in-progress/draft" item values. When an item is edited, its values could be copied into the "work-in-progress" section, and any actions that update it would be applied to the "work-in-progress" copy, allowing the editing form to be controlled by that set of data while another part of the UI still refers to the original version. "Resetting" the edit form would simply require removing the item from the "work-in-progress" section and re-copying the original data from "current" to "work-in-progress", while "applying" the edits would involve copying the values from the "work-in-progress" section to the "current" section.

This really goes back to one of the core concepts of both React and Redux: whenever possible, represent your application's behavior through state. In this case, we want to have two different representations of the same item. So, copy it, leave the original alone, and modify the copy. (This also applies to non-relational form data as well.)

Generic Reducer Logic for Editing Entities

Implementing this "draft slice" capability will require a noticeable amount of work. We need to add this new "draft" state slice to our store, then write the reducer logic to actually copy items from our current entities slice into the "draft" slice.

Adding the Draft State Slice

The good news is that this first part is pretty easy. We've already got a small reducer in our core app folder that defines the entities slice. We just need to copy that reducer's initial state handling so that the "tables" are created for us, and add it in to the root reducer.

Commit 925d077: Add an "editingEntities" state slice to store draft data

app/reducers/editingEntitiesReducer.js

import {createReducer} from "common/utils/reducerUtils";

import schema from "app/schema";
const defaultEditingEntities = schema.getDefaultState();

export default createReducer(defaultEditingEntities, {
});

This reducer will only define the initial shape of the state. We won't add any actual update logic here - all that goes elsewhere.

app/reducers/rootReducer.js

 import entitiesReducer from "./entitiesReducer";
+import editingEntitiesReducer from "./editingEntitiesReducer";
 import tabReducer from "features/tabs/tabReducer";
 
const combinedReducer = combineReducers({
    entities : entitiesReducer,
+   editingEntities : editingEntitiesReducer,
    unitInfo : unitInfoReducer,
    pilots : pilotsReducer,
    mechs : mechsReducer,
    tabs : tabReducer,
}); 

We'll add editingEntities as another top-level slice in our state tree.

We'll also throw in a small utility function to simplify looking up a Model instance from a Session:

Commit dc83aeb: Add a utility function to look up a model by type and ID

common/utils/modelUtils.js

export function getModelByType(session, itemType, itemID) {
    const modelClass = session[itemType];
    const model = modelClass.withId(itemID);
    return model;
}

Finally, we'll create our initial empty "editing feature" reducer. Similar to how we defined the "generic entity CRUD" reducer, we'll add it to the end of the list of "top-level" reducers in our root reducer:

Commit fbcad74: Create an empty editing feature reducer

features/editing/editingReducer.js

import {createReducer} from "common/utils/reducerUtils";

const editingFeatureReducer = createReducer({}, {
});

export default editingFeatureReducer;

app/reducers/rootReducer.js

import entityCrudReducer from "features/entities/entityReducer";
+import editingFeatureReducer from "features/editing/editingReducer";

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

Before we create the actual core reducer logic, we'll add some utility functions to encapsulate behavior. We'll have a small function to update state.entities, and another to update state.editingEntities. We'll also create a small function to access a given Redux-ORM Model instance, and ask it to copy its entire contents into a plain JS object:

Commit 57b924c: Add editing utility functions

features/editing/editingUtilities.js

import schema from "app/schema";
import {getModelByType} from "common/utils/modelUtils";

export function updateEditingEntitiesState(state, updatedEditingEntities) {
    return {
        ...state,
        editingEntities : updatedEditingEntities,
    };
}

export function updateEntitiesState(state, updatedEntities) {
    return {
        ...state,
        entities : updatedEntities,
    };
}

export function readEntityData(entities, itemType, itemID) {
    const readSession = schema.from(entities);

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

    return data;
}

(Yes, those two "update" functions are trivial, and not really needed here, but it's a small bit of encapsulation.)

As we saw earlier with code that used parse(), here we're expecting that all our Model instances will have a toJSON() method. Again, that's something we're writing ourselves as a common convention, not anything that's built in to Redux-ORM.

On that note, let's go ahead and write those toJSON() methods for our current model classes. Since the model classes are very simple, the methods will be trivial:

Commit 6376ebd: Add toJSON() methods for Redux-ORM model classes

features/pilots/Pilot.js

    static parse(mechData) {
        return this.create(mechData);
    }

+   toJSON() {
+       return {...this.ref};
+   }
}

For now, all we're going to do is make a shallow copy of the actual plain JS object that's in the store. Once our data starts using more complex relations, this would be the place to recurse through all of the 1:1 and 1:many relations, and call toJSON() on them as well. Since we don't have any classes like that right now, here's a quick example of what this might look like:

class SomeHypotheticalModelWithRelations extends Model {
    toJSON() {}
        return {
            ...this.ref,
            someSingleRelation : this.someSingleRelation.toJSON(),
            someMultipleRelation : this.someMultipleRelation.withModels.map(item => item.toJSON())
        };
    }
}

Nothing fancy - just translate the relational fields back into a nested plain JS object as appropriate.

With all that in place, it's time to figure out what our editing reducer actually needs to do.

Writing the Core Editing Reducer Logic

To start with, we need our editing reducer to handle three basic tasks:

  • When we start editing an item, we need to copy it from the "current data" entities slice to the "draft data" editingEntities slice
  • We need to be able to update the item in editingEntities with new values
  • When we're done editing the item, we need to delete the copy from editingEntities, since it's not being used any more

We're going to define three initial actions for those tasks: EDIT_ITEM_EXISTING, EDIT_ITEM_UPDATE, and EDIT_ITEM_STOP.

Remember that our editing reducer is a "top-level" reducer, and receives the entire state tree as its state argument. That means it has access to both state.entities and state.editingEntities.

Looking back at our "generic entity CRUD" reducer, we can see that we already have individual case reducer functions that know how to create, update, and delete an entity in a slice of state. They're currently being used together to handle updates to the state.entities slice as a whole, but we can import and reuse those individual case reducer functions as part of the editing reducer logic.

I'll link the commit that implements the reducers for these three cases, and explain each piece in turn:

Commit 018c552: Add generic reducer logic for editing an entity

features/editing/editingReducer.js

import {createReducer} from "common/utils/reducerUtils";

import {
    createEntity,
    updateEntity,
    deleteEntity
} from "features/entities/entityReducer";

import {
    EDIT_ITEM_EXISTING,
    EDIT_ITEM_UPDATE,
    EDIT_ITEM_STOP
} from "./editingConstants";

import {selectEntities} from "features/entities/entitySelectors";
import {selectEditingEntities} from "./editingSelectors";
import {
    readEntityData,
    updateEditingEntitiesState,
} from "./editingUtils";

export function copyEntity(sourceEntities, destinationEntities, payload) {
    const {itemID, itemType} = payload;

    const newItemAttributes = readEntityData(sourceEntities, itemType, itemID);
    const creationPayload = {itemType, itemID, newItemAttributes}

    const updatedEntities = createEntity(destinationEntities, creationPayload);
    return updatedEntities;
}

export function editItemExisting(state, payload) {
    const entities = selectEntities(state);
    const editingEntities = selectEditingEntities(state);

    const updatedEditingEntities = copyEntity(entities, editingEntities, payload);
    return updateEditingEntitiesState(state, updatedEditingEntities);
}

export function editItemUpdate(state, payload) {
    const editingEntities = selectEditingEntities(state);

    const updatedEditingEntities = updateEntity(editingEntities, payload);
    return updateEditingEntitiesState(state, updatedEditingEntities);
}

export function editItemStop(state, payload) {
    const editingEntities = selectEditingEntities(state);

    const updatedEditingEntities = deleteEntity(editingEntities, payload);
    return updateEditingEntitiesState(state, updatedEditingEntities);
}

const editingFeatureReducer = createReducer({}, {
    [EDIT_ITEM_EXISTING] : editItemExisting,
    [EDIT_ITEM_UPDATE] : editItemUpdate,
    [EDIT_ITEM_STOP] : editItemStop,
});

export default editingFeatureReducer;

First, copyEntity() is an internal helper function, which isn't assigned to handle any specific cases. It's called with two different state slices, one as a source and the other as a destination, and the payload from an action. It uses the readEntityData() utility method we wrote to look up a Model instance and copy its entire contents to a plain JS object. Then, it uses the existing createEntity() case reducer to parse that object and create a copy of the destination slice with the new item "inserted into the tables".

I want to pause and re-emphasize that idea: we're copying items by "serializing" the Models to plain objects, and then re-parsing them to create the duplicate Model instances. The "parsing" concept is the same function we're calling when we load the data from our fake API - we're reusing that here as our means of insertion.

The editItemExisting() case reducer is used to start the editing process for an item that already exists in the "current data" state.entities slice. We use small selectors to retrieve the state.entities and state.editingEntities slices, then pass them to copyEntity(). That returns us the newly updated editingEntities slice, but we need to return that as part of the overall top-level state. We use the updateEditingEntitiesState() utility we wrote to do that.

Similarly, the editItemUpdate() case reducer reads the editingEntities slice, passes that to the existing updateEntity reducer, and returns the new top-level state with the updated editingEntities slice.

Finally, editItemStop() deletes the item out of the editingEntities slice, using the existing deleteEntity() case reducer to do the work.

Again, the idea here is that we already have a set of "primitive", low-level reducer functions for manipulating entities in state, so we can reuse them as building blocks to create higher-level logic. Because hey, it's all just functions!

Using Draft Data for Editing Pilots

With those building blocks set, we can now begin updating our <PilotDetails> form to work with the new editing logic and data.

Displaying the Copied Pilot Entry

Since we're copying edited items from entities to editingEntities, our <PilotDetails> form will need to be able to switch which slice of state it's reading the pilot data from. We'll add some additional logic to the mapState function so that it can determine what slice it should use to load the data. We also need to update the action creators to actually dispatch the EDIT_ITEM_EXISTING and EDIT_ITEM_STOP actions at the same time as we dispatch the PILOT_EDIT_START and PILOT_EDIT_STOP actions.

Commit 3cb01fc: Add ability to display pilot entry from "draft" editingEntities slice

features/editing/editingSelectors.js

import {createSelector} from "reselect";

import schema from "app/schema";

export const selectEditingEntities = state => state.editingEntities;

export const getEditingEntitiesSession = createSelector(
    selectEditingEntities,
    editingEntities => schema.from(editingEntities)
);

As with our entities slice, we'll create a memoized selector that ensures we only have a single Redux-ORM Session instance per update to the editingEntities slice.

features/pilots/pilotsActions.js

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


-export function startEditingPilot() {
-   return {
-       type : PILOT_EDIT_START,
-   };
-}
+export function startEditingPilot(pilotID) {
+   return (dispatch, getState) => {
+       dispatch(editExistingItem("Pilot", pilotID));
+       dispatch({type : PILOT_EDIT_START});
+   }
+}

-export function stopEditingPilot() {
-   return {
-       type : PILOT_EDIT_STOP,
-   };
-}
+export function stopEditingPilot(pilotID) {
+   return (dispatch, getState) => {
+       dispatch({type : PILOT_EDIT_STOP});
+       dispatch(stopEditingItem("Pilot", pilotID));
+   }
+}

We need to dispatch two different actions each time the "Start Editing" and "Stop Editing" buttons are clicked. We'll change the existing plain action creators into thunks, pass in the pilot IDs as arguments, and dispatch both the pilot-related action and the editing-related action in each thunk.

features/pilots/PilotDetails/PilotDetails.jsx

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

// Skip to mapState

    const currentPilot = selectCurrentPilot(state);

+   const pilotIsSelected = Boolean(currentPilot);
+   const isEditingPilot = selectIsEditingPilot(state);
+
+   if(pilotIsSelected) {
+       const session = isEditingPilot ?
+           getEditingEntitiesSession(state) :
+           getEntitiesSession(state);

        const {Pilot} = session;

        if(Pilot.hasId(currentPilot)) {
            pilot = Pilot.withId(currentPilot).ref;
        }
+    }

export class PilotDetails  extends Component {
+   onStartEditingClicked = () => {
+       const {id} = this.props.pilot;
+       this.props.startEditingPilot(id);
+   }

+   onStopEditingClicked = () => {
+       const {id} = this.props.pilot;
+       this.props.stopEditingPilot(id);
=   }

// Omit rendering code
                    <Button
                        primary
                        disabled={!canStartEditing}
                        type="button"
-                       onClick={actions.startEditingPilot}                        
+                       onClick={this.onStartEditingClicked}
                    >
                        Start Editing
                    </Button>
                    <Button
                        secondary
                        disabled={!canStopEditing}
                        type="button"
-                       onClick={actions.stopEditingPilot}
+                       onClick={this.onStopEditingClicked}
                    >
                        Stop Editing
                    </Button>

We rework the mapState function to determine whether we're currently editing a pilot or just displaying it. Based on that, we retrieve either the editing Session or the "current data" Session, and read the Pilot out of there.

The <PilotDetails> component gets a couple new click handlers, which extract the pilot ID from props, and call the appropriate action creator.

features/editing/editingReducer.js

    const newItemAttributes = readEntityData(sourceEntities, itemType, itemID);
+   if(newItemAttributes.name) {
+       newItemAttributes.name += " (copied)";
+   }
    const creationPayload = {itemType, itemID, newItemAttributes}

As a visual indicator that we really are displaying the edited item, we'll temporarily modify our copyEntity() function to append the text " (copied)" to the end of the name field.

If we select a pilot and start editing it, we should now see:

Progress! We now have two different versions of the same pilot being displayed: the "current" entry in the list, and the "draft" entry in the form.

Updating the Edited Entry

If you try typing in the "Name" field, you'll notice some very strange behavior. As soon as you type anything, the list item for the pilot will update as well. That's because we're still dispatching ENTITY_UPDATE, instead of EDIT_ITEM_UPDATE. We need to modify <PilotDetails> to actually update the "draft" version. While we're at it, we'll remove the little "(copied)" modification we made to the reducer.

Commit d466044: Update pilot editing logic to apply to draft entry

features/pilots/PilotDetails/PilotDetails.jsx

-import {updateEntity} from "features/entities/entityActions";
+import {editItemAttributes} from "features/editing/editingActions";

const actions = {
    startEditingPilot,
    stopEditingPilot,
-   updateEntity,
+   editItemAttributes,
}


   onInputChanged = (e) => {
        const newValues = getValueFromEvent(e);
        const {id} = this.props.pilot;
        
-       this.props.updateEntity("Pilot", id, newValues);
+       this.props.editItemAttributes("Pilot", id, newValues);
    }

    onDropdownChanged = (e, result) => {
        const {name, value} = result;
        const newValues = { [name] : value};
        const {id} = this.props.pilot;
        
-       this.props.updateEntity("Pilot", id, newValues);
+       this.props.editItemAttributes("Pilot", id, newValues);
    }

Now, if you edit the pilot in the form, only that form should update. The corresponding entry in the list should stay as it was.

Saving Form Edits

We can now edit a draft item, but when we click "Stop Editing", the original item is still unchanged. We need to overwrite the values in the original entry with the updated values in the draft entry.

Updating Existing Models

Earlier, we wrote a function called copyEntity, which serializes the requested model (and its relations) to plain JS objects, and recreates them in the draft slice. We now need to reverse the process, but this gets a bit tricker. We already have an entry in the entities slice, so we can't just call createEntity() on that slice. If we tried deleting it and immediately recreating it with the new values, Redux-ORM would also clear out all the relational fields that reference that same item, which would break things. What we really need is a way to update the existing model "in-place".

This is the other part that Tommi Kaikkonen helped me figure out. We can write custom updateFrom() methods on our Model classes, which take another model of the same type, and recursively update themselves and all their relations. It's basically the inverse operation of the sample toJSON() function I showed earlier.

Commit df88219: Add updateFrom() methods to models

features/pilots/Pilot.js

    toJSON() {
        return {...this.ref};
    }

+   updateFrom(otherPilot) {
+       this.update(otherPilot.ref);
+   }
}

Again, our current model classes don't have any complex relations, so the implementation is really simple. We just call the built-in update() method, and pass it the plain JS object that the other model wraps around (which, in our case, is stored in the other slice of state).

(The process of updating a 1:many collection gets considerably more complicated, as you have to take into account adds, updates, and deletions. In my real app, I wound up writing a reusable helper function to encapsulate the somewhat complex logic. I plan to show that off later in the series, when we start dealing with more complex relations.)

Applying Changes to Entities

We're going to write another core logic function that can load a model from a "source" slice, load its equivalent from the "destination" slice, and tell the destination entry to update itself with the source entry. Then we'll write a case reducer that uses that function with editingEntities as the source and entities as the destination.

Commit 4bd028d: Add logic to apply item edits to the "current data" slice

features/editing/editingReducer.js

import {createReducer} from "common/utils/reducerUtils";

+import schema from "app/schema";

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

+import {getModelByType} from "common/utils/modelUtils";

import {
    readEntityData,
+    updateEntitiesState,
    updateEditingEntitiesState,
} from "./editingUtils";


+export function updateEditedEntity(sourceEntities, destinationEntities, payload) {
+   // Start by reading our "work-in-progress" data
+   const readSession = schema.from(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 = schema.from(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, which queues up a
+           // series of immutable add/update/delete actions internally
+           existingItem.updateFrom(model);
+       }
+   }
+
+   // Immutably apply the changes and generate our new "current" relational data
+   const updatedEntities = writeSession.reduce();
+   return updatedEntities;
+}
+
+

+export function editItemApply(state, payload) {
+   const entities = selectEntities(state);
+   const editingEntities = selectEditingEntities(state);

+   const updatedEntities = updateEditedEntity(editingEntities, entities, payload);
+   return updateEntitiesState(state, updatedEntities);
+}

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

The logic is fairly straightforward. For updateEditedEntity(), we create two separate Redux-ORM sessions, retrieve the models from each, call existingItem.updateFrom(model), and return the immutably updated state as normal. The editItemApply() case reducer just selects the two state slices, calls updateEditedEntity(), and returns the updated root state.

Updating Pilot Save Logic

Our pilots reducer currently responds to the PILOT_SELECT action by resetting isEditing : false. That was fine when we were just showing and editing the existing entry, but now we have some additional steps going on. We really need to make sure that our editing data is cleaned up when another pilot is selected.

We're going to update the selectPilot() action creator to explicitly dispatch the "stop editing pilot" logic before it actually dispatches PILOT_SELECT. In the process, since we know that you can only ever edit the current Pilot, we can rework the thunks to retrieve the current pilot from state instead of passing it in as a parameter. (There's a very good discussion to be had about which approach is better, but we'll go ahead and make the changes for sake of illustrating the idea.)

Commit 4851049: Rework pilot logic to explicitly stop editing on selection

features/pilots/pilotsReducer.js

        currentPilot : isSamePilot ? null : newSelectedPilot,
-       // Any time we select a different pilot, we stop editing
-       isEditing : false,

features/pilots/pilotsActions.js

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

export function selectPilot(pilotID) {
-   return {
-       type : PILOT_SELECT,
-       payload : {currentPilot : pilotID},
-   };
+   return (dispatch, getState) => {
+       const state = getState();
+       const isEditing = selectIsEditingPilot(state);
+
+       if(isEditing) {
+           dispatch(stopEditingPilot())
+       }
+
+       dispatch({
+           type : PILOT_SELECT,
+           payload : {currentPilot : pilotID},
+       });
+   }
}

export function stopEditingPilot(pilotID) {
    return (dispatch, getState) => {
        const currentPilot = selectCurrentPilot(getState());

        dispatch({type : PILOT_EDIT_STOP});
        dispatch(stopEditingItem("Pilot", pilotID));
        dispatch(stopEditingItem("Pilot", currentPilot));
    }
}

-export function startEditingPilot(pilotID) {
+export function startEditingPilot() {
    return (dispatch, getState) => {
+       const currentPilot = selectCurrentPilot(getState());

-       dispatch(editExistingItem("Pilot", pilotID));
+       dispatch(editExistingItem("Pilot", currentPilot));
        dispatch({type : PILOT_EDIT_START});
    }
}

-export function stopEditingPilot(pilotID) {
+export function stopEditingPilot() {
    return (dispatch, getState) => {
+       const currentPilot = selectCurrentPilot(getState());

        dispatch({type : PILOT_EDIT_STOP});
-       dispatch(stopEditingItem("Pilot", pilotID));
+       dispatch(stopEditingItem("Pilot", currentPilot));
    }
}

From there, we can simply add in the "apply edits" action as part of the "stop editing" thunk:

Commit f6608d0: Save changes to pilot entries when editing is stopped

features/pilots/pilotsActions.js

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

export function stopEditingPilot() {
    return (dispatch, getState) => {
        const currentPilot = selectCurrentPilot(getState());

        dispatch({type : PILOT_EDIT_STOP});
+       dispatch(applyItemEdits("Pilot", currentPilot));
        dispatch(stopEditingItem("Pilot", currentPilot));
    }
}

And finally, whenever we hit the "Stop Editing" button, our changes are saved, and the list entry should be updated with whatever changes we made in the form.

Resetting and Canceling Form Edits

Our last couple tasks will be to add the ability to reset our form contents back to the original values, and to add the ability to cancel a form edit entirely.

Resetting Entity Edits

Happily, this is another feature we can implement very easily, by reusing existing code. All we have to do is delete the relevant item out of the editingEntities slice, and immediately copy the original item back over to editingEntities.

Commit 09c5236: Add logic to reset a currently edited item

features/editing/editingReducer.js

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


+export function editItemReset(state, payload) {
+   const stateWithoutItem = editItemStop(state, payload);
+   const stateWithCurrentItem = editItemExisting(stateWithoutItem, payload);
+
+   return stateWithCurrentItem;
+}

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

Adding "Reset" and "Cancel" Buttons

The other neat thing is that we don't even need to create a "cancel" action. We can do that by simply calling the same "stop editing" actions, and skip applying the item edits.

We'll add a couple more buttons to our <PilotDetails> component, tweak the button layout a bit, and that'll be all:

Commit a4c2ce7: Add the ability to reset and cancel editing a pilot

features/pilots/pilotsActions.js

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

features/pilots/PilotDetails/PilotDetails.jsx

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

+import {
+   resetEditedItem,
+} from "features/editing/editingActions";


const actions = {
    startEditingPilot,
    stopEditingPilot,
    editItemAttributes,
+   resetEditedItem,
+   cancelEditingPilot,
}


export class PilotDetails  extends Component {

+   onResetClicked = () => {
+       const {id} = this.props.pilot;
+       this.props.resetEditedItem("Pilot", id);
+   }

// Omit rendering code

+               <Grid.Row width={16}>
+                   <Button
+                       disabled={!canStopEditing}
+                       type="button"
+                       onClick={this.onResetClicked}
+                   >
+                       Reset Values
+                   </Button>
+                   <Button
+                       negative
+                       disabled={!canStopEditing}
+                       type="button"
+                       onClick={this.props.cancelEditingPilot}
+                   >
+                       Cancel Edits
+                   </Button>
+               </Grid.Row>

We can now start editing an item; save the item and stop editing; reset the draft item to its original values; and cancel an edit without actually saving the changes. Yay!

Let's take one last look at the current UI appearance:

You can see that we're in the middle of editing a Pilot. We've edited several values, and they're different from what's being displayed in the list. We also have our "Stop", "Reset", and "Cancel" buttons active, while "Start" is disabled. Looks good!

Final Thoughts

We've come a long way from the start of the project. We've built up a UI, added data loading, displayed our data, discussed performance concerns, and implemented some fairly sophisticated logic for handling normalized entities and working with forms.

This is a good place to pause the Practical Redux series for a while. I'm absolutely not done with the series, but I have a couple other blog posts that I want to write on other topics, and some non-blogging things I want to work on soon. Rest assured, I have many more topics that I want to cover in this series! (In fact, if you're interested in my writing plans, I have a gist listing the blog post topics I want to write about in the future.)

I'd definitely like to thank everyone who has read these posts so far, and say how much I appreciate both the interest and the positive feedback you've given me! It's extremely encouraging to know that people are actually interested in what I've written, and are finding the information helpful.

Be sure to keep an eye on this blog for other posts coming soon, and I'll continue to tweet updates about what I'm working on next.

Until next time: keep on Redux-ing! :)

Further Information


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


Author Avatar

Mark Erikson

Collector of interesting links, answerer of questions