Ruby on Rails archive

Ajax Toggle Buttons

How to create toggle buttons using Ruby on Rails, ajax, and unobtrusive javascript. Straight-forward and boring, just how I like it.

Built with jQuery and CoffeeScript, this page reflects the web development practices of its time rather than the Hotwire-based approach I use today.

Introduction

Toggle buttons are useful for communicating and changing state. Here's what the user sees:

  • Clicks on
  • An ajax request is started and the button becomes
  • When the request succeeds the button becomes

This pattern presents itself in every one of my applications, so I wanted to document how I implement it in Ruby on Rails, as it is a good introduction to ajax, unobtrusive javascript, and Rails handling javascript requests.

Demo

Favorite Locked

This webpage is not hosted by Rails, so the above UX is just a simulation.

How it Works

  1. A Rails helper method renders the button in its current state.
  2. This button has an href along with data-method, data-type=script, and data-remote=true attributes which instruct jquery_ujs.js to perform an ajax request and evaluate the result as javascript.
  3. When the user clicks either button, a jQuery listener switches to the loading icon while another listener switches it back on completion.
  4. In Rails, the main resource has a child which provides #update and #destroy actions for toggling on and off respectively.
  5. After saving the change, Rails responds with one line of javascript that replaces the button with its new state.

The Code

# models/post.rb
class Post < ApplicationRecord
  def favorited?
    favorited_at != nil
  end

  def favorite
    self.favorited_at = Time.zone.now
  end

  def favorite!
    favorite
    save!
  end

  def unfavorite
    self.favorited_at = nil
  end

  def unfavorite!
    unfavorite
    save!
  end
end
# config/routes.rb
resources :posts do
  resource :favorite, only: %w[update destroy]
end
# controllers/posts/favorites_controller.rb
class Posts::FavoritesController < ApplicationController
  before_action :load_post

  def update
    @post.favorite!
  end

  def destroy
    @post.unfavorite!
  end

private

  def load_post
    @post = Post.find(params[:post_id])
  end
end
# helpers/posts_helper.rb
module PostsHelper
  def link_to_toggle_post_favorite(post)
    url = post_favorite_path(post)

    if post.favorited?
      link_to_with_icon(
        "star",
        "Favorite",
        url,
        method: "DELETE",
        remote: true,
        class: "favorite btn btn-primary",
      )
    else
      link_to_with_icon(
        "star",
        "Favorite",
        url,
        method: "PUT",
        remote: true,
        class: "favorite btn",
      )
    end
  end

  def link_to_with_icon(icon, title, url, options = {})
    idle = content_tag(:span, lucide_icon(icon, class: "icon"), data: { icon: "idle" })
    spinner = content_tag(:span, lucide_icon("loader-circle", class: "icon icon-spin"), class: "hidden", data: { icon: "loading" })
    title_with_icon = idle << spinner << " ".html_safe << h(title)
    link_to(title_with_icon, url, options)
  end
end
# Gemfile
gem "jquery-rails"
gem "lucide-rails"
// assets/javascript/application.js
//
// jquery_ujs allows us to use 'data-remote',
// 'data-type', and 'data-method' attributes
//
//= require jquery
//= require jquery_ujs
//= require_tree .
/* assets/javascripts/loading.js */

// This isn't necessarily specific to toggle buttons
$(function() {

  // Change the link's icon while the request is performing
  $(document).on('click', 'a[data-remote]', function() {
    $(this).find('[data-icon=idle]').addClass('hidden');
    $(this).find('[data-icon=loading]').removeClass('hidden');
  });

  // Change the link's icon back after it's finished.
  $(document).on('ajax:complete', function(e) {
    $(e.target).find('[data-icon=loading]').addClass('hidden');
    $(e.target).find('[data-icon=idle]').removeClass('hidden');
  });

  // Don't fail silently
  $(document).ajaxError(function(event, jqxhr) {
    if (jqxhr.status) {
      alert("We're sorry, but something went wrong (" + jqxhr.status + ')');
    }
  });

});
<%# views/posts/show.html.erb %>
<%= div_for @post do %>
  <%= link_to_toggle_post_favorite @post %>
<% end %>
/* views/posts/favorites/update.js.erb */
$('#post-<%= @post.id %> .favorite').replaceWith("<%=j link_to_toggle_post_favorite @post %>");
/* views/posts/favorites/destroy.js.erb */
$('#post-<%= @post.id %> .favorite').replaceWith("<%=j link_to_toggle_post_favorite @post %>");

Wrap-up

Unobtrusive javascript allows the application logic to stay in Ruby.

We follow "The Rails' Way" to #update and #destroy in the controller, which will help the application gracefully grow when we add more functionality to posts like additional toggles, fields, or a public RESTful API.

Let me know what else you think could be improved.

Dan Cunning

Dan Cunning

Ruby on Rails contractor from Atlanta GA, focusing on simplicity and usability through solid design.