Autosave Form

Debounced auto-save with a status indicator. The isDirty flag is derived from a snapshot of the last saved state, so the UI can never drift out of sync with reality.

Live Demo

Draft Editor

Source

HTML + JavaScript
<div data-component="autosave-form">
    <span data-bind="saveStatusText"
          data-bind-class="saveStatusClass"></span>

    <input data-model="title" data-model-trim
           placeholder="Untitled Document">
    <textarea data-model="content"></textarea>
    <input data-model="tags" data-model-trim>
</div>

<script>
wildflower.component('autosave-form', {
    state: {
        title: '',
        content: '',
        tags: '',
        saving: false,
        lastSaved: null,
        // Snapshot of the last successfully saved state.
        // `isDirty` compares current state against this.
        snapshot: { title: '', content: '', tags: '' }
    },
    computed: {
        isDirty() {
            return this.title   !== this.snapshot.title
                || this.content !== this.snapshot.content
                || this.tags    !== this.snapshot.tags;
        },
        saveStatusText() {
            if (this.saving)  return 'Saving...';
            if (this.isDirty) return 'Unsaved changes';
            return 'Saved';
        },
        saveStatusClass() {
            if (this.saving)  return 'save-indicator save-saving';
            if (this.isDirty) return 'save-indicator save-dirty';
            return 'save-indicator save-saved';
        },
        lastSavedText() {
            return this.lastSaved
                ? 'Last saved: ' + this.lastSaved
                : 'Not yet saved';
        }
    },
    // onUpdate fires after any state change. data-model updates
    // state synchronously on every keystroke, so this runs on every
    // edit. We coalesce rapid calls into one save via setTimeout.
    onUpdate() {
        if (!this.isDirty || this.saving) return;
        clearTimeout(this._saveTimer);
        this._saveTimer = setTimeout(() => {
            this.saving = true;
            setTimeout(() => {
                // "Save" completed: snapshot current state so isDirty
                // flips back to false until the user types again.
                this.snapshot = {
                    title:   this.title,
                    content: this.content,
                    tags:    this.tags
                };
                this.saving = false;
                this.lastSaved = new Date().toLocaleTimeString();
            }, 500);
        }, 800);
    },
    destroy() {
        clearTimeout(this._saveTimer);
    }
});
</script>

Key Points

  • data-model updates state synchronously on every keystroke, so the "Unsaved changes" label appears instantly (the isDirty computed recomputes as soon as state changes)
  • onUpdate() is a component lifecycle hook that fires after any state change; we debounce the save inside it with setTimeout so rapid typing coalesces into one save 800ms after the user stops
  • isDirty is derived from a snapshot of the last saved state, not a manual flag you set, so it can never drift out of sync with reality
  • Snapshotting on save completion is the whole mechanism: once snapshot matches current state, isDirty flips back to false automatically
  • destroy() clears the save timer so a pending save doesn't fire against a removed component
  • placeholder="Untitled Document" on the title field disappears on first keystroke; state stays clean (empty string) instead of carrying boilerplate text the user has to delete