javascript ruby erb 87 lines · 3 tabs

Stimulus: autosave draft with Turbo-friendly requests

Shared by codesnips Jan 2026
3 tabs
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static values = {
    url: String,
    delay: { type: Number, default: 800 },
    method: { type: String, default: "post" }
  }

  connect() {
    this.timer = null
  }

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

  scheduleSave() {
    if (this.timer) clearTimeout(this.timer)
    this.timer = setTimeout(() => this.save(), this.delayValue)
  }

  async save() {
    const form = this.element.closest("form") || this.element
    const body = new FormData(form)

    const response = await fetch(this.urlValue, {
      method: this.methodValue,
      headers: {
        "Accept": "text/vnd.turbo-stream.html",
        "X-CSRF-Token": this.csrfToken
      },
      body
    })

    if (!response.ok) return
    Turbo.renderStreamMessage(await response.text())
  }

  get csrfToken() {
    const meta = document.querySelector("meta[name='csrf-token']")
    return meta ? meta.content : ""
  }
}
3 files · javascript, ruby, erb Explain with highlit

This snippet shows a small autosave feature built the Hotwire way: a Stimulus controller that quietly persists form drafts as the user types, paired with the Rails controller that answers those requests with Turbo Streams. The goal is to save work without a visible submit button while keeping the server in charge of rendering feedback, which is the core Hotwire philosophy.

The autosave_controller.js tab defines the client behaviour. It reads three values — url, delay, and method — through Stimulus's static values API, so the same controller can be reused across forms with different endpoints. The input action is debounced through scheduleSave, which clears any pending setTimeout before setting a new one; this collapses a burst of keystrokes into a single request after the user pauses, avoiding a flood of writes. save serializes the form with FormData and sends it via fetch, deliberately setting Accept: text/vnd.turbo-stream.html so Rails knows to respond with a stream rather than a redirect. The CSRF token is pulled from the csrf-token meta tag, because a raw fetch bypasses Rails' automatic form-authenticity handling and would otherwise be rejected.

Crucially, save does not touch the DOM itself. Instead it hands the response body to Turbo.renderStreamMessage, letting the server-rendered stream update a status indicator or fill in a hidden draft_id field. This keeps rendering logic on the server and out of the JavaScript. The disconnect lifecycle hook cancels any in-flight timer so a navigation away does not fire a stray save.

The DraftsController tab is the server half. upsert finds or builds a draft scoped to current_user, updates it, and responds in a respond_to block: Turbo Stream requests receive autosave.turbo_stream.erb, while a plain HTML fallback still works if JavaScript is disabled. Scoping through the association prevents users from writing to each other's drafts.

The autosave.turbo_stream.erb tab replaces a #draft_status element with a timestamp and updates the hidden id field so subsequent saves target the same record. The trade-off of this pattern is added request volume and eventual-consistency between the visible form and the stored draft; the debounce and idempotent upsert keep both manageable. It is a good fit for long forms, comment editors, or anything where losing typed text would frustrate users.


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
typescript
export interface RetryOptions {
  retries: number;
  baseMs: number;
  maxMs: number;
  signal?: AbortSignal;
  onRetry?: (attempt: number, delay: number, err: unknown) => void;

Exponential backoff with jitter for retries

typescript reliability retry
by codesnips 2 tabs
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

Share this code

Here's the card — post it anywhere.

Stimulus: autosave draft with Turbo-friendly requests — share card
Link copied