<!DOCTYPE html>
<html>
<head>
<title>Contacts</title>
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= javascript_importmap_tags %>
</head>
<body>
<main class="container">
<%= yield %>
</main>
<%# Persistent top-level frame the modal renders into %>
<%= turbo_frame_tag :modal %>
</body>
</html>
class ContactsController < ApplicationController
def index
@contacts = Contact.order(created_at: :desc)
end
def new
@contact = Contact.new
end
def edit
@contact = Contact.find(params[:id])
end
def create
@contact = Contact.new(contact_params)
if @contact.save
respond_to do |format|
format.turbo_stream
format.html { redirect_to contacts_path }
end
else
render :new, status: :unprocessable_entity
end
end
private
def contact_params
params.require(:contact).permit(:name, :email, :phone)
end
end
<%= turbo_frame_tag :modal do %>
<%= render "modal" do %>
<h2>New contact</h2>
<%= form_with model: @contact do |f| %>
<% if @contact.errors.any? %>
<div class="errors">
<%= pluralize(@contact.errors.count, "error") %> prevented saving.
</div>
<% end %>
<div class="field">
<%= f.label :name %>
<%= f.text_field :name %>
</div>
<div class="field">
<%= f.label :email %>
<%= f.email_field :email %>
</div>
<div class="field">
<%= f.label :phone %>
<%= f.telephone_field :phone %>
</div>
<div class="actions">
<%= f.submit "Save" %>
<a href="#" data-action="modal#close">Cancel</a>
</div>
<% end %>
<% end %>
<% end %>
<%# Close the modal by emptying its frame %>
<%= turbo_stream.update :modal, "" %>
<%# Insert the new record at the top of the list %>
<%= turbo_stream.prepend :contacts do %>
<%= render partial: "contacts/contact", locals: { contact: @contact } %>
<% end %>
This snippet shows the canonical Hotwire pattern for a modal that lazily loads a form into a Turbo Frame, submits it, and then dismisses itself with a Turbo Stream response — no custom JavaScript state machine and no full page reload.
The layout in application.html.erb declares a single persistent, top-level turbo_frame_tag :modal. Because it lives outside the main content frame, any link that targets data-turbo-frame="modal" will render its response into this shared slot. A trigger link uses data: { turbo_frame: :modal } so clicking it fetches the new action and paints only that frame, leaving the rest of the page untouched.
In contacts_controller.rb, the new and edit actions render normally; the frame boundary in the view decides what content lands in the modal. The important work happens in create. On a validation failure it re-renders the new template with status: :unprocessable_entity, which Turbo requires so the frame is updated with the error-laden form instead of being ignored. On success it responds to turbo_stream, rendering create.turbo_stream.erb.
That stream template does two things atomically: turbo_stream.update :modal, "" empties the modal frame — the dismissal — and turbo_stream.prepend :contacts inserts the freshly created row into the list. Because both actions travel in one response, the modal closes and the list updates in a single round trip, keeping the UI consistent without a redirect.
The partial _modal.html.erb wraps the shared frame in a dialog shell controlled by a small Stimulus controller referenced via data-controller="modal". The new.html.erb view renders the form inside turbo_frame_tag :modal; the matching frame id is what lets the response replace the modal contents. The form itself needs no special attributes because it is already inside the targeted frame, so Turbo scopes its submission there automatically.
A common pitfall is forgetting status: :unprocessable_entity on re-render, which causes Turbo to silently discard the response and leave a stale form. Another is mismatched frame ids: the id in the layout, the trigger, and the view must all be modal. This approach is ideal when a form is incidental to the page and should feel like an overlay rather than a navigation.
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.