Andy from Webcrunch

Subscribe for email updates:

Portrait of Andy Leverenz
Andy Leverenz

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.

Link this article
Est. reading time: 8 minutes
Stats: 766 views

Collection

Part of the Hotwire and Rails collection