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
}
}
}
<%= form_with model: @post,
data: {
controller: "autosave",
autosave_key_value: "post_draft_#{@post.id || 'new'}",
autosave_interval_value: 3000,
action: "turbo:submit-end->autosave#clearDraft"
} do |f| %>
<div class="mb-2 text-sm text-gray-600">
<span data-autosave-target="status"></span>
</div>
<div class="form-group">
<%= f.label :title %>
<%= f.text_field :title,
class: "form-input",
data: { autosave_target: "field" } %>
</div>
<div class="form-group">
<%= f.label :body %>
<%= f.text_area :body,
rows: 20,
class: "form-input",
data: { autosave_target: "field" } %>
</div>
<%= f.submit "Publish", class: "btn btn-primary" %>
<% end %>
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.