Handle autosave in Vue3
Asked Answered
S

0

6

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 type T.
  • 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?

Schoolhouse answered 29/11, 2022 at 23:16 Comment(1)
I think there's plenty of modeling / api service software that handles most of the heavy lifting in your auto save class. save/patch (other methods), managing state, reactive values that can be used in template for button loaders, etc... I personally use feathers-pinia (since i'm a feathers api user). If you decouple the idea of your services from the autosave, the autosave functionality becomes significantly simpler. 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

© 2022 - 2024 — McMap. All rights reserved.