let installed = false;
function notify(message, level = "error") {
document.dispatchEvent(
new CustomEvent("flash:show", { detail: { message, level } })
);
}
function onBeforeFetchResponse(event) {
const { fetchResponse } = event.detail;
const status = fetchResponse.response.status;
if (status >= 500) {
event.preventDefault();
notify("Something went wrong on our end. Please try again.");
} else if (status === 429) {
event.preventDefault();
notify("You're going too fast — slow down for a moment.", "warning");
}
}
function onFetchRequestError(event) {
event.preventDefault();
notify("Network error. Check your connection and retry.");
}
function onLoad() {
document.dispatchEvent(new CustomEvent("flash:clear"));
}
export function installTurboErrorHandler() {
if (installed) return;
installed = true;
document.addEventListener("turbo:before-fetch-response", onBeforeFetchResponse);
document.addEventListener("turbo:fetch-request-error", onFetchRequestError);
document.addEventListener("turbo:load", onLoad);
}
installTurboErrorHandler();
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["message"];
static values = { timeout: { type: Number, default: 5000 } };
connect() {
this.dismissTimer = null;
this.boundShow = this.show.bind(this);
this.boundClear = this.dismiss.bind(this);
document.addEventListener("flash:show", this.boundShow);
document.addEventListener("flash:clear", this.boundClear);
}
disconnect() {
document.removeEventListener("flash:show", this.boundShow);
document.removeEventListener("flash:clear", this.boundClear);
if (this.dismissTimer) clearTimeout(this.dismissTimer);
}
show(event) {
const { message, level } = event.detail;
this.messageTarget.textContent = message;
this.element.dataset.level = level || "error";
this.element.hidden = false;
if (this.dismissTimer) clearTimeout(this.dismissTimer);
this.dismissTimer = setTimeout(() => this.dismiss(), this.timeoutValue);
}
dismiss() {
this.element.hidden = true;
if (this.dismissTimer) {
clearTimeout(this.dismissTimer);
this.dismissTimer = null;
}
}
}
import "@hotwired/turbo-rails";
import { Application } from "@hotwired/stimulus";
import FlashController from "./controllers/flash_controller";
const application = Application.start();
application.register("flash", FlashController);
// Imported for its side effect: attaches the global Turbo error listeners.
import "./turbo_error_handler";
export { application };
Turbo Drive turns a server-rendered Rails app into an SPA-like experience by intercepting link clicks and form submissions, then fetching and swapping <body> over the network. Because navigation happens through fetch rather than a full browser page load, the usual browser affordances — the error page for a failed request, the loading spinner in the tab — are bypassed. This snippet wires up a small runtime that reattaches those affordances by listening to the Turbo Drive lifecycle events.
In turbo_error_handler.js, the core idea is that Turbo dispatches DOM events at each stage of a visit. turbo:before-fetch-response fires with the raw fetchResponse before the DOM is touched, which is the right moment to inspect response.status. Any status at or above 500 is treated as a hard failure: the handler stops the default rendering with event.preventDefault() so Turbo does not swap in an error HTML page, and instead surfaces a toast. A turbo:fetch-request-error listener covers the case where the network itself fails and no response arrives at all. turbo:load is used to clear transient state once a page successfully renders.
A subtle point is idempotency: Turbo emits turbo:load on every visit, so the module guards attachment with an installed flag to avoid registering duplicate listeners across navigations. The handler is deliberately attached to document once at import time rather than inside a controller.
In flash_controller.js, a Stimulus controller renders the toast the handler asks for. It exposes show via a small custom event bridge so plain modules can talk to Stimulus without holding a reference to the controller instance, and it auto-dismisses using a setTimeout cleared in disconnect to prevent leaks when Turbo tears down the element.
application.js shows the wiring order that matters: Turbo and Stimulus start first, then the error handler is imported for its side effect. The trade-off of this pattern is that global handlers are coarse — they cannot easily know which UI triggered a request — so it favors broad resilience over per-action messaging. It is the right reach when an app needs consistent failure feedback across many Turbo visits without sprinkling try/catch everywhere.
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.