DEV Community

Cover image for Get Started with Hotwire in Your Ruby on Rails App
Sapan Diwakar for AppSignal

Posted on • Originally published at blog.appsignal.com

Get Started with Hotwire in Your Ruby on Rails App

Hotwire is a hot topic at the moment for every Rails developer. If you work with Rails, there is a good chance you have already heard a lot about it.

Hotwire is a completely new way of adding interactivity to your app with very few lines of code, and it works blazing fast by transmitting HTML over the wire.

That means you can keep your hands clean from most Single Page Applications (SPA) frameworks. You can also keep your rendering logic centralized on the server, while still maintaining quick page load times and interactivity.

In this post, we'll look at the main components of Hotwire and how to use it in your Rails app. But first: what is Hotwire and why should you use it?

What Is Hotwire?

Hotwire is not a single library, but a new approach to building web and mobile applications by sending HTML over the wire. It includes Turbo, Stimulus, and Strada (coming later this year).

We will discuss each of these in detail in the next section.

Side note: While Hotwire is highly linked with Rails, it is completely language-agnostic, so it can work just as well with other applications.

I have been using Stimulus in production on several non-Rails apps and some static websites. You can use Turbo without Rails as well.

But let us come back to the Rails world for now.

Why Use Hotwire in Your Rails App?

So when should you use Hotwire? The answer is anywhere you want to add interactivity to your application. For example, if you want:

  • Some content to be displayed/hidden conditionally based on a user's interaction (e.g., an address form where the list of states automatically changes based on the selected country).
  • To update some content in real-time (e.g., a feed like Twitter where new Tweets automatically get added to the page).
  • To lazy-load some parts of your pages (e.g., inside an accordion, you can load the titles and mark the details to be lazy-loaded to speed up load times).

Hotwire Components

As mentioned before, Hotwire is a collection of new (and some old) techniques for building web apps.

Let's discuss each of these in the next few sections.

Turbo

HTML drives Turbo at its core.

Turbo provides several techniques to handle HTML data coming over the wire and display it on your application without performing a full page reload.

It is composed of:

  • Turbo Drive

If you have used Turbolinks in the past, you will feel right at home with Turbo Drive. At its core, some JS code intercepts JavaScript events on your application, loads HTML asynchronously, and replaces parts of your HTML markup.

  • Turbo Frames

Turbo Frames decouple parts of your markup into different sections that can be loaded independently. For example, if you have a blog application, the content of your post and the comments are two related but independent parts of the page. You can decouple them so navigation works independently or even load them asynchronously with turbo frames.

  • Turbo Streams

Turbo Streams offers utilities to easily bring in real-time data to your application. For example, let's say you are building a news feed like Twitter. You want to pull new tweets into a user's feed as soon as they are posted without reloading the page. Turbo Streams allow you to do this without writing a single line of JS.

  • Turbo Native

Turbo Native lets you build a native wrapper around your web application. Navigations and interactions will feel native without you having to redo all the screens natively. You'll keep delivering the rest of the application through the web. That way, you can focus on the really interactive parts of your application and get them right.

Stimulus

Stimulus is a JavaScript framework for writing controllers that interact with your HTML.

Let's say we need to add some JavaScript attributes like data-controller, data-action, and data-target to elements on a page. We'll write a stimulus controller with access to elements that receives events based on those attributes.

Here's an example:

<div data-controller="clipboard">
  PIN:
  <input data-clipboard-target="source" type="text" value="1234" readonly />
  <button data-action="clipboard#copy">Copy to Clipboard</button>
</div>
Enter fullscreen mode Exit fullscreen mode

It is very easy to get an idea about what this does without even reading the associated Stimulus controller.

Here's a controller that goes with the HTML:

// src/controllers/clipboard_controller.js
import { Controller } from "@hotwired/stimulus";

export default class extends Controller {
  static targets = ["source"];

  copy() {
    navigator.clipboard.writeText(this.sourceTarget.value);
  }
}
Enter fullscreen mode Exit fullscreen mode

That is at the core of Stimulus: keeping things simple and reusable.

Now, if you ever need a copy-to-the-clipboard button on another page, you can just re-use that controller. Add the data-* attributes on the markup to get everything working.

Strada

Unfortunately, we don't know much about Strada yet. But it will allow a web application to communicate (and possibly perform actions) with a native app using HTML bridge attributes.

How to Use Hotwire in Your Ruby on Rails Application

I don't want to spend too much time discussing Hotwire installation or a basic use case. The Hotwire team has already done an excellent job of it in their Hotwire screencast. For full instructions, see turbo-rails installation and Stimulus installation.

Let's jump straight into some common Hotwire use cases.

Endless Scroll

Using Turbo Frames, we can easily make a page with automatic pagination as the user scrolls. For this, we need to do two things:

  1. Render each "page" inside its own frame by appending the page number to the frame id (e.g., turbo_frame_tag "posts_#{@posts.current_page}").
  2. Use a lazy frame for the next page so that it doesn't load automatically unless it comes into view.
<%= turbo_frame_tag "posts_#{@posts.current_page}" do %>
  <%= render @posts %>
  <% unless @posts.last_page? %>
    <%= turbo_frame_tag "posts_#{@posts.next_page}", :src => path_to_next_page(@posts), :loading => "lazy" do %>
      <%= render "loading" %>
    <% end  %>
  <% end  %>
<% end %>
Enter fullscreen mode Exit fullscreen mode

Note that this example uses methods from Kaminari, but you can adapt it to any other pagination method.

We don't need anything special in the controller. A standard index method works:

class PostsController < ApplicationController
  def index
    @posts = Post.page(params[:page]).per(params[:per_page])
  end
end
Enter fullscreen mode Exit fullscreen mode

The trick here is that we use nested frames, with the frame for the next page nested inside the frame for the previous page.

That way, when the first page loads, the frame for the next page is placed at the end. When the user scrolls to that frame, it is replaced with the content of the second page. The lazy frame for the third page renders at the end.

Dynamic Forms

You can easily implement dynamic forms with Hotwire without custom logic for toggling fields on the front end. This is a bit more involved than the endless scroll use case, as it includes the use of both Turbo Stream and Stimulus.

Let's start with our form first.

<!-- app/views/posts/new.html.erb -->
<div data-controller="refresh-form" data-refresh-form-url="<%= refresh_form_posts_url(:target => "new_post") %>">
  <%= render "form" %>
</div>

<!-- app/views/posts/_form.html.erb -->
<%= form_for(@post, :data => { :target => "refresh-form.form" }) do |f| %>
  <%= f.select :kind, options_for_select([["News", :news], ["Blog", :blog]], @post.kind), {}, data: { action: "change->refresh-form#refreshForm" } %>

  <%= f.select :category, options_for_select(categories_for_kind(@post.kind), @post.category) %>
<% end %>
Enter fullscreen mode Exit fullscreen mode

The form is simple enough — we display a kind select with News and Blog options. We want to change the available categories' values based on the kind that is selected (assuming that categories_for_kind(@post.kind) returns the list of categories for the given kind).

If you look closer, you'll see that we've added some data attributes to the form.

The data-target will link the form element to the RefreshFormController Stimulus Controller's form target.
And the data-action with the value of change->refresh-form#refreshForm will call the refreshForm method on the linked Stimulus Controller every time the kind select is changed.

Let's look at our Stimulus Controller:

// app/javascript/controllers/refresh_form_controller.js
import { Controller } from "stimulus";
import { put } from "@rails/request.js";

export default class extends Controller {
  static targets = ["form"];

  refreshForm() {
    put(this.data.get("url"), {
      body: new FormData(this.formTarget),
      responseKind: "turbo-stream",
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

On all refreshForm calls, we just make a new PUT request to the controller's URL (set using the data-refresh-form-url on the same element with a data-controller="refresh-form").
The important part here is that the responseKind is set to turbo-stream.

The @rails/request library understands this response and performs instructions based on the response stream.

Now all that's left is to return the correct stream from our refresh_form call for Turbo to understand and update our form.

class PostsController < ApplicationController
  def refresh_form
    @post = Post.new
    @post.attributes = post_params
    @post.valid?
    respond_to do |format|
      format.turbo_stream
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Just update the attributes on the post and mark that you want to respond in a turbo_stream format (so that it looks up refresh_form.turbo_stream.erb).

<!-- app/views/posts/refresh_form.turbo_stream.erb -->
<%= turbo_stream.replace params[:target] do %>
  <%= render "form" %>
<% end %>
Enter fullscreen mode Exit fullscreen mode

In this step, we are reusing our form partial, wrapping it inside a turbo_stream with a replace action.

And that's all you need to get a dynamic form working.

I know this looks a bit advanced, but the refresh stimulus controller is a shared part you can now use for all your dynamic forms by adding the correct data-* attributes.
So essentially, you now get server-side dynamic form refresh without writing any new JS for other forms. Pretty awesome, right?

Append Content to Pages Without Reloading

The next use case that Hotwire makes easy is streaming HTML over a WebSocket connection and updating a page with new content as it comes in.

A good example of this is the GitHub comments section.
You can implement this very easily using Turbo Streams.

There are two parts to this.

First, we embed a turbo stream listener on the listing page that opens a WebSocket connection to the server and listens for events.

<!-- app/views/comments/index.html.erb -->
<div id="comments">
  <%= turbo_stream_from @post, :comments %>

  <% @comments.each do |comment| %>
    <%= render comment %>
  <% end %>
</div>
Enter fullscreen mode Exit fullscreen mode

Next, we update the model to broadcast new comments to the stream.

# app/models/coment.rb
class Comment < ApplicationRecord
  belongs_to :post

  after_create_commit :stream

  private

  def stream
    broadcast_prepend_later_to(post, :comments, target: :comments)
  end
end
Enter fullscreen mode Exit fullscreen mode

You don't need anything else. Turbo will automatically render the app/views/comments/_comment.html.erb partial for each new comment and send it over a WebSocket connection. It will be picked up by Turbo's JS and prepended to the target with id comments.

Let's go one step ahead and add an indication to all newly added comments with a small Stimulus Controller.

First, modify the broadcast and comment partial to include the controller conditionally.

# app/models/coment.rb
# ...
def stream
  broadcast_prepend_later_to(post, :comments,
                             target: :comments,
                             locals: { highlight: true })
end
Enter fullscreen mode Exit fullscreen mode
<!-- app/views/comments/_comment.html.erb -->
<div <%= %s(data-controller="highlight") if local_assigns[:highlight] %>
  <%= comment.body %>
</div>
Enter fullscreen mode Exit fullscreen mode

This small Stimulus controller adds a special highlight class on connection for 3 seconds and then removes it.

export default class extends Controller {
  connect() {
    this.element.classList.add("highlight");
    this.timeout = setTimeout(
      () => this.element.classList.remove("highlight"),
      3000
    );
  }

  disconnect() {
    clearTimeout(this.timeout);
  }
}
Enter fullscreen mode Exit fullscreen mode

Note: You also need to update the CSS highlighting based on the presence of that class.

Once this controller is done, you can re-use it on anything that requires a highlight class. You could even modify it to get the duration and class name from data attributes if you need that flexibility.

That's the great thing about Hotwire — it takes you a long way, and you don't have to dip your hands in JS. When you do need to write some JS, Stimulus gives you the tools to build small generic controllers that can be re-used.

Wrap Up and Further Reading

The Rails community has been really excited with the introduction of Hotwire, and rightly so.

In this post, we looked at the key components of Hotwire and how to use Hotwire in your Rails app. We touched on how you can bring your application to life using Turbo and Stimulus.

The official Hotwire screencast introduction and the Turbo documentation are great places to see what Hotwire and Turbo can do for you.

For advanced usage, I suggest heading over to the turbo-rails GitHub repo.
Sadly, the documentation is a bit sparse, but if you are not afraid to get your hands dirty, read the code and inline comments in:

  1. Turbo::FramesHelper for Turbo Frames.
  2. Turbo::Broadcastable for broadcasting to Turbo Streams from the code.
  3. Turbo::Streams::TagBuilder for broadcasting to Turbo Streams as part of inline controller actions.

Happy coding!

P.S. If you'd like to read Ruby Magic posts as soon as they get off the press, subscribe to our Ruby Magic newsletter and never miss a single post!

Top comments (0)