Practical Redux, Part 8: Form Draft Data Management
This is a post in the Practical Redux series.
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
- Managing Draft Data for Forms
- Generic Reducer Logic for Editing Entities
- Using Draft Data for Editing Pilots
- Saving Form Edits
- Resetting and Canceling Form Edits
- Final Thoughts
- Further Information
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.
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
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:
features/editing/editingReducer.js
import {createReducer} from "common/utils/reducerUtils";
const editingFeatureReducer = createReducer({}, {
});
export default editingFeatureReducer;
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:
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
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.
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 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
.
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 🔗︎
- Project Mini-Mek repo
- Selectors
- "Draft data" concept and discussion
- Reducers
- Mark's blog post todo list
This is a post in the Practical Redux series. Other posts in this series:
- Jan 01, 2018 - Practical Redux, Part 11: Nested Data and Trees
- Nov 28, 2017 - Practical Redux course now available on Educative.io!
- Jul 25, 2017 - Practical Redux, Part 10: Managing Modals and Context Menus
- Jul 11, 2017 - Practical Redux, Part 9: Upgrading Redux-ORM and Updating Dependencies
- Jan 26, 2017 - Practical Redux, Part 8: Form Draft Data Management
- Jan 12, 2017 - Practical Redux, Part 7: Form Change Handling, Data Editing, and Feature Reducers
- Jan 10, 2017 - Practical Redux, Part 6: Connected Lists, Forms, and Performance
- Dec 12, 2016 - Practical Redux, Part 5: Loading and Displaying Data
- Nov 22, 2016 - Practical Redux, Part 4: UI Layout and Project Structure
- Nov 10, 2016 - Practical Redux, Part 3: Project Planning and Setup
- Oct 31, 2016 - Practical Redux, Part 2: Redux-ORM Concepts and Techniques
- Oct 31, 2016 - Practical Redux, Part 1: Redux-ORM Basics
- Oct 31, 2016 - Practical Redux, Part 0: Introduction