class DocumentsController < ApplicationController
before_action :set_document, only: :destroy
def destroy
@document.discard!
respond_to do |format|
format.turbo_stream
format.html { redirect_to documents_path, notice: "Document deleted." }
end
end
def restore
@document = Document.unscoped.find(params[:id])
@document.undiscard!
respond_to do |format|
format.turbo_stream
format.html { redirect_to documents_path, notice: "Document restored." }
end
end
private
def set_document
@document = Document.find(params[:id])
end
end
class Document < ApplicationRecord
include Discard::Model
belongs_to :owner, class_name: "User"
default_scope { kept }
validates :title, presence: true
validates :title, uniqueness: { scope: :owner_id },
unless: :discarded?
def to_row_id
"document_#{id}"
end
end
<%= turbo_stream.remove @document.to_row_id %>
<%= turbo_stream.prepend "flash" do %>
<div class="flash flash--notice" role="status">
<span>Document deleted.</span>
<%= button_to "Undo",
restore_document_path(@document),
method: :patch,
class: "flash__undo",
form: { data: { turbo_stream: true } } %>
</div>
<% end %>
<%= turbo_stream.prepend "documents" do %>
<%= render partial: "documents/document", locals: { document: @document } %>
<% end %>
<%= turbo_stream.prepend "flash" do %>
<div class="flash flash--success" role="status">
<span><%= @document.title %> was restored.</span>
</div>
<% end %>
This snippet shows how a Rails controller pairs a soft-delete with a Turbo Stream flash that offers a one-click undo, so a mistaken deletion can be reversed without a full-page round trip.
The documents_controller.rb tab handles both directions of the flow. destroy calls discard! rather than destroy!, which sets a discarded_at timestamp instead of removing the row. Because the record still exists, a restore is always possible. The response renders a Turbo Stream that removes the row from the list (turbo_stream.remove) and prepends a flash into the flash frame. That flash carries an undo link pointing at the member restore route. The restore action calls undiscard!, clears the timestamp, and streams the row back into position with turbo_stream.prepend plus a confirmation flash. Note how each action responds to format.turbo_stream while keeping an format.html fallback with redirect_to, so the feature degrades gracefully when JavaScript is unavailable.
The Document model uses the discard gem's Discard::Model concern, which supplies discard!, undiscard!, and the kept/discarded scopes. Setting default_scope { kept } means ordinary queries never see soft-deleted rows, which is why restore must load the record through unscoped — the default scope would otherwise raise RecordNotFound for a discarded document. This is a classic pitfall with soft-delete: the scope that hides deleted rows also hides them from the very code trying to bring them back.
The destroy.turbo_stream.erb and restore.turbo_stream.erb tabs are the view halves. They use turbo_stream helpers with partials so the same _document and _flash partials render everywhere. The undo link uses button_to with method: :patch so it issues a real state-changing request rather than a GET.
The trade-off is that discarded rows accumulate and need periodic purging, and unique indexes must account for discarded_at to allow re-creating a "deleted" record. In exchange, undo becomes trivial and auditable, and the UI stays responsive because only two small stream fragments cross the wire.
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.