Andy from Webcrunch

Subscribe for email updates:

Portrait of Andy Leverenz
Andy Leverenz

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:

  1. I updated the before action to require authentication except for the index and show actions.
  2. 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 a User to a Post whenever one gets created.
  3. 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.

hotwire comments screenshot of new form

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 comments basic commenting UI screenshot

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 %>

  1. 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.
  2. 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.
  3. 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!

Link this article
Est. reading time: 20 minutes
Stats: 1,565 views

Categories

Collection

Part of the Hotwire and Rails collection

Products and courses