I have a Vue3 application for which I want to convey a specific type of UX where changes made to a resource are automatically saved (i.e. no save button). The application has several different types of editable resources and several different editor components for them, and I wanted to create an abstraction that would handle autosaving and that could simply be plugged inside of my editor components. As an additional requirement, some fields have to be debounced (e.g. text fields) whereas others are to be saved immediately to the server.
I'm interested in knowing what are the possible drawbacks of using the solution I'm providing here and if there's a better way.
The idea:
- create a polymorphic class
AutoSaveManager<T>
that handles auto-saving of an object of typeT
. - pass a function that updates an in-memory, local object, to be called anytime there's a change to that object (e.g. an input element bound to a field of that object emits an update)
- pass a function that updates a remote object, i.e. the record in the database. For example, a Pinia action that makes an API call
- wrap the remote function in a debouncer for the fields that need to be debounced, or flush it immediately if a no-debounce field is updated.
Code for the class:
/* eslint-disable @typescript-eslint/no-explicit-any */
import { debounce, DebouncedFunc } from "lodash";
type RemotePatchFunction<T> = (changes: Partial<T>) => Promise<void>;
type PatchFunction<T> = (changes: Partial<T>, reverting?: boolean) => void;
export type FieldList<T> = (keyof T)[];
enum AutoSaveManagerState {
UP_TO_DATE,
PENDING,
ERROR,
}
export class AutoSaveManager<T> {
instance: T;
unsavedChanges: Partial<T>;
beforeChanges: Partial<T>;
remotePatchFunction: DebouncedFunc<RemotePatchFunction<T>>;
localPatchFunction: PatchFunction<T>;
errorFunction?: (e: any) => void;
successFunction?: () => void;
cleanupFunction?: () => void;
debouncedFields: FieldList<T>;
revertOnFailure: boolean;
alwaysPatchLocal: boolean;
state: AutoSaveManagerState;
constructor(
instance: T,
remotePatchFunction: RemotePatchFunction<T>,
localPatchFunction: PatchFunction<T>,
debouncedFields: FieldList<T>,
debounceTime: number,
successFunction?: () => void,
errorFunction?: (e: any) => void,
cleanupFunction?: () => void,
revertOnFailure = false,
alwaysPatchLocal = true,
) {
this.instance = instance;
this.localPatchFunction = localPatchFunction;
this.remotePatchFunction = debounce(
this.wrapRemotePatchFunction(remotePatchFunction),
debounceTime,
);
this.debouncedFields = debouncedFields;
this.unsavedChanges = {};
this.beforeChanges = {};
this.successFunction = successFunction;
this.errorFunction = errorFunction;
this.cleanupFunction = cleanupFunction;
this.revertOnFailure = revertOnFailure;
this.alwaysPatchLocal = alwaysPatchLocal;
this.state = AutoSaveManagerState.UP_TO_DATE;
}
async onChange(changes: Partial<T>): Promise<void> {
this.state = AutoSaveManagerState.PENDING;
// record new change to field
this.unsavedChanges = { ...this.unsavedChanges, ...changes };
// make deep copy of fields about to change in case rollback becomes necessary
// (only for non-debounced fields as it would be disconcerting to roll back
// debounced changes like in text fields)
Object.keys(changes)
.filter(k => !this.debouncedFields.includes(k as keyof T))
.forEach(
k => (this.beforeChanges[k] = JSON.parse(JSON.stringify(this.instance[k]))),
);
if (this.alwaysPatchLocal) {
// instantly update in-memory instance
this.localPatchFunction(changes);
}
// dispatch update to backend
await this.remotePatchFunction(this.unsavedChanges);
if (Object.keys(changes).some(k => !this.debouncedFields.includes(k as keyof T))) {
// at least one field isn't to be debounced; call remote update immediately
await this.remotePatchFunction.flush();
}
}
private wrapRemotePatchFunction(
callback: RemotePatchFunction<T>,
): RemotePatchFunction<T> {
/**
* Wraps the callback into a function that awaits the callback first, and
* if it is successful, then empties the unsaved changes object
*/
return async (changes: Partial<T>) => {
try {
await callback(changes);
if (!this.alwaysPatchLocal) {
// update in-memory instance
this.localPatchFunction(changes);
}
// reset bookkeeping about recent changes
this.unsavedChanges = {};
this.beforeChanges = {};
this.state = AutoSaveManagerState.UP_TO_DATE;
// call user-supplied success callback
this.successFunction?.();
} catch (e) {
// call user-supplied error callback
this.errorFunction?.(e);
if (this.revertOnFailure) {
// roll back unsaved changes
this.localPatchFunction(this.beforeChanges, true);
}
this.state = AutoSaveManagerState.ERROR;
} finally {
this.cleanupFunction?.();
}
};
}
}
This class would be instantiated inside of an editor component like this:
this.autoSaveManager = new AutoSaveManager<MyEditableObject>(
this.modelValue,
async changes => {
await this.store.someAction({ // makes an API call to the server to actually update the object
id: this.modelValue.id
changes,
});
this.saving = false;
},
changes => {
this.saving = true;
this.savingError = false;
this.store.someAction({ // only updates the local object in the store
id: this.modelValue.id
changes;
})
},
["body", "title"], // these are text fields and need to be debounced
2500, // debounce time
undefined, // no cleanup function
() => { // function to call in case of error
this.saving = false;
this.savingError = true;
},
);
Then, inside of a template, you'd use it like this:
<TextInput
:modelValue="modelValue.title"
@update:modelValue="autoSaveManager.onChange({ title: $event })"
/>
Is there anything I can do to improve on this idea? Should I use a different approach altogether for auto-saving in Vue?
watch(model, debouce(model.save))
is a pseudo code for how it could work. There may be a good argument to build a more feature rich autosave helper. – Subterranean