erb javascript ruby 91 lines · 3 tabs

Autosave drafts with Stimulus + Turbo (lightweight)

Shared by codesnips Jan 2026
3 tabs
<%= 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 %>
3 files · erb, javascript, ruby Explain with highlit

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

ruby
class CommentsController < ApplicationController
  before_action :set_post

  def create
    @comment = @post.comments.build(comment_params)

System test: asserting Turbo Stream responses

rails hotwire turbo
by codesnips 4 tabs
ruby
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

rails activerecord patterns
by Alex Kumar 1 tab
ruby
module Api
  module V1
    class UsersController < BaseController
      def show
        user = User.includes(:profile).find(params[:id])

ETags for conditional requests and caching

rails caching http-caching
by Alex Kumar 1 tab
ruby
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

rails turbo hotwire
by codesnips 4 tabs
ruby
require "csv"

class PeopleCsvStream
  include Enumerable

  HEADERS = %w[id full_name email signed_up_at plan].freeze

Resilient CSV Export as a Streamed Response

rails performance streaming
by codesnips 3 tabs
erb
<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)

rails hotwire stimulus
by Henry Kim 2 tabs

Share this code

Here's the card — post it anywhere.

Autosave drafts with Stimulus + Turbo (lightweight) — share card
Link copied