My previous ActsAsVotable posts:

  1. gem acts_as_votable 1: Like and Dislike posts with Vanilla JS
  2. gem acts_as_votable 2: reddit-style up and down voting
  3. gem acts_as_votable 3: vote search scopes

This is my current-best approach to reddit-style voting with Hotwire.

Prerequisites:

1. Install acts_as_votable. Models. Vote scopes. #

# console
rails g scaffold message body:text
bundle add acts_as_votable
rails generate acts_as_votable:migration
rails db:migrate
# app/models/user.rb
  acts_as_voter

I strongly recommend to use vote_scopes. This way you can always easily add multiple vote types on a same model, like like, bookmark, star.

# app/models/message.rb
  acts_as_votable

  # upvote or remove vote
  def upvote!(user)
    if user.voted_up_on? self, vote_scope: 'like'
      unvote_by user, vote_scope: 'like'
    else
      upvote_by user, vote_scope: 'like'
    end
  end

  # downvote or remove vote
  def downvote!(user)
    if user.voted_down_on? self, vote_scope: 'like'
      unvote_by user, vote_scope: 'like'
    else
      downvote_by user, vote_scope: 'like'
    end
  end

Now you can like/unlike/dislike via the console:

user = User.first
message = Message.create(body: SecureRandom.hex)
message.upvote!(user) # like
message.upvote!(user) # if a vote exists => unlike
user.voted_for? message, vote_scope: "like" # true
message.get_upvotes(vote_scope: 'like').size # 1
message.get_downvotes(vote_scope: 'like').size # 0
message.find_votes_for(vote_scope: 'like').size # total votes count
ActsAsVotable::Vote.count # 1

Problem: I don’t see a way to get message.weighted_score with a vote_scope.

2. Store Cached votes in the database #

This is important for performance. Also to order posts by votes like Post.order(cached_weighted_like_score: :desc)

# console
rails g migration AddCachedScopedLikeVotesToMessages
class AddCachedScopedLikeVotesToMessages < ActiveRecord::Migration[7.0]
  def change
    change_table :messages do |t|
      t.integer :cached_scoped_like_votes_total, default: 0
      t.integer :cached_scoped_like_votes_score, default: 0
      t.integer :cached_scoped_like_votes_up, default: 0
      t.integer :cached_scoped_like_votes_down, default: 0
      t.integer :cached_weighted_like_score, default: 0
      t.integer :cached_weighted_like_total, default: 0
      t.float :cached_weighted_like_average, default: 0.0

      # calculate the existing votes
      # Message.find_each { |p| p.update_cached_votes("like") }
    end
  end
end

3. View, Controller #

Let’s allow the user to upvote/downvote on a message from the browser.

# config/routes.rb
  resources :messages do
    member do
      patch :vote
    end
  end

So, the path to vote will be vote_message_path(message).

Instead of having 2 methods upvote and downvote, we have just one method vote.

To distinguish whether we want to upvote or downvote, we will be sending an additional param[:type] with the button:

vote_message_path(message, type: :upvote)
vote_message_path(message, type: :downvote)

The controller action will respond accordingly:

# app/controllers/messages_controller.rb
  def vote
    # return unless %w[upvote downvote].include?(params[:type])
    @message = Message.find(params[:id])

    case params[:type]
    when 'upvote'
      @message.upvote! current_user
    when 'downvote'
      @message.downvote! current_user
    else
      # redirect_to request.url, alert: "no such vote type" and return
      return redirect_to request.url, alert: "no such vote type"
    end
    flash.now[:notice] = params[:type]
    respond_to do |format|
      format.html do
        redirect_to request.url
      end
      format.turbo_stream do
        render turbo_stream:
          turbo_stream.replace(@message,
                                partial: 'messages/message',
                                locals: { message: @message })
      end
    end
  end

Logic for the upvote/downvote button text:

# app/helpers/application_helper.rb
  def upvote_label(message, user)
    vote_message = if user.voted_up_on? message, vote_scope: 'like'
                     'UN-vote'
                   else
                     'UP-vote'
                   end
    tag.span do
      "#{message.cached_scoped_like_votes_up} #{vote_message}"
    end
  end

  def downvote_label(message, user)
    vote_message = if user.voted_down_on? message, vote_scope: 'like'
                     'UN-vote'
                   else
                     'DOWN-vote'
                   end
    tag.span do
      "#{message.cached_scoped_like_votes_down} #{vote_message}"
    end
  end

Let’s display:

  • buttons to upvote/downvote
  • total votes (upvotes + downvotes)
  • rating (upvotes - downvotes)
# app/views/messages/_message.html.erb
<div id="<%= dom_id message %>">
  <%= simple_format(message.body) %>

  <%= button_to [:vote, message], params: { type: :upvote }, method: :patch do %>
    <%= upvote_label(message, current_user) %>
  <% end %>

  Total votes:
  <%= message.cached_scoped_like_votes_total %>
  Rating:
  <%= message.cached_weighted_like_score %>

  <%= button_to [:vote, message], params: { type: :downvote }, method: :patch do %>
    <%= downvote_label(message, current_user) %>
  <% end %>
</div>

Cool stuff! You can respond with either html (full page redirect), or turbo_stream (ajax):

<%= button_to upvote_label(message, current_user), vote_message_path(message, type: :upvote, format: :html), method: :patch %>
<%= button_to upvote_label(message, current_user), vote_message_path(message, type: :upvote, format: :turbo_stream), method: :patch %>

4. Get all records that a user voted for #

# all voted entities
user.find_voted_items # voted
user.find_up_voted_items # upvoted
user.find_down_voted_items # downvoted

# all voted entities with a scope
user.find_voted_items(vote_scope: 'like')
user.find_up_voted_items(vote_scope: 'like')
user.find_down_voted_items(vote_scope: 'like')

# only voted Messages with a scope
user.find_votes_for_class(Message, vote_scope: "like")
user.find_up_votes_for_class(Message, vote_scope: "like")
user.find_down_votes_for_class(Message, vote_scope: "like")

That’s it!