class Comment < ApplicationRecord
belongs_to :post
belongs_to :author, class_name: "User"
has_rich_text :body
validates :body, presence: true
after_create_commit do
broadcast_append_to(
post,
target: "comments",
partial: "comments/comment",
locals: { comment: self }
)
end
def excerpt(length = 120)
body.to_plain_text.truncate(length)
end
end
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
@comment.author = current_user
if @comment.save
respond_to do |format|
format.turbo_stream do
render turbo_stream: [
turbo_stream.append("comments", partial: "comments/comment", locals: { comment: @comment }),
turbo_stream.replace("new_comment", partial: "comments/form", locals: { post: @post, comment: @post.comments.build })
]
end
format.html { redirect_to @post }
end
else
render partial: "comments/form",
locals: { post: @post, comment: @comment },
status: :unprocessable_entity
end
end
private
def set_post
@post = Post.find(params[:post_id])
end
def comment_params
params.require(:comment).permit(:body)
end
end
<%= turbo_stream_from @post %>
<section class="comments-panel">
<div id="comments">
<%= render @post.comments %>
</div>
<%= turbo_frame_tag "new_comment" do %>
<%= render "comments/form", post: @post, comment: @post.comments.build %>
<% end %>
</section>
<%# comments/_form.html.erb %>
<%= form_with model: [post, comment], id: "new_comment_form" do |f| %>
<% if comment.errors.any? %>
<div class="field-errors">
<%= comment.errors.full_messages.to_sentence %>
</div>
<% end %>
<%= f.rich_text_area :body, placeholder: "Write a comment\u2026" %>
<%= f.submit "Post comment" %>
<% end %>
This snippet shows how a rich-text comment form built with Action Text can live inside a Turbo Frame and append new comments without a full page reload. The three files tell one story: the Comment model that persists the rich text and broadcasts updates, the CommentsController that handles the frame-scoped create action, and the comments view that wires up the frame, the list target, and the Trix editor.
In Comment the has_rich_text :body association is what makes Action Text work: the actual markup is stored in a separate action_text_rich_texts row and referenced polymorphically, so the model stays thin while embeds and attachments are handled for free. The model also calls broadcast_append_to after commit, targeting the DOM id comments. This means any browser subscribed to the post stream receives a <turbo-stream action="append"> and the new comment appears live, which matters when several people comment on the same post concurrently.
The CommentsController is deliberately format-aware. On a successful create it responds to turbo_stream by rendering a stream that both appends the comment and resets the form, while the HTML fallback keeps the controller usable without JavaScript. Because the form posts from inside a Turbo Frame, Turbo automatically scopes the response to that frame unless a stream is returned; returning turbo_stream lets the response escape the frame and touch the shared list. On validation failure the controller re-renders the frame with status: :unprocessable_entity, the status Turbo requires to actually swap in an error state rather than silently ignoring it.
The comments view establishes the contract the other files rely on. turbo_frame_tag "new_comment" isolates the form so submissions and validation errors stay local, while the separate div id="comments" is the append target named by both the broadcast and the stream. The turbo_stream_from @post subscription opens the WebSocket channel the model broadcasts to. A common pitfall is mismatched DOM ids: the broadcast target, the stream target, and the list container must all agree on comments, or appends land nowhere. Together these pieces deliver an SPA-like commenting experience with almost no custom JavaScript.
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.