Almost JSless Frontend on Rails

Despite some thoughts you can hear in the developer communities, Rails is not becoming a legacy technology, it’s not going to die, and it’s still a great tool to build your new project. One of the reasons is that Rails has enough tooling to cover the essential parts of a typical web application. You don’t have to think about how to handle HTTP requests, what to use to input and retrieve data from the database, how to generate HTML that users will see in their browsers, and even how to give life to the UI.
JSless Frontend on Rails

Rails tools out of the box: rails-ujs, turbolinks

Rails UJS

For a long time, since back when I was trying to build my first web page using only HTML, Rails had a cool tool called jquery-ujs (unobtrusive javascript), which is now called rails-ujs. It works great with a rails backend when you want to add a pinch of AJAX requests for a small price.

You can do something like this.

app/controllers/money_controller.rb

class MoneyController < ApplicationController
  def show
    @money = GetAllMoney.call
  end

  def destroy
    SpendAllMoney.call
  end
end

views/money/show.html.erb

<div class="money">
  <h3>Your money</h3>
  <span id="money-amount"><%= @money %></span>
  <span>$</span>

  <%= link_to 'Spend all money',
              money_path,
              method: 'delete',
              remote: true,
              data: { confirm: 'Do you want to spend all money?' },
              class: 'spend-money-button' %>
</div>

views/money/destroy.js

document.querySelector('#money-amount').innerHTML = 0

So you’ve made an AJAX request with a few HTML attributes and one JS file with one line of code. Pretty cool!

Turbolinks

Another old-timer in the Rails world is Turbolinks. It is not under active development, but we will talk about its successor later. To make a long story short, Turbolinks brings you a SPA experience with almost no client-side code. To be more detailed it: 

  • loads new page content with js and replaces it on the page without reloading the browser;
  • caches pages, so return visits will seem instant;
  • gives the ability to persist elements on the page during the navigation.

The first two features work out of the box, but the last one must be explicitly defined in your code. I will show a little and far-fetched example of what we can achieve with it.  

Let’s assume we have a notifications counter somewhere on the page.

app/helpers/application_helper.rb

module ApplicationHelper
  def notifications_count
    sleep 3 # emulate some calculations

    10
  end

  def articles
    Article.last(5)
  end
end

app/views/layouts/application.html.erb

<!DOCTYPE html>
<html>
<head>
  <title>Turbolinks</title>
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <%= csrf_meta_tags %>
  <%= csp_meta_tag %>

  <%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %>
  <%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %>
</head>

<body>
<div class="container">
  <nav class="navigation">
    <ul>
      <%- articles.each do |article| %>
        <li>
          <%= link_to article.title, article_path(article.id) %>
        </li>
      <% end %>
    </ul>
    <div class="notifications">
      <div class="notifications-badge">
        <%= notifications_count %>
      </div>
    </div>
  </nav>
  <section class="content">
    <%= yield %>
  </section>
</div>
</body>
</html>

Calculating the number of notifications may take a while, but this is a price you pay to keep it up-to-date.

As a next step, you might also want to keep the number of notifications updated by subscribing to notifications updates in real-time. Rails has an Action Cable built-in for this.

Since this work is done on a frontend you don’t really need to calculate the total number between page transitions handled by Turbolinks. Of course, the whole issue could be solved by a simple caching but you know…there are only two hard things in CS…cache invalidation…and we are talking about Turbolinks anyway.

So we can just exclude code from execution if the page was requested by Turbolinks and exclude the part of the page from being updated by Turbolinks.

app/helpers/application_helper.rb

module ApplicationHelper
  def notifications_count
+   return nil if request.headers['Turbolinks-Referrer'].present?
+
    sleep 3 # emulate some calculations

    10
  end

  def articles
    Article.last(5)
  end
end

app/views/layouts/application.html.erb

<div class="notifications">
-   <div class="notifications-badge" id="notifications-badge">
+   <div class="notifications-badge" id="notifications-badge" data-turbolinks-permanent>
    <%= notifications_count %>
  </div>
</div>

What is not enough for us

Even though these Rails long-livers do their job well and many apps successfully built complex UIs on top of them without introducing any complex JS framework, we still miss some features that will make our applications more maintainable and building interfaces easier.

New tools from the Rails team

At the beginning of 2021, DHH made some noise announcing the Hotwire, which presented a new Rails way for building user interfaces. Despite Hotwire being a collective name for a family of libraries, this family is relatively small. It only has two libraries as of October 2021:

The Rails team developed both of them, and both can be seamlessly integrated into your majestic monolith. I will share more information about Turbo since it’s relatively new and will replace the already existing Turbolinks.

Turbo

If you thought that Turbolinks had lost its “links” part because it’s not only about the navigation anymore, you are 100% right. Turbo is divided into a few parts, with each of them serving one purpose – to bring the server-rendered HTML to your app, with some differences in when and how it’s done:

  • Turbo Drive: That good old Turbolinks we are familiar with.
  • Turbo Frames: Decomposable frames that can be loaded asynchronously and updated when the server returns a frame with the same id.
  • Turbo Streams: Another kind of frame in which an update is triggered due to an HTTP request or by the server via Websocket.
  • Turbo Native: A wrapper over your turbo-enabled web application that integrates it into your mobile application.

Turbo Drive

As was mentioned before, Turbo Drive replaces Turbolinks and handles page navigation. Since almost nothing changed, the migration is pretty simple. And replace data-turbolinks… attributes with data-turbo

You just have to add npm package

yarn add @hotwired/turbo

Replace Turbolinks with Turbo in your javascript code

app/javascript/packs/application.js

 import Rails from "@rails/ujs"
- import Turbolinks from "turbolinks"
+ import * as Turbo from "@hotwired/turbo"
 
  Rails.start()
- Turbolinks.start()

And replace data-turbolinks… attributes with data-turbo

app/views/layouts/application.html.erb


-    <%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %>
-    <%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %>
+    <%= stylesheet_link_tag 'application', media: 'all', 'data-turbo-track': 'reload' %>
+    <%= javascript_pack_tag 'application', 'data-turbo-track': 'reload' %>

One thing that needs a bit more attention is a form submission. Turbo drive handles this too. First of all, it expects that your redirects after the form submission will have a 303 status code to allow Fetch API to follow this redirect automatically. This is the correct HTTP status for non-idempotent (a clever word to describe non-GET or HEAD methods 😁) requests if you want redirects to be made via GET method. Otherwise, only POST requests will be redirected correctly since they allow 301 and 302 statuses as well. So you might want to explicitly add a status code to your redirects.

app/controllers/any_controller.rb

redirect_to money_path
+    redirect_to money_path, status: :see_other

However, Rails’ forms use the POST method anyway and add  <input type="hidden" name="_method" value="patch"> to define what controller action to use. This means that your forms will still work and there have already been heated discussions about the necessity for the correct status code. 

The second thing to note is that Turbo doesn’t respect the local: true parameter that you might have used to disable JS control over the form. If this is your case, another slight change should be made.

app/views/_any_form.html.erb

- <%= form_with(url: money_path, local: true) do |f| %>
+ <%= form_with(url: money_path, data: { turbo: false }) do |f| %>

Turbo Frames

We had finally reached something new in the Rails stack. Turbo Frame is a simple tool to create a container with content that can be loaded and updated separately. Just like the render_async gem or Rails’ .ejs responses, but with less code to be written   

Let’s see an example of how we can decompose our page into a few asynchronous loaded parts that are loaded only when a user sees them. Imagine a product page with general information, product properties, and customer reviews. There is no guarantee that a user will visit each of these sections so we can load them only if a user really reached that part of the page.

I’ll skip the part of the example related to the setting up models, routes, Bootstrap installation, and adding some CSS. I assume you are not interested in such basic things.

This is how our products/show.html.erb looks like. 

<div class="product">
  <ul class="nav nav-tabs" id="product-tab" role="tablist">
    <li class="nav-item" role="presentation">
      <button class="nav-link active" id="home-tab" data-bs-toggle="tab" data-bs-target="#general" type="button" role="tab" aria-controls="general" aria-selected="true">General</button>
    </li>
    <li class="nav-item" role="presentation">
      <button class="nav-link" id="properties-tab" data-bs-toggle="tab" data-bs-target="#properties" type="button" role="tab" aria-controls="properties" aria-selected="false">Properties</button>
    </li>
    <li class="nav-item" role="presentation">
      <button class="nav-link" id="reviews-tab" data-bs-toggle="tab" data-bs-target="#reviews" type="button" role="tab" aria-controls="reviews" aria-selected="false">Reviews</button>
    </li>
  </ul>

  <div class="tab-content">
    <div class="tab-pane active p-3" id="general" role="tabpanel" aria-labelledby="general-tab">
      <turbo-frame id="<%= dom_id(@product, 'general') %>" loading="lazy" src="<%= general_product_path %>">
        <%= render('common/spinner') %>
      </turbo-frame>
    </div>
    <div class="tab-pane p-3" id="properties" role="tabpanel" aria-labelledby="properties-tab">
      <turbo-frame id="<%= dom_id(@product, 'properties') %>" loading="lazy" src="<%= properties_product_path %>">
        <%= render('common/spinner') %>
      </turbo-frame>
    </div>
    <div class="tab-pane p-3" id="reviews" role="tabpanel" aria-labelledby="reviews-tab">
      <turbo-frame id="<%= dom_id(@product, 'reviews') %>" loading="lazy" src="<%= reviews_product_path %>">
        <%= render('common/spinner') %>
      </turbo-frame>
    </div>
  </div>
</div>

It’s the basic Bootstrap’s tab bar. But the interesting things happen in the .tab-page elements. We have added the turbo-frame tag which is our container for being loaded and updated. Each frame must have its own unique id attribute and dom_id helper is a good tool to free us from the need to think about naming.

To load the frame asynchronously we have to add an src attribute and the response from this path should return the frame with the same id.

And since we want to load only the visible part, we add loading="lazy" and the frame will be loaded only when this tag of the page will become visible. Pay attention that it doesn’t matter how this element became visible. A user can just scroll to this tag and it will load its content. Its parent’s style can change from display: none to display: block. The app can insert it into the page via Javascript or you can even recursively render one frame from another (but don’t forget to exit from the recursion somehow).

The spinner in the example is just a div with CSS animation. You don’t need to manage it because it will just spin until the frame’s content is loaded and inserted into the page.

app/views/common/_spinner.html.erb

<div class="text-center mt-5">
  <div class="spinner-grow text-secondary" role="status">
    <span class="visually-hidden">Loading...</span>
  </div>
</div>

Alternatively, you could utilize the busy attribute that is added to the frame when it loads and add some custom CSS to show the loading state.

Our controller is pretty straightforward.

app/controllers/products_controller.rb

class ProductsController < ApplicationController
  def show
    @product = Product.find(params[:id])
  end

  def general
    @product = Product.find(params[:id])

    render partial: 'products/general'
  end

  def properties
    @product = Product.find(params[:id])

    render partial: 'products/properties'
  end

  def reviews
    @product = Product.find(params[:id])

    render partial: 'products/reviews'
  end
end

app/views/products/_general.html.erb

<turbo-frame id="<%= dom_id(@product, 'general') %>" loading="lazy" src="<%= general_product_path(product_id: @product) %>">
  <div class="product--general">
    <h1>
      <%= @product.title %>
    </h1>
    <div class="row mt-4">
      <div class="col">
        <div class="product--image">
          <%= image_tag @product.image %>
        </div>

      </div>
      <div class="col">
        <h3>
          <%= @product.price %>
        </h3>

        <%= @product.content %>
      </div>
    </div>
  </div>
</turbo-frame>

app/views/products/_properties.html.erb

<turbo-frame id="<%= dom_id(@product, 'properties') %>">
  <h1>
    <%= @product.title %> properties
  </h1>
  <dl class="row mt-4">
    <%- @product.properties.each do |name, value| %>
      <dt class="col-sm-3"><%= name.to_s.titleize %></dt>
      <dd class="col-sm-9"><%= value %></dd>
    <% end %>
  </dl>
</turbo-frame>

app/views/products/_review.html.erb

<turbo-frame id="<%= dom_id(@product, 'reviews') %>">
  <%- @product.reviews.each do |review| %>
    <div class="card mb-3">
      <div class="card-body">
        <div class="card-title">
          <%= review.author %>
        </div>
        <div class="card-text">
          <%= review.content %>
        </div>
      </div>
    </div>
  <% end %>
</turbo-frame>

We render partials here but it can be the whole page with the layout as well. The main thing is to render a turbo-frame tag with the same id as the tag-requestor has.  

Actually, that’s all you need to have a lazy-loaded page. 

Unfortunately, I wasn’t able to find a convenient way to handle errors with turbo frames but this solution can be handy:

app/controllers/any_controller.rb

def general
  @product = Product.find(params[:id])

  raise StandardError, 'Some error'

  render partial: 'products/general', locals: { in_cart: product_in_cart?(@product) }
rescue StandardError
  render partial: 'common/turbo_error',
         locals: { id: dom_id(@product, 'general'), error_message: 'Oops. Something went wrong' }
end

app/views/common/_turbo_error.html.erb

<turbo-frame id="<%= id %>">
  <%= error_message %>
</turbo-frame>

Another cool thing we can do with turbo-frame is replace parts of the page as a response to the form submission. The idea is pretty much the same. Controller action should return the turbo-frame tag and turbo will replace it on the page. Let’s extend the previous example to have the ability to add an item to the cart and remove it from it.

app/controllers/products_controller.rb

class ProductsController < ApplicationController
  include ActionView::RecordIdentifier

  def show
    @product = Product.find(params[:id])
  end

  def general
    @product = Product.find(params[:id])

-   render partial: 'products/general'
+   render partial: 'products/general', locals: { in_cart: product_in_cart?(@product) }
  end

  def properties
    @product = Product.find(params[:id])

    render partial: 'products/properties'
  end

  def reviews
    @product = Product.find(params[:id])

    render partial: 'products/reviews'
  end

+  def add_to_cart
+    @product = Product.find(params[:id])
+
+    session[:cart] = (session[:cart] || []) << @product.id
+
+    render partial: 'products/general', locals: { in_cart: product_in_cart?(@product) }
+  end
+
+  def remove_from_cart
+    @product = Product.find(params[:id])
+
+    session[:cart] = (session[:cart] || []).reject { |id| @product.id == id }
+
+    render partial: 'products/general', locals: { in_cart: product_in_cart?(@product) }
+  end
+
+  private
+
+  def product_in_cart?(product)
+    return false unless product && session[:cart]
+
+    session[:cart].include?(product.id)
+  end
end

app/views/products/_general.html.erb

<turbo-frame id="<%= dom_id(@product, 'general') %>" loading="lazy" src="<%= general_product_path(product_id: @product) %>">
  <div class="product--general">
    <h1>
      <%= @product.title %>
    </h1>
    <div class="row mt-4">
      <div class="col">
        <div class="product--image">
          <%= image_tag @product.image %>
        </div>

      </div>
      <div class="col">
        <h3>
          <%= @product.price %>
        </h3>

+        <%- if in_cart %>
+          <%= form_with(url: remove_from_cart_product_path) do |f| %>
+            <%= f.submit 'Remove from cart', class: 'my-3 btn btn-danger' %>
+          <%- end %>
+        <%- else %>
+          <%= form_with(url: add_to_cart_product_path) do |f| %>
+            <%= f.submit 'Add to cart', class: 'my-3 btn btn-success' %>
+          <%- end %>
+        <% end %>
        <%= @product.content %>
      </div>
    </div>
  </div>
</turbo-frame>

You can see that our controller now has actions to add items to the cart and remove them from it. Both of these methods just render the general partial and it’s magically replaced on the page. This is pretty similar to what was usually done in .js.erb templates but I would prefer turbo way more to not have extra js code that is outside of all your javascripts. 

Turbo Streams

Turbo has brought us one more interesting tool to modify the html on the page – Turbo Streams. It brings more opportunities to update DOM and you are not limited to replacing only one frame as you are with Turbo frames. This DOM manipulation is called action and should be performed around the targets – elements received by some selector. Turbo streams gives you 7 actions to be performed

  • append – add html to the beginning of the target
  • prepend – add html to the end of the target
  • replace – replace the whole target with html provided
  • update – update html inside the target
  • remove – remove the whole target 
  • before – add html after the target
  • after – add html before the target 

You can usually hear/read about the Turbo Streams when it comes to the real-time updates and building another chat application. But we can start with a simpler example and see how Turbo Streams helps to reflect the form submission in UI.

Let’s proceed with the previous example and add the ability to post a new review and show the total number of reviews.

At the beginning, I’ll just add the number of reviews and a form for adding a new review.

app/views/products/show.html.erb

<ul class="nav nav-tabs" id="product-tab" role="tablist">
    <li class="nav-item" role="presentation">
      <button class="nav-link active" id="home-tab" data-bs-toggle="tab" data-bs-target="#general" type="button" role="tab" aria-controls="general" aria-selected="true">General</button>
    </li>
    <li class="nav-item" role="presentation">
      <button class="nav-link" id="properties-tab" data-bs-toggle="tab" data-bs-target="#properties" type="button" role="tab" aria-controls="properties" aria-selected="false">Properties</button>
    </li>
    <li class="nav-item" role="presentation">
-      <button class="nav-link" id="reviews-tab" data-bs-toggle="tab" data-bs-target="#reviews" type="button" role="tab" aria-controls="reviews" aria-selected="false">Reviews</button>
+      <button
+        class="nav-link"
+        id="reviews-tab"
+        data-bs-toggle="tab"
+        data-bs-target="#reviews"
+        type="button"
+        role="tab"
+        aria-controls="reviews"
+        aria-selected="false"
+      >
+        Reviews
+        <span id=<%= dom_id(@product, 'reviews_count') %>>
+          <%= render(partial: 'products/reviews/count_badge', locals: { count: @product.reviews.count }) %>
+        </span>
+      </button>
    </li>
  </ul>

app/views/products/_reviews.html.erb

<turbo-frame id="<%= dom_id(@product, 'reviews') %>">
  <%= render(partial: 'products/reviews/form', locals: { product: @product }) %>

  <div id="<%= dom_id(@product, 'reviews_list') %>">
    <%- @product.reviews.each do |review| %>
      <%= render(partial: 'products/reviews/card', locals: { review: review }) %>
    <% end %>
  </div>
</turbo-frame>

app/views/products/reviews/_count_badge.html.erb

<span class="badge bg-primary">
  <%= count %>
</span>

app/views/products/reviews/_form.html.erb

<%= form_with(url: add_review_product_path(id: product.id), class: 'mb-4', id: dom_id(product, 'reviews_form')) do |f| %>
  <%= f.text_area :review, class: "form-control mb-1" %>
  <%= f.submit 'Add a review', class: 'btn btn-primary' %>
<% end %>

app/views/products/reviews/_card.html.erb

<%= form_with(url: add_review_product_path(id: product.id), class: 'mb-4', id: dom_id(product, 'reviews_form')) do |f| %>
  <%= f.text_area :review, class: "form-control mb-1" %>
  <%= f.submit 'Add a review', class: 'btn btn-primary' %>
<% end %>

This is how it’s going to look. 

Now we can add some interactivity using Turbo Streams.

app/controllers/products_controller.rb

+ def add_review
+    @product = Product.find(params[:id])
+    @review = @product.add_review(author: 'You', content: params[:review])
+ end

app/views/products/add_review.turbo_stream.erb

<%= turbo_stream.update(dom_id(@product, 'reviews_count')) do %>
  <%= render(partial: 'products/reviews/count_badge', locals: { count: @product.reviews.count }) %>
<% end %>

<%= turbo_stream.replace(dom_id(@product, 'reviews_form')) do %>
  <%= render(partial: 'products/reviews/form', locals: { product: @product }) %>
<% end %>

<%= turbo_stream.prepend(dom_id(@product, 'reviews_list')) do %>
  <%= render(partial: 'products/reviews/card', locals: { review: @review }) %>
<% end %>

The most interesting file here is _add_review.turbo_stream.erb. The format turbo_stream must be new for you if you’ve not encountered Turbo Streams before. Turbo requires HTTP response to have content-type text/vnd.turbo-stream.html so you should either pass content_type: “text/vnd.turbo-stream.html” to the render method in the controller action or add .turbo_stream.erb extension for your view. I found the second option handier.

The main actor in _add_review.turbo_stream.erb is turbo_stream helper. We use it to call the previously mentioned actions. To be more accurate – it generates xml tags that describe what DOM manipulations should be made. 

This file does three things:

  • Updates the total number of reviews – updates the content of the tag with id dom_id(@product, 'reviews_count')
  • Resets review form – replace the whole tag with id dom_id(@product, 'reviews_form')
  • Display a new review on the page – add content in the beginning of the tag with id dom_id(@product, 'reviews_list')

So this is all you need to build a truly interactive web application. Without a line of JS code. And it will be enough for the majority of the applications. 

Turbo Streams also gives us a chance to deliver page changes over WebSocket. It won’t require many actions from our side so let’s update our reviews in all opened browsers when a new review is added.

Before we start you should add gem “turbo-rails” to your Gemfile and run this command:

bundle exec rails turbo:install

It will install @hotwired/turbo-rails and replace the Action Cable adapter from async (the default one) to redis.

Now we are ready for some real-time.

The first thing we need to do is to subscribe to the product’s updates. That is super easy thanks to the turbo_stream_from helper 

app/views/products/show.html.erb

<div class="product">
+  <%= turbo_stream_from @product %>

  <ul class="nav nav-tabs" id="product-tab" role="tablist">

Now instead of returning turbo-frame tags, which tell what action should be performed on the user’s UI, we will broadcast those actions to all subscribers (all open product’s pages.)

app/controllers/products_controller.rb

def add_review
  @product = Product.find(params[:id])
  @review = @product.add_review(author: 'You', content: params[:review])

+  Turbo::StreamsChannel.broadcast_update_to(
+    @product,
+    target: ActionView::RecordIdentifier.dom_id(@product, 'reviews_count'),
+    partial: 'products/reviews/count_badge',
+    locals: { count: @product.reviews.count }
+  )
+
+  Turbo::StreamsChannel.broadcast_prepend_to(
+    @product,
+    target: ActionView::RecordIdentifier.dom_id(@product, 'reviews_list'),
+    partial: 'products/reviews/card',
+    locals: { review: @review }
+  )
end

And to avoid some actions being performed twice we have to remove them from the HTTP response.

app/views/products/add_review.turbo_stream.erb

- <%= turbo_stream.update(dom_id(@product, 'reviews_count')) do %>
-   <%= render(partial: 'products/reviews/count_badge', locals: { count: @product.reviews.count }) %>
- <% end %>

<%= turbo_stream.replace(dom_id(@product, 'reviews_form')) do %>
  <%= render(partial: 'products/reviews/form', locals: { product: @product }) %>
<% end %>

- <%= turbo_stream.prepend(dom_id(@product, 'reviews_list')) do %>
-   <%= render(partial: 'products/reviews/card', locals: { review: @review }) %>
- <% end %>

Form update will still be there since the form should be cleared after the submission and we don’t want to clear the form for all users. 

That is all you need to do when you want to add some real-time communication in your Rails app. Rails’ magic in all its beauty.

The Rails team did a great job to minimize interaction with all that big and scary JS world while leaving the framework a great tool for building modern web applications. Of course, the real world may (and most probably will) require more than Turbo can give to you. And the Rails team has brought Stimulus and request.js to make your life easier when you have to write JS code within this Rails application. But this is a topic for a separate article.

Additional Ruby on Rails resources