erb ruby 89 lines · 4 tabs

Turbo Frames modal: load, submit, then close via stream

Shared by codesnips Jan 2026
4 tabs
<!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>
4 files · erb, ruby Explain with highlit

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

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
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
erb
<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)

rails hotwire stimulus
by Henry Kim 2 tabs

Share this code

Here's the card — post it anywhere.

Turbo Frames modal: load, submit, then close via stream — share card
Link copied