Most modern applications have some sort of mix between automatic saves and explicit buttons. In the case of what we've built at Xactware, I usually differentiate between the two behaviors with one question:
What is the impact of a user error?
If the impact of a user error is small, I typically prefer automatic updates with snackbars as a user indication that the given data has been saved successfully. However, in cases where the impact of a user error is large, I lean towards explicit action buttons.
In this article, I'll outline a simple way to detect user changes to enable or disable explicit action buttons.
Foundations
Before jumping in to the component code, it's important to take a look at how object comparison works in Javascript and why we would want to use a solution like Lodash.
Here's a common piece of code that trips up new engineers:
const objectOne = {
hello: "world",
};
const objectTwo = {
hello: "world",
};
const objectsAreEqual = objectOne === objectTwo;
console.log(objectsAreEqual);
// false
When a non-primitive (think array or object) value is created in Javascript, it's given a unique location in memory. When another object is created, even if it has the exact same contents as the first, it's also given a unique location in memory. So for non-primitive values, the triple equal sign is comparing the location in memory as opposed to comparing the actual contents of our variables.
To give you a real world frame of reference, think of a row of houses that are all built the same way on a street. If two houses look the exact same inside and out, are they the same house? No because they have different addresses. In the same way, our objects may look the same but if they have different locations in memory, they are different objects.
Lodash gives us the isEqual function to sidestep this behavior and compare the contents of two variables instead of their location in memory. So if we were to use this function for our first example, we'd get what you'd expect:
import { isEqual } from "lodash";
const objectOne = {
hello: "world",
};
const objectTwo = {
hello: "world",
};
const objectsAreEqual = isEqual(objectOne, objectTwo);
console.log(objectsAreEqual);
// true
The Fun Stuff
Now that we have a good foundation, let's jump into a real world scenario. The example below is a series of dropdowns representing application preferences. We'll pretend that all of these preferences share a single endpoint where the expected request body is an object containing the latest preferences.
Notice that as you toggle values, the save button becomes enabled. But if you adjust the local values to represent what was originally there without pressing Save, the save button button becomes disabled.
User Preferences
Theme
Light Mode
Subscription
Standard
Notifications
None
Autosave Interval
30 Seconds
Here I'll show the source code, but don't worry about grasping all of it up front. We'll tackle it piece by piece in the sections to come.
enum NotificationPreferences {
None,
Email,
Text,
}
enum AutosaveInterval {
ThirtySeconds,
OneMinute,
FiveMinutes,
}
interface IStagedChangesWithLodashExample1Props {
preferences: {
darkMode: boolean;
useProSubscription: boolean;
notificationPreference: NotificationPreferences;
autosaveInterval: AutosaveInterval;
};
}
export function StagedChangesWithLodashExample1(
props: IStagedChangedWithLodashExample1Props
) {
const [
localAndDatabasePreferences,
setLocalAndDatabasePreferences,
] = useState({
localPreferences: props.preferences,
databasePreferences: props.preferences,
});
function setLocalPreferences(
updatedLocalPreferences: any
// ignore the any here, my actual typing of Partial<IPreferences>
// messes up the code highlighting
) {
setLocalAndDatabasePreferences((previous) => {
return {
...previous,
localPreferences: {
...previous.localPreferences,
...updatedLocalPreferences,
},
};
});
}
function setDatabasePreferenceToMatchLocalPreference() {
setLocalAndDatabasePreferences((previous) => {
return {
...previous,
databasePreferences: previous.localPreferences,
};
});
}
function onChangeDarkModeOption(darkMode: boolean) {
setLocalPreferences({ darkMode });
}
function onChangeProSubscriptionOption(useProSubscription: boolean) {
setLocalPreferences({
useProSubscription,
});
}
function onChangeNotificationPreferences(
notificationPreference: NotificationPreferences
) {
setLocalPreferences({
notificationPreference,
});
}
function onChangeAutosaveInterval(autosaveInterval: AutosaveInterval) {
setLocalPreferences({
autosaveInterval,
});
}
const [isSaving, setIsSaving] = useState(false);
function onClickSave() {
setIsSaving(true);
}
useEffect(() => {
if (!isSaving) return;
let didCancel = false;
Api.preferences
.updatePreferences(localAndDatabasePreferences.localPreferences)
.then(() => {
if (didCancel) return;
setDatabasePreferenceToMatchLocalPreference();
})
.finally(() => {
if (didCancel) return;
setIsSaving(false);
});
return () => {
didCancel = true;
};
}, [isSaving]);
const noChangesDetected = isEqual(
localAndDatabasePreferences.databasePreferences,
localAndDatabasePreferences.localPreferences
);
return (
<div>
<Typography>User Preferences</Typography>
<Dropdown
options={darkModeOptions}
selectedOption={
localAndDatabasePreferences.localPreferences.darkMode
}
onChange={onChangeDarkModeOption}
label="Theme"
disabled={isSaving}
/>
<Dropdown
options={proSubscriptionOptions}
selectedOption={
localAndDatabasePreferences.localPreferences
.useProSubscription
}
onChange={onChangeProSubscriptionOption}
label="Subscription"
disabled={isSaving}
/>
<Dropdown
options={notificationPreferenceOptions}
selectedOption={
localAndDatabasePreferences.localPreferences
.notificationPreference
}
onChange={onChangeNotificationPreferences}
label="Notifications"
disabled={isSaving}
/>
<Dropdown
options={autosaveIntervalOptions}
selectedOption={
localAndDatabasePreferences.localPreferences
.autosaveInterval
}
onChange={onChangeAutosaveInterval}
label="Autosave Interval"
disabled={isSaving}
/>
<Button
onClick={onClickSave}
type="button"
isPrimary
disabled={noChangesDetected || isSaving}
showSpinner={isSaving}
>
Save
</Button>
</div>
);
}
Okay let's talk at a high level about what we're doing here. When the component is first initialized, we pass in the data returned to us from our api endpoint. We then create a piece of state with two separate keys, localPreferences and databasePreferences. At the start, the two objects are in sync meaning that the data represented in our UI and the data represented in our database are the exact same. Here's another look at that piece of the code:
const [localAndDatabasePreferences, setLocalAndDatabasePreferences] = useState({
localPreferences: props.preferences,
databasePreferences: props.preferences,
});
When a change is made to our preferences through the UI, we update just the local portion of our state. Here's what that looks like:
function setLocalPreferences(
updatedLocalPreferences: any
// ignore the any here, my actual typing of Partial<IPreferences>
// messes up the code highlighting
) {
setLocalAndDatabasePreferences((previous) => {
return {
...previous,
localPreferences: {
...previous.localPreferences,
...updatedLocalPreferences,
},
};
});
}
function setDatabasePreferenceToMatchLocalPreference() {
setLocalAndDatabasePreferences((previous) => {
return {
...previous,
databasePreferences: previous.localPreferences,
};
});
}
function onChangeDarkModeOption(darkMode: boolean) {
setLocalPreferences({ darkMode });
}
function onChangeProSubscriptionOption(useProSubscription: boolean) {
setLocalPreferences({
useProSubscription,
});
}
function onChangeNotificationPreferences(
notificationPreference: NotificationPreferences
) {
setLocalPreferences({
notificationPreference,
});
}
function onChangeAutosaveInterval(autosaveInterval: AutosaveInterval) {
setLocalPreferences({
autosaveInterval,
});
}
return (
<Dropdown
options={darkModeOptions}
selectedOption={
localAndDatabasePreferences.localPreferences.darkMode
}
onChange={onChangeDarkModeOption}
label="Theme"
disabled={isSaving}
/>
<Dropdown
options={proSubscriptionOptions}
selectedOption={
localAndDatabasePreferences.localPreferences
.useProSubscription
}
onChange={onChangeProSubscriptionOption}
label="Subscription"
disabled={isSaving}
/>
<Dropdown
options={notificationPreferenceOptions}
selectedOption={
localAndDatabasePreferences.localPreferences
.notificationPreference
}
onChange={onChangeNotificationPreferences}
label="Notifications"
disabled={isSaving}
/>
<Dropdown
options={autosaveIntervalOptions}
selectedOption={
localAndDatabasePreferences.localPreferences
.autosaveInterval
}
onChange={onChangeAutosaveInterval}
label="Autosave Interval"
disabled={isSaving}
/>
)
By only updating our local piece of state, it's easy to detect when our save button should be disabled. We use Lodash's isEqual method to compare our databasePreferences with our localPreferences . If they match, we know that nothing in our UI needs to be saved and we can safely disable the save button. If they don't match, we know that there are staged changes and we enable the save button. Here's a look at the relevant code again:
const noChangesDetected = isEqual(
localAndDatabasePreferences.databasePreferences,
localAndDatabasePreferences.localPreferences
);
return (
<Button
onClick={onClickSave}
type="button"
isPrimary
disabled={noChangesDetected || isSaving}
showSpinner={isSaving}
>
Save
</Button>
);
When the data is successfully saved, we override the databasePreferences slice of state to once again match the localPreferences slice of state. Here's that code again:
function setDatabasePreferenceToMatchLocalPreference() {
setLocalAndDatabasePreferences((previous) => {
return {
...previous,
databasePreferences: previous.localPreferences,
};
});
}
useEffect(() => {
if (!isSaving) return;
let didCancel = false;
Api.preferences
.updatePreferences(localAndDatabasePreferences.localPreferences)
.then(() => {
if (didCancel) return;
setDatabasePreferenceToMatchLocalPreference();
})
.finally(() => {
if (didCancel) return;
setIsSaving(false);
});
return () => {
didCancel = true;
};
}, [isSaving]);
To give you another visual on how this works, play around with the component below to see the state of our localPreferences, our databasePreferences, and the actual data in our database. One thing to note here is I'm assuming our request to update the data takes four seconds (two on the way there and two on the way back).
UI Local Preferences
{ "darkMode": false, "useProSubscription": false, "notificationPreference": 0, "autosaveInterval": 0 }
Database
{ "darkMode": false, "useProSubscription": false, "notificationPreference": 0, "autosaveInterval": 0 }
UI Database Preferences
{ "darkMode": false, "useProSubscription": false, "notificationPreference": 0, "autosaveInterval": 0 }
User Preferences
Theme
Light Mode
Subscription
Standard
Notifications
None
Autosave Interval
30 Seconds
Hopefully that gives you a clearer idea of how to handle this UI pattern in your applications. One thing to note is you should avoid this methodology for automatic updates. While it works for most cases with automatic updates, there's an important edge case that isn't handled. This pattern should only be used for explicit updates.
Thanks for reading this article. Before I finish, I wanted to give a shouted to some code I pulled in for the spinner on my save button. Great work from Luke Haas.