May 30, 2024
•Last updated May 31, 2024
Hotwire Comments with Ruby on Rails
Hotwire is a library that brings real-time functionality to your Rails applications. By integrating Hotwire-enabled comments, you can create a more interactive and immersive experience for your readers. That’s our focus today.
With Hotwire, we can enhance a typical blob comment with Rails in the following ways:
- Enable real-time commenting without page reloads
- Display new comments as they are posted.
- Update comment counts dynamically.
- Enhance user engagement and encourage discussion.
- Delete and edit comments without reloading pages
Hotwire is built on top of Turbo, a library that provides a seamless user experience by minimizing page reloads and optimizing performance. By leveraging Hotwire's features, you can take your Rails blog to the next level and provide a modern, responsive commenting system.
What I hope you'll learn in this tutorial:
This tutorial will guide you through adding Hotwire-enabled comments to a simple Rails blog application. You'll learn how to:
- Create a basic blog with Rails 7.1
- Create a comment model and controller.
- Build a comment form with Hotwire.
- Display and update comments in real time.
Some prerequisites:
- A Rails 7 application (Hotwire is compatible with Rails 7 and later)
- Basic knowledge of Rails and JavaScript
Let's get started!
Create a new Rails 7.1 application.
Rails 7+ applications enable Hotwire by default. This tutorial assumes you’re using the latest version. You can add Hotwire to an older application, but it requires some configuration that I won’t cover here. You’ll need a few things added to your legacy Rails apps that inline turbo-rails and stimulus-rails
rails new hotwire_blog
Rails UI
In this guide, I’m using my new project Rails UI. It’s a way to shortcut adding UI to your rails application. If you need help with design, I recommend giving it a shot. You can skip using Rails UI entirely, but be sure to generate a User
model before the Post
and Comment
models.
Add the gem to your project’s Gemfile to install Rails UI.
# Gemfile
gem "railsui", github: "getrailsui/railsui", branch: "main"
Then run bundle install.
bundle install
And finally, run the Rails UI installer.
rails railsui:install
When that process completes, boot your server.
bin/dev
Head to localhost:3000 to see the new splash screen that ships with Rails UI. Click the Configure button to set up your app. This process takes no more than 2 minutes.
Rails UI Configuration
I’ll name the app Hotwire Blog
and add a sample support email. Finally, I’ll select the Hound
template and click the install button. This will install any assets associated with the template and build a custom component library for integrated components, all built with Tailwind CSS.
Generate the blog
Generating a blog with Rails is pretty straightforward. It’s how the framework was first demoed way back when. We’ll leverage the built-in generators to do this.
Remember, when using Rails UI like I am, we get the User
model for free.
Let’s start with blog posts. The scaffold generates the full CRUD resources for a given table. We don’t need everything, but you’ll find that Rails UI comes with these pre-formatted, which is a big time saver.
rails g scaffold Post title:string content:text user:references
There’s not much more to it than that for posts 👏.
Next, I’ll add the comments. Because the comments will live below the posts#show
view, we don’t need the full scaffold. I like to approach it a little more manually.
Generate a comment model.
rails g model Comment content:text post:references user:references
We have a simple comment column and a post_id column. Comments need to reference specific posts, so the shorthand post:references
should do the trick for us automatically.
Update the relationships
We’ll need to append a new relationship to comments in our post model.
# app/models/post.rb
class Post < ApplicationRecord
belongs_to :user
has_many :comments, dependent: :destroy
end
and in the comments, the model
# app/models/comment.rb
class Comment < ApplicationRecord
belongs_to :post
belongs_to :user
end
and finally, the user model
# app/models/user.rb
class User < ApplicationRecord
has_many :posts, dependent: :destroy
has_many :comments, dependent: :destroy
end
Add some validations for good measure
We don’t want any blank comments saved in the database, so we can add validations to force the end user to add content.
class Comment < ApplicationRecord
belongs_to :user
belongs_to :post
validates :content, presence: true
end
And the same for the post. Only with a post, we’ll require both Title and Content fields.
class Post < ApplicationRecord
belongs_to :user
has_many :comments, dependent: :destroy
validates :title, :content, presence: true
end
Update root route
When installed, Rails UI ships with custom routing. Let’s make the new root path the posts#index
view.
Rails.application.routes.draw do
resources :posts
if Rails.env.development? || Rails.env.test?
mount Railsui::Engine, at: "/railsui"
end
# Inherits from Railsui::PageController#index
# To overide, add your own page#index view or change to a new root
# Visit the start page for Rails UI any time at /railsui/start
# root action: :index, controller: "railsui/page"
devise_for :users
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
# Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
# Can be used by load balancers and uptime monitors to verify that the app is live.
get "up" => "rails/health#show", as: :rails_health_check
# Defines the root path route ("/")
root "posts#index"
end
Here, I commented out the Rails UI root route that comes by default when you first install Rails UI and uncommented the root "posts#index"
line.
Migrate data and Restart your server
For good measure, let’s restart the server to recompile assets and start fresh.
rails db:migrate && bin/dev
Controller logic
Because we scaffolded Posts, most of the work is complete in the PostsController
. I’ll update it to account for our User
model.
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
before_action :authenticate_user!, except: [:index, :show]
before_action :set_post, only: %i[ show edit update destroy ]
# GET /posts or /posts.json
def index
@posts = Post.all
end
# GET /posts/1 or /posts/1.json
def show
@comment = @post.comments.new
end
# GET /posts/new
def new
@post = Post.new
end
# GET /posts/1/edit
def edit
end
# POST /posts or /posts.json
def create
@post = Post.new(post_params.merge(user: current_user))
respond_to do |format|
if @post.save
format.html { redirect_to post_url(@post), notice: "Post was successfully created." }
format.json { render :show, status: :created, location: @post }
else
format.html { render :new, status: :unprocessable_entity }
format.json { render json: @post.errors, status: :unprocessable_entity }
end
end
end
# PATCH/PUT /posts/1 or /posts/1.json
def update
respond_to do |format|
if @post.update(post_params)
format.html { redirect_to post_url(@post), notice: "Post was successfully updated." }
format.json { render :show, status: :ok, location: @post }
else
format.html { render :edit, status: :unprocessable_entity }
format.json { render json: @post.errors, status: :unprocessable_entity }
end
end
end
# DELETE /posts/1 or /posts/1.json
def destroy
@post.destroy!
respond_to do |format|
format.html { redirect_to posts_url, notice: "Post was successfully destroyed." }
format.json { head :no_content }
end
end
private
# Use callbacks to share common setup or constraints between actions.
def set_post
@post = Post.find(params[:id])
end
# Only allow a list of trusted parameters through.
def post_params
params.require(:post).permit(:title, :content)
end
end
This is default Rails stuff for the most part. Here are some notes to be aware of:
- I updated the before action to require authentication except for the index and show actions.
- When creating a post we merge the instant of
current_user
to the post params so they save correctly. Ultimately, this let’s us assign aUser
to aPost
whenever one gets created. - I added a new instance of a comment to the' # show' action, which we’ll need to render the comment form on that specific route/action.
Viewing posts
When visiting the site, you should see the posts#index page load. One cool quality-of-life thing Rails UI does for you is generate dummy user accounts. There are two by default: an admin user and a public user. We’ll log in as an admin just for grins. Use these credentials.
user: [email protected]
pass: password
Update the posts form
In the posts controller, we are saving the User dynamically. We don’t need a user_id
field in the posts form. I’ve updated the form provided by the Rails UI scaffold to accommodate this.
<!-- app/views/posts/_form.html.erb-->
<%= form_with(model: post) do |form| %>
<%= render "shared/error_messages", resource: form.object %>
<div class="form-group">
<%= form.label :title, class: "form-label" %>
<%= form.text_field :title, class: "form-input" %>
</div>
<div class="form-group">
<%= form.label :content, class: "form-label" %>
<%= form.text_area :content, class: "form-input" %>
</div>
<div class="flex items-center justify-between flex-wrap">
<div class="sm:flex-1 sm:mb-0 mb-6">
<%= form.submit class: "btn btn-primary" %>
</div>
<% if form.object.new_record? %>
<%= link_to "Cancel", posts_path, class: "btn btn-light" %>
<% else %>
<%= link_to "Cancel", post_path(@post), class: "btn btn-light" %>
<% end %>
</div>
<% end %>
Generate Comments controller
We’ll roll the comments controller manually, but to save time, let’s generate a controller on the command line.
rails g controller comments create
create app/controllers/comments_controller.rb
route get 'comments/create'
invoke railsui
create app/views/comments
create app/views/comments/create.html.erb
invoke test_unit
create test/controllers/comments_controller_test.rb
invoke helper
create app/helpers/comments_helper.rb
invoke test_unit
This creates the comments_controller.rb
file and two actions called create
. Delete the create.html.erb
file in the app/views/comments
folder for now, as well as the get
comments routing lines that get added to routes.rb
Update routing to nested routes
Because comments live on a posts#show
page (at least, that’s how we designed it), it makes sense to make the URL architecture nest accordingly. By this, I mean take on the shape of something like the following
/posts/:post_id/comments/:comment_id
This lets you link directly to a given post or comment pretty easily, should you want to. Here’s my updated routes file.
# config/routes.rb
Rails.application.routes.draw do
if Rails.env.development? || Rails.env.test?
mount Railsui::Engine, at: "/railsui"
end
# Inherits from Railsui::PageController#index
# To overide, add your own page#index view or change to a new root
# Visit the start page for Rails UI any time at /railsui/start
# root action: :index, controller: "railsui/page"
devise_for :users
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
# Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
# Can be used by load balancers and uptime monitors to verify that the app is live.
get "up" => "rails/health#show", as: :rails_health_check
resources :posts do
resources :comments
end
# Defines the root path route ("/")
root "posts#index"
end
Notice how resources posts
nests comments
.
Build a comment form with Hotwire
On the posts#show view, we can append a new form, the comments form. Let’s do so now.
Create a new _form.html.erb
file in app/views/comments
and add the following code. Based on our routing, we pass an array to build the proper URL for the form helper.
<!-- app/views/comments/_form.html.erb-->
<%= form_with(model: [post, comment]) do |form| %>
<%= render "shared/error_messages", resource: form.object %>
<div class="form-group">
<%= form.label :content, "Reply", class: "form-label" %>
<%= form.text_area :content, class: "form-input", placeholder: "Type a response" %>
</div>
<%= form.submit class: "btn btn-primary" %>
<% end %>
Then, in your app/posts/show.html.erb
file, we can render the partial and pass the instances through as local variables.
<div class="max-w-3xl mx-auto px-4 my-16">
<div class="pb-6 border-b">
<nav aria-label="breadcrumb" class="my-6 font-medium flex text-slate-500 dark:text-slate-200 text-sm">
<ol class="flex flex-wrap items-center space-x-3">
<li>
<%= link_to "Posts", posts_path, class: "hover:underline hover:text-slate-600 dark:hover:text-slate-400" %>
</li>
<li class="flex space-x-3">
<div class="flex items-center">
<span class="text-slate-300 dark:text-slate-500">/</span>
</div>
<span class="text-primary-600 dark:text-primary-500" aria-current="page">
#<%= @post.id %>
</span>
</li>
</ol>
</nav>
<div class="flex items-center justify-between">
<h1 class="text-4xl font-extrabold text-slate-900 dark:text-slate-100 tracking-tight flex-1">post #<%= @post.id %></h1>
<%= link_to "Edit", edit_post_path(@post), class: "btn btn-light" %>
</div>
</div>
<%= render @post %>
<div class="my-16">
<h2 class="text-3xl font-extrabold text-slate-900 dark:text-slate-100 tracking-tight mb-3">
<%= pluralize(@post.comments.size, 'comment') %>
</h2>
<%= render "comments/form", post: @post, comment: @comment %>
</div>
</div>
After creating a sample post, you should see the form on the posts show page.
But not so fast. The comments controller, on which this form depends, has zero logic. Let’s address that now.
Comments controller logic
We’ll need to zero in on the create
action in the comments_controller.rb
file to create a comment. This code is vanilla Rails with no turbo added. We’ll do that later and address other actions a user might want to do (i.e., edit or delete).
class CommentsController < ApplicationController
before_action :set_post
before_action :authenticate_user!
def create
@comment = @post.comments.new(comment_params.merge(user: current_user))
respond_to do |format|
if @comment.save
format.html { redirect_to post_url(@post), notice: "Comment was successfully created." }
format.json { render :show, status: :created, location: @comment }
else
format.html { render :new, status: :unprocessable_entity }
format.json { render json: @comment.errors, status: :unprocessable_entity }
end
end
end
private
def set_post
@post = Post.find(params[:post_id])
end
def comment_params
params.require(:comment).permit(:content)
end
end
In the posts/show.html.erb
file I’ve extended the previous code
<!-- app/views/posts/show.html.erb-->
<! -- more code above -->
<div class="my-16">
<h2 class="text-3xl font-extrabold text-slate-900 dark:text-slate-100 tracking-tight mb-3">
<%= pluralize(@post.comments.size, 'comment') %>
</h2>
<%= render "comments/form", post: @post, comment: @comment %>
<hr class="my-6" />
<%= render partial: "comments/comment", collection: @post.comments %>
</div>
I also added a new _comment.html.erb
partial to app/views/comments
.
<!-- app/views/comments/_comment.html.erb-->
<div class="p-6 bg-slate-50 rounded-xl mb-6">
<p class="font-semibold"><%= comment.user.name %></p>
<div class="prose prose-slate">
<%= comment.content %>
</div>
</div>
Again, there is no Turbo to speak of here. We’re just setting the stage. Try adding some comments, and you’ll see something like this. The bold text would be the demo account name. Below is the comment content.
Hotwire Turbo comments
Let’s move on to the real reason you’re here. HOTWIRE TURBO!
Comments will become “real time” with a few adjustments to our code. To seal the deal, we’ll need a pairing of turbo frame tags and controller responses, along with adding some unique IDs to some elements. It looks gnarly, but it works well.
Let’s start with the views.
<!-- app/views/show.html.erb-->
<div class="my-16">
<h2 class="text-3xl font-extrabold text-slate-900 dark:text-slate-100 tracking-tight mb-3">
<%= turbo_frame_tag "post_#{@post.id}_comment_count" do %>
<%= pluralize(@post.comments.size, 'comment') %>
<% end %>
</h2>
<%= turbo_frame_tag "post_comment_form" do %>
<%= render "comments/form", post: @post, comment: @comment %>
<% end %>
<hr class="my-6">
<%= turbo_frame_tag "post_#{@post.id}_comments" do %>
<%= render partial: "comments/comment", collection: @post.comments %>
<% end %>
</div>
Here, we add the bulk of the turbo frame tags surrounding the comment form and the list of comments. We’ll target each as an individual frame to update or change during the request cycle.
In the form’s case, we must ensure a matching turbo frame tag in the comments/_form.html.erb
partial. I called mine post_comment_form
but you could make this anything so long as it matches what’s over on posts/show.html.erb
.
<!-- app/views/comments/_form.html.erb-->
<%= turbo_frame_tag "post_comment_form" do %>
<%= form_with(model: [post, comment]) do |form| %>
<%= render "shared/error_messages", resource: form.object %>
<div class="form-group">
<%= form.label :content, "Reply", class: "form-label" %>
<%= form.text_area :content, class: "form-input", placeholder: "Type a response" %>
</div>
<%= form.submit class: "btn btn-primary" %>
<% end %>
<% end %>
In the app/views/_comment.html.erb
partial, we need to add a unique ID
attribute to target comments individually. I reached for the dom_id
view helper to aid in this. It outputs comment_1
based on the class and ID. The main thing we need is for each ID to be unique.
<!-- app/views/comments/_comment.html.erb-->
<%= turbo_frame_tag dom_id(comment) do %>
<div class="p-6 bg-slate-50 rounded-xl mb-6">
<p class="font-semibold"><%= comment.user.name %></p>
<div class="prose prose-slate">
<%= comment.content %>
</div>
</div>
<% end %>
With the views out of the way, we can focus on the controller. I’ll update the response to include turbo_stream.
def create
@comment = @post.comments.new(comment_params.merge(user: current_user))
respond_to do |format|
if @comment.save
format.turbo_stream
format.html { redirect_to post_url(@post), notice: "Comment was successfully created." }
format.json { render :show, status: :created, location: @comment }
else
format.turbo_stream
format.html { render :new, status: :unprocessable_entity }
format.json { render json: @comment.errors, status: :unprocessable_entity }
end
end
end
The only difference from before is the format.turbo_stream
lines.
From here, you could append the turbo stream logic directly in the controller, but I prefer to extract it to a create.turbo_stream.erb
file in the app/views/comments
folder. The @post
and @comment
instance variables are available in that file. The file's name should coincide with the specific action on your controller.
# app/views/comments/create.turbo_stream.erb
<%= turbo_stream.replace "post_comment_form" do %>
<%= render partial: "comments/form", locals: { post: @post, comment: Comment.new } %>
<% end %>
<%= turbo_stream.update "post_#{@post.id}_comment_count" do %>
<%= pluralize(@post.comments.size, 'comment') %>
<% end %>
<%= turbo_stream.append "post_#{@post.id}_comments" do %>
<%= render partial: "comments/comment", locals: { comment: @comment } %>
<% end %>
- Replace the form: When creating a new comment, we want to render a new blank form after creating it. To do this, we can replace the current form with a new instance.
- Update the comments count: Updating the count is straightforward. We must target the unique ID and update the contents with the new comment count following creation.
- Append the new comment to the list: We should append it when a new comment is added. You can also prepend if you prefer to order it differently. If so, you must adjust your comment default query order.
Now, when you create a comment, it should all be real-time! Sweet!
But this is a lot of code to add to get real-time updates
Yes, and that’s also my critique. Sometimes, just using Rails defaults gets the job done just fine. A page refresh isn’t that big a deal. My advice is not to always reach for Hotwire unless the UX calls for it. I think with comments in 2024, this is one of those times.
Editing a comment
Now, we can create comments. That’s good. But what about other actions? Well, they become quite similar. Let’s start by rendering the form in place during edit.
We need a way to evoke “editing” mode, so let’s add some buttons to the _comment.html.erb
partial.
<!-- app/views/comments/_comment.html.erb-->
<%= turbo_frame_tag dom_id(comment) do %>
<div class="p-6 bg-slate-50 rounded-xl mb-6">
<p class="font-semibold"><%= comment.user.name %></p>
<div class="prose prose-slate">
<%= comment.content %>
</div>
<div class="mt-3 flex items-center gap-2 w-full">
<%= link_to "Edit", edit_post_comment_path(comment.post, comment), class: "btn btn-white" %>
<%= button_to "Delete", post_comment_path(comment.post, comment), method: :delete, class: "btn btn-white" %>
</div>
</div>
<% end %>
Then, in the app/views/comments/edit.html.erb
, we can render the form surrounded by the same dom_id(@comment)
unique identifier.
<!-- app/views/comments/edit.html.erb -->
<%= turbo_frame_tag dom_id(@comment) do %>
<div class="p-6 bg-slate-50 rounded-xl mb-6">
<%= render "comments/form", comment: @comment, post: @post %>
</div>
<% end %>
Next, I’ll extend the controller a touch.
class CommentsController < ApplicationController
before_action :set_post
before_action :set_comment, only: [:edit, :update, :destroy]
before_action :authenticate_user!
def create
@comment = @post.comments.new(comment_params.merge(user: current_user))
respond_to do |format|
if @comment.save
format.turbo_stream
format.html { redirect_to post_url(@post), notice: "Comment was successfully created." }
format.json { render :show, status: :created, location: @comment }
else
format.turbo_stream
format.html { render :new, status: :unprocessable_entity }
format.json { render json: @comment.errors, status: :unprocessable_entity }
end
end
end
def edit
end
def update
respond_to do |format|
if @comment.update(comment_params)
format.turbo_stream
format.html { redirect_to post_url(@post), notice: "Comment was successfully created." }
format.json { render :show, status: :created, location: @comment }
else
format.turbo_stream
format.html { render :new, status: :unprocessable_entity }
format.json { render json: @comment.errors, status: :unprocessable_entity }
end
end
end
def destroy
end
private
def set_comment
@comment = Comment.find(params[:id])
end
def set_post
@post = Post.find(params[:post_id])
end
def comment_params
params.require(:comment).permit(:content)
end
end
You’ll notice the addition of edit, update, and destroy actions with a before_action
used to set_comment
. This keeps us from having to write @comment = Comment.find(params[:id])
for every one of those actions.
Inside the update
action, we have similar logic to create
with tiny differences. We can then create an update.turbo_stream.erb
file like we did before with create
.
We can target it directly because we only update a single comment, and our turbo_stream code becomes quite simple.
# app/views/comments/update.turbo_stream.erb
<%= turbo_stream.update "comment_#{@comment.id}" do %>
<%= render partial: "comments/comment", locals: { comment: @comment } %>
<% end %>
Now, when you click edit, change your comment, and click “Update comment,” the change should be reflected and happen very fluidly. Here’s a video.
Deleting comments
Following a similar model, we’ll remove a comment directly from the list in real-time with Hotwire. I’ll start in the controller to achieve this.
# app/controllers/comments_controller.rb
def destroy
@comment.destroy
end
Pretty simple, right? The main change from a vanilla Rails app is to add a destroy.turbo_stream.erb
file to the app/views/comments
folder.
We need to remove the comment from view and update the count.
# app/views/comments/destroy.turbo_stream.erb
<%= turbo_stream.remove "comment_#{@comment.id}" %>
<%= turbo_stream.update "post_#{@post.id}_comment_count" do %>
<%= pluralize(@post.comments.size, 'comment') %>
<% end %>
Sweet!
Now look how fluid the entire flow is
Where to take it from here
While there’s much more to perfect, this should get you started adding comments to a Ruby on Rails app using Hotwire.
The flow is fluid, and one I’m a fan of in tasteful does.
My main gripe is the amount of turbo_frame_tags you need to add all over the place.
Many Rails developers prefer this path as opposed to JavaScript, and I can understand that.
The more your app scales, the more careful you have to make sure there are no conflicting identifiers, turbo_frame_tags, and more.
Coming soon: A Hotwire Course
I’m excited to announce a new course on using Hotwire in Rails apps. The final name is still pending, but it’s targeted at newcomers to Hotwire.
The bulk of the course will be FREE, with premium build-alongs available for purchase. The build-along is a more in-depth module aimed at putting Hotwire to real use so you can see the pros and cons of the approach. I look forward to sharing more soon. I’m in the “build” stage of the course, so I’ll share much more as things progress.
If you made it this far, thanks so much for taking the time!
Categories
Collection
Part of the Hotwire and Rails collection
Products and courses
-
Hello Hotwire
A course on Hotwire + Ruby on Rails.
-
Hello Rails
A course for newcomers to Ruby on Rails.
-
Rails UI
UI templates and components for Rails.