September 15, 2023
•Last updated November 5, 2023
Real-time likes with Turbo and Rails
In this guide, my goal is to help you showcase the popularity of blog posts by adding real-time likes as a polymorphic feature to your Rails app using hotwired.dev and the Turbo framework. Follow this step-by-step guide to set up models, associations, and implement real-time liking functionality.
Prerequisites
I'll use my new project called Rails UI alongside this guide, as it solves many of the early (and later) design problems for a typical Rails application. You're free to not use Rails UI, but you'll need to install and configure Devise (or a similar user authentication library) to achieve similar results.
Getting Started
Let's generate a fresh Rails app. If you're using Rails UI, I highly recommend not including any JavaScript or CSS frameworks for maximum compatibility.
rails new hotwire_likes
Add Rails UI
I'll add the public alpha version of Rails UI with bundler. We can pass a couple flags to pull directly from GitHub. Following that we can run the Rails UI installer.
bundle add railsui --github getrailsui/railsui --branch main
rails railsui:install
When the installer completes boot, your server
bin/dev
Head to localhost:3000
You should see the Rails UI landing page and a button to configure your app. I chose a default Tailwind CSS theme called Hound
Rails UI. As I mentioned before, the installer pre-installs Devise, which gives us a User
model ready to work with under the hood.
Setting Up Models and Associations
With the bulk of the setup out of the way, we need to focus more on the architecture of the application at hand.
To get started, we'll create two models for the app: Post
, and Like
. The User
model will of course represent your app's users, the Post
model will handle individual blog posts, and the Like model will keep track of likes given by users in a polymorphic fashion.
Execute the following commands in your terminal to generate these models:
rails generate scaffold Post title:string content:text
rails generate model Like user:references likeable:references{polymorphic}
After running these commands, don't forget to run rails db:migrate
to apply the changes to your database.
Defining Model Associations
Next, let's define the associations between these models. In the User model, add the following line:
# app/models/user.rb
has_many :likes, dependent: :destroy
In the Like model, add the following lines:
# app/models/like.rb
belongs_to :user
belongs_to :likeable, polymorphic: true
And in the Post model, add the following lines:
# app/model/post.rb
has_many :likes, as: :likeable, dependent: :destroy
With these model associations in place, we can implement the logic for adding likes to posts using Hotwire and Turbo.
Update Routing
Since we installed Rails UI, we can update the root path to be relative to the app at hand root “posts#index”
You can visit /railsui/start
locally or click the Rails UI launcher in the bottom left of the viewport to access all Rails UI in your local development environment at any time after changing the default root path.
# config/routes.rb
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
# Defines the root path route ("/")
root "posts#index"
end
Implementing Liking Functionality
In your PostsController
, create new actions called like and unlike to handle the liking functionality:
class PostsController < ApplicationController
before_action :set_post, only: %i[ show edit update destroy like unlike ]
before_action :authenticate_user!, except: %i[ index show]
...
def like
current_user.likes.create(likeable: @post)
render partial: 'posts/post', locals: { post: @post }
end
def unlike
current_user.likes.find_by(likeable: @post).destroy
render partial: 'posts/post', locals: { post: @post }
end
...
end
The code in the like
and unlike
methods depend on an authenticated user so we've added a callback function to be explicit with that:
before_action :authenticate_user!, except: %i[ index show]
With this line of code, we're telling our app that all routes beside index
and show
will require an authenticated user.
Additionally, we need an instance of the @post
to perform the liking or unliking functionality. I extended the default before action to set_post
to add our two new methods:
before_action :set_post, only: %i[ show edit update destroy like unlike ]
Finally, after performing the logic, we render a basic _post.html.erb
partial as a response. That file is in your app/views/posts
folder, which was scaffolded when we started building the app.
Depending on your app, you might not necessarily want to “stream” updates, which is fairly common in this scenario. Assuming you’ve added the proper turbo_frame_tag in your partials with the right id attributes, all you need to do in your controller is render a partial as a response. Hotwire is coined for being “HTML over the wire,” we're dumbing our controller code down to just that. Turbo frames take care of the rest, so you get real-time updates as expected. I love how simple this can become!
Now, if you do want to stream updates for a regular resource, you might need to render a traditional response with something like the following:
def like
....
respond_to do |format|
format.turbo_stream do
render turbo_stream: turbo_stream.replace(
@post,
partial: 'posts/post',
locals: { post: @post }
)
end
format.html { redirect_to @post }
end
Add the like and unlike routing
Update your routes.rb
file to include a route for the like
and unlike
actions. We’ll leverage the member
block to pass the appropriate post_id
in the request.
# config/routes.rb
resources :posts do
member do
post "like", to: "posts#like" # /posts/:id/like
delete "unlike", to: "posts#unlike" # posts/:id/unlike
end
end
This will create a route that maps to the like action on individual posts in the form of a POST
request and an unlike action in the form of a DELETE
request.
Create some dummy content
At this point, we need a post or two to “like.” Create some dummy content to make this easier on ourselves.
Post.create(title: "Boosting Web App Performance with Tailwind CSS", content: "In this blog post, we'll explore how to optimize the performance of your web applications using Tailwind CSS. We'll cover the basics of Tailwind's utility-first approach and demonstrate how it can help reduce your CSS file size and improve loading times.")
Post.create(title: "Building Dynamic Web Apps with Ruby on Rails and Stimulus.js", content: "Ruby on Rails and Stimulus.js make a powerful combination for building dynamic web applications. In this blog post, we'll explore how you can use Ruby on Rails as your backend framework and Stimulus.js as your frontend JavaScript framework to create interactive and responsive web apps. We'll cover topics like setting up your Rails project, integrating Stimulus.js, and building real-time features. Whether you're a seasoned developer or just starting, you'll find this guide helpful in taking your web development skills to the next level.")
Enhancing the Views
Let’s improve the view for displaying a post by including a like button. In your posts/_post.html.erb
file, add the following code:
<!-- app/views/posts/_post.html.erb-->
<%= turbo_frame_tag dom_id(post) do %>
<article class="py-6 prose dark:prose-invert">
<p class="mb-0 font-semibold">
Title
</p>
<p class="my-0">
<%= post.title %>
</p>
<p class="mb-0 font-semibold">
Content
</p>
<p class="my-0">
<%= post.content %>
</p>
<time class="text-slate-600 dark:text-slate-400 text-xs mt-2" datetime="<%= post.created_at.to_formatted_s(:long) %>">Created <%= time_ago_in_words(post.created_at) + " ago" %></time>
<p><%= pluralize(post.likes.count, 'like') %></p>
<% if user_signed_in? %>
<% if current_user && post.likes.exists?(user_id: current_user.id) %>
<%= button_to "Unlike", unlike_post_path(post), method: :delete, class: "text-rose-600" %>
<% else %>
<%= button_to "Like", like_post_path(post), method: :post, class: "like-button" %>
<% end %>
<% else %>
<%= link_to "Like", new_user_session_path, class: "underline", data: { turbo_frame: "_top" } %>
<% end %>
</article>
<% end %>
This code will create a turbo frame for each post with a unique ID thanks to the post ID and the dom_id
view helper. It also displays the post title, content, and the number of likes.
If the current user has already liked the post, it will display an "Unlike" button that sends a DELETE
request to the unlike action in the PostsController.
If the user hasn't liked the post, it will display a "Like" button that sends a POST request to the like action.
Any user will need to be signed in to like or unlike a post, and we’ll show a fake like link that links back to the sign-in form if the person happens to be visiting.
Always room for improvement
While the example I shared is pretty simple, you could go on.e step further and broadcast updates using turbo stream and action cable. I haven’t found a good solution for doing this well with polymorphic relationships so I left that part out. I’ll circle back and update this post if I come across anything!
Don't forget to check out railsui.com to find the alpha version of the Ruby gem I recently released.
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.