<%= form_with url: autosave_draft_path,
method: :post,
local: false,
data: {
controller: "autosave",
autosave_target: "form",
autosave_debounce_value: 800,
action: "input->autosave#schedule"
} do |f| %>
<%= hidden_field_tag :draftable_key, @draftable_key %>
<div class="field">
<%= f.label :title %>
<%= f.text_field :title, value: @draft.title %>
</div>
<div class="field">
<%= f.label :body %>
<%= f.text_area :body, value: @draft.body, rows: 12 %>
</div>
<p class="autosave-status"
data-autosave-target="status"
aria-live="polite">
<%= @draft.persisted? ? "Saved #{time_ago_in_words(@draft.updated_at)} ago" : "Not saved yet" %>
</p>
<% end %>
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["form", "status"]
static values = { debounce: { type: Number, default: 800 } }
connect() {
this.timer = null
}
disconnect() {
if (this.timer) clearTimeout(this.timer)
}
schedule() {
if (this.timer) clearTimeout(this.timer)
this.setStatus("Saving\u2026")
this.timer = setTimeout(() => this.save(), this.debounceValue)
}
save() {
this.timer = null
this.formTarget.requestSubmit()
}
setStatus(text) {
if (this.hasStatusTarget) this.statusTarget.textContent = text
}
}
class DraftsController < ApplicationController
before_action :authenticate_user!
def autosave
draft = upsert_draft
if draft.save
respond_to do |format|
format.turbo_stream do
render turbo_stream: turbo_stream.replace(
"autosave_status",
partial: "drafts/status",
locals: { draft: draft }
)
end
end
else
head :unprocessable_entity
end
end
private
def upsert_draft
draft = current_user.drafts.find_or_initialize_by(
draftable_key: params[:draftable_key]
)
draft.assign_attributes(draft_params)
draft
end
def draft_params
params.permit(:title, :body)
end
end
This snippet shows a lightweight autosave-draft feature built with Hotwire, where an in-progress form is persisted to the server without an explicit save button. The pattern solves a common problem in long forms — losing work on an accidental navigation or crash — by treating every meaningful keystroke as a candidate save while avoiding the network storm that naive per-keystroke saving would produce.
The _draft_form.html.erb tab wires the DOM to Stimulus using data attributes. The form_with helper points at autosave_draft_path with local: false so Turbo intercepts submission and processes the response as a Turbo Stream rather than a full navigation. The data-controller, data-action, and data-autosave-target attributes bind the form to the autosave controller: input->autosave#schedule fires on any field change, and a #status target displays feedback. Rendering the current @draft values means a reload restores whatever was last persisted.
The autosave_controller.js tab is the debounce engine. Rather than posting on every keystroke, schedule() clears any pending timer and sets a new one via setTimeout, so the actual save() only runs after debounceValue milliseconds of quiet — collapsing a burst of typing into one request. save() calls this.formTarget.requestSubmit(), which lets Turbo handle the POST and swap the response. The disconnect() hook clears the timer to prevent a stray save firing after the element leaves the DOM, a subtle leak that debounced controllers often miss.
The DraftsController tab persists the payload. upsert finds or initializes a Draft scoped to the current user and a draftable key, so each form has exactly one draft row rather than accumulating history. On success it responds with turbo_stream, replacing only the status region — no page reload, no flash of unstyled content. The head :unprocessable_entity fallback keeps invalid states from silently appearing saved.
The trade-off is that autosaved drafts are eventually-consistent, not transactional: a debounce window means the last few characters may be unsaved if the tab closes instantly. For most editors that window is acceptable, and it can be tightened or paired with a beforeunload flush when stronger guarantees are needed.
Related snips
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
<form data-controller="query-sync" data-action="change->query-sync#apply">
<select name="status" class="rounded border p-2">
<option value="">Any</option>
<option value="open">Open</option>
<option value="closed">Closed</option>
</select>
Filter UI that syncs query params via Stimulus (no front-end router)
Share this code
Here's the card — post it anywhere.