Stimulus controller for autosaving form drafts

Jordan Lee Jan 2026
2 tabs
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["field", "status"]
  static values = {
    key: String,
    interval: { type: Number, default: 5000 }
  }

  connect() {
    this.restoreDraft()
    this.setupAutosave()
  }

  disconnect() {
    if (this.saveTimeout) clearTimeout(this.saveTimeout)
  }

  setupAutosave() {
    this.fieldTargets.forEach(field => {
      field.addEventListener('input', () => this.scheduleSave())
    })
  }

  scheduleSave() {
    if (this.saveTimeout) clearTimeout(this.saveTimeout)

    this.saveTimeout = setTimeout(() => {
      this.saveDraft()
    }, this.intervalValue)
  }

  saveDraft() {
    const formData = {}

    this.fieldTargets.forEach(field => {
      formData[field.name] = field.value
    })

    localStorage.setItem(this.keyValue, JSON.stringify({
      data: formData,
      savedAt: new Date().toISOString()
    }))

    this.updateStatus('Draft saved')
  }

  restoreDraft() {
    const draft = localStorage.getItem(this.keyValue)
    if (!draft) return

    const { data, savedAt } = JSON.parse(draft)

    // Only restore if draft is less than 24 hours old
    const savedDate = new Date(savedAt)
    const hoursSince = (Date.now() - savedDate.getTime()) / 1000 / 60 / 60

    if (hoursSince > 24) {
      localStorage.removeItem(this.keyValue)
      return
    }

    // Restore form data
    this.fieldTargets.forEach(field => {
      if (data[field.name]) {
        field.value = data[field.name]
      }
    })

    this.updateStatus(`Draft restored from ${new Date(savedAt).toLocaleTimeString()}`)
  }

  clearDraft() {
    localStorage.removeItem(this.keyValue)
    this.updateStatus('')
  }

  updateStatus(message) {
    if (this.hasStatusTarget) {
      this.statusTarget.textContent = message
    }
  }
}
2 files · javascript, erb Explain with highlit

Losing form data due to browser crashes or accidental navigation is frustrating. An autosave controller periodically saves form state to localStorage and restores it on page load. I debounce the save operation to avoid excessive writes and clear the draft when the form successfully submits. This pattern is essential for long-form content like blog posts or applications. I also show a visual indicator when autosave is active and the timestamp of the last save. For authenticated users, I can enhance this by saving drafts server-side via background requests. The key is balancing save frequency with user expectations—too frequent feels janky, too infrequent risks data loss.