module FlashHelper
FLASH_CLASSES = {
"notice" => "flash--success",
"alert" => "flash--error",
"error" => "flash--error"
}.freeze
def flash_class(type)
FLASH_CLASSES.fetch(type.to_s, "flash--info")
end
def turbo_flash
flash.map do |type, message|
turbo_stream.append "flash", partial: "shared/flash",
locals: { type: type, message: message }
end.join.html_safe
end
end
<%# app/views/shared/_flash.html.erb %>
<div id="flash" class="flash-container">
<%# messages are appended here by turbo streams %>
</div>
<%# app/views/shared/_flash_message.html.erb %>
<div id="<%= dom_id(type, :flash) %>"
class="flash <%= flash_class(type) %>"
role="alert"
data-controller="removals"
data-removals-delay-value="4000">
<span class="flash__text"><%= message %></span>
<button type="button"
class="flash__close"
data-action="removals#remove">×</button>
</div>
class ArticlesController < ApplicationController
before_action :set_article, only: :destroy
def create
@article = Article.new(article_params)
respond_to do |format|
if @article.save
flash.now[:notice] = "Article published."
format.turbo_stream do
render turbo_stream: [
turbo_stream.prepend("articles", partial: "articles/article", locals: { article: @article }),
turbo_flash
]
end
format.html { redirect_to @article, notice: "Article published." }
else
flash.now[:alert] = @article.errors.full_messages.to_sentence
format.turbo_stream { render turbo_stream: turbo_flash, status: :unprocessable_entity }
format.html { render :new, status: :unprocessable_entity }
end
end
end
def destroy
@article.destroy
flash.now[:notice] = "Article deleted."
respond_to do |format|
format.turbo_stream do
render turbo_stream: [
turbo_stream.remove(dom_id(@article)),
turbo_flash
]
end
format.html { redirect_to articles_path, notice: "Article deleted." }
end
end
private
def set_article
@article = Article.find(params[:id])
end
def article_params
params.require(:article).permit(:title, :body)
end
end
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = { delay: { type: Number, default: 5000 } }
connect() {
if (this.delayValue > 0) {
this.timeout = setTimeout(() => this.remove(), this.delayValue)
}
}
disconnect() {
clearTimeout(this.timeout)
}
remove() {
this.element.classList.add("flash--leaving")
this.element.addEventListener("transitionend", () => this.element.remove(), { once: true })
}
}
This snippet shows how a Rails app can render flash messages over Turbo Stream responses without writing any custom JavaScript. The pattern relies on Turbo's built-in stream actions (append, update, remove) targeting a stable DOM container, so ordinary controller actions that respond with turbo_stream can push toasts into the page as a side effect of any create/update/destroy.
The flash partial defines the visual container #flash plus one partial per message. Each toast carries a data-controller="removals" hook whose only job is to auto-dismiss after a delay — this is a tiny, generic Stimulus controller, not feature-specific glue, and it degrades gracefully if JS is disabled since the message still renders. Giving each toast a dom_id based on the flash type keeps replacements idempotent.
The FlashHelper centralizes the mapping from Rails flash keys (:notice, :alert) to CSS classes via flash_class, and exposes turbo_flash which wraps turbo_stream.append "flash" for every pending flash entry. Rendering the partial through the helper means both full-page loads and stream responses share exactly one template, avoiding drift between the two rendering paths.
In ArticlesController, the create action responds to turbo_stream by concatenating two streams: one that prepends the new article row and one produced by turbo_flash. Because flash.now is used, the message lives only for this response and is not persisted into the session — important for stream responses, which do not perform a redirect and would otherwise leave a stale flash for the next request. The destroy action follows the same shape, removing the row and emitting a confirmation toast.
The main trade-off is that the #flash container must already exist in the layout for append to find a target; if it is missing, Turbo silently drops the stream. The approach shines when an app already uses Turbo for CRUD and wants consistent feedback without a bespoke notification stack. Pitfalls to watch include double-rendering flashes when a redirect is mixed with a stream, and forgetting flash.now, which reintroduces the classic "message shows one action too late" bug.
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.