bootrails.com is now moving to saaslit.com  See you there!
bootrails moves to  saaslit.com
Post

Rails 7 Hotwire, a tutorial

Motivation

At BootrAils, we don’t use Hotwire at all. We consider it more simple to launch an MVP without it. That being said, Hotwire looks promising, and deserves special attention, since it’s shipped by default with Rails.

Why Hotwire

Hotwire is an attempt to limit the use of JavaScript when coding a full-stack web application. It’s not a lib or a gem, but a set of features to achieve this goal. Interestingly enough, there’s also Hotwire for other server-side frameworks like Django. In this tutorial, we will see how to use it with Rails.

What is Hotwire

So Hotwire is a set of techniques :

  • Turbo, itself composed of :
    • Turbo Drive : each link will not trigger a full page reload on click. Instead, only the HTML inside the <body> tag will be replaced.
    • Turbo Frames : will decompose a page into pieces of content that can be updated individually. Before Turbo Frames, one page = one URL.
    • Turbo Streams : same idea as Turbo Frame, but broader scope, we’ll see how.
    • Turbo Native : targets mobile devices - not covered in this tutorial.


  • Stimulus :
    • Stimulus is a tiny JS tool that does not render any HTML (contrary to most JS frontend frameworks), instead it adds some responsiveness on top of existing HTML.

Prerequisites for a Rails + Hotwire tutorial

1
2
3
4
5
6
7
8
$> ruby -v  
ruby 3.0.0p0 // you need at least version 3 here  
$> bundle -v  
Bundler version 2.2.11  
$> npm -v  
8.3.0 // you need at least version 7.1 here  
$> yarn -v  
1.22.10

Create Fresh new Rails project, from scratch

Type in your shell :

1
2
3
4
5
mkdir railshotwire && cd railshotwire 
echo "source 'https://rubygems.org'" > Gemfile  
echo "gem 'rails', '~> 7.0.0'" >> Gemfile  
bundle install  
bundle exec rails new . --force -d=postgresql  

Create simple files (no Hotwire so far:)

Continue in your shell by typing :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
  # Create a default controller
  echo "class HomeController < ApplicationController" > app/controllers/home_controller.rb
  echo "end" >> app/controllers/home_controller.rb

  # Create another controller
  echo "class OtherController < ApplicationController" > app/controllers/other_controller.rb
  echo "end" >> app/controllers/other_controller.rb

  # Create routes
  echo "Rails.application.routes.draw do" > config/routes.rb
  echo '  get "home/index"' >> config/routes.rb
  echo '  get "other/index"' >> config/routes.rb
  echo '  root to: "home#index"' >> config/routes.rb
  echo 'end' >> config/routes.rb

  # Create a default view
  mkdir app/views/home
  echo '<h1>This is home</h1>' > app/views/home/index.html.erb
  echo '<div><%= link_to "go to other page", other_index_path %></div>' >> app/views/home/index.html.erb
    
    # Create another view
  mkdir app/views/other
  echo '<h1>This is another page</h1>' > app/views/other/index.html.erb
  echo '<div><%= link_to "go to home page", root_path %></div>' >> app/views/other/index.html.erb

  
  # Create database and schema.rb
  bin/rails db:create
  bin/rails db:migrate
  

Good ! Run

1
bin/rails s

And open your browser to see your app locally. You should see something like this :

localhost
localhost

Hotwire : Turbo Drive

Open the “Network” tab of the DevTools of your browser.

Now navigate from homepage to the other page by clicking links. What can you notice ? Navigation is achieved via XHR. The CSS is not reloaded on each navigation, the JavaScript is also not reloaded. Actually the only things reloaded are the DOM differences inside the HTML body tag.

turbo drive
Turbo Drive

What for ?

  • From our experience, the perceived performance gap is absolutely huge, each click responds immediately, even for deployed, production-ready apps. Which means not only on localhost, on your computer, where the improvements may not be noticed.
  • However this comes with some issues like DOM flickering, fewer accessibility, and a few more weird behaviours.

Turbo Drive is extremely powerful, but it takes time and patience to master it.

Hotwire : Turbo Frame

Turbo Frames allow developers to split the current page into chunks that can be updated in isolation, when new data comes from the server.

Classic use cases for Turbo Frames could be :

  • Inline edition
  • Tabbed content
  • Searching, sorting, and filtering data

Let’s see an example.

Change the app/controllers/home_controller.rb like this :

1
2
3
4
5
6
7
8
9
10
11
12
13
class HomeController < ApplicationController

  # @route GET /turbo_frame_form 
  def turbo_frame_form
  end

  # @route POST /turbo_frame_submit 
  def turbo_frame_submit
    extracted_anynumber = params[:any][:anynumber]
    render :turbo_frame_form, status: :ok, locals: {anynumber: extracted_anynumber, comment: 'turbo_frame_submit ok' }
  end
  
end

Add app/views/home/turbo_frame_form.html.erb with this content :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<section>

    <%= turbo_frame_tag 'anyframe' do %>
            
      <div>
          <h2>Frame view</h2>
          <%= form_with scope: :any, url: turbo_frame_submit_path, local: true do |form| %>
              <%= form.label :anynumber, 'Type an integer (odd or even)', 'class' => 'my-0  d-inline'  %>
              <%= form.text_field :anynumber, type: 'number', 'required' => 'true', 'value' => "#{local_assigns[:anynumber] || 0}",  'aria-describedby' => 'anynumber' %>
              <%= form.submit 'Submit this number', 'id' => 'submit-number' %>
          <% end %>
      </div>
      <div>
        <h2>Data of the view</h2>
        <pre style="font-size: .7rem;"><%= JSON.pretty_generate(local_assigns) %></pre> 
      </div>
      
    <% end %>

</section>

And change your routes.rb like this :

1
2
3
4
5
6
7
8
9
10
Rails.application.routes.draw do
  get 'home/index'
  get 'other/index'

  get '/home/turbo_frame_form' => 'home#turbo_frame_form', as: 'turbo_frame_form'
  post '/home/turbo_frame_submit' => 'home#turbo_frame_submit', as: 'turbo_frame_submit'


  root to: "home#index"
end

Finally change the home view like this, in app/views/home/index.html.erb

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<h1>This is home</h1>
<div><%= link_to "go to other page", other_index_path %></div>

<%= turbo_frame_tag 'anyframe' do %>        
  <div>
      <h2>Home view</h2>
      <%= form_with scope: :any, url: turbo_frame_submit_path, local: true do |form| %>
          <%= form.label :anynumber, 'Type an integer (odd or even)', 'class' => 'my-0  d-inline'  %>
          <%= form.text_field :anynumber, type: 'number', 'required' => 'true', 'value' => "#{local_assigns[:anynumber] || 0}",  'aria-describedby' => 'anynumber' %>
          <%= form.submit 'Submit this number', 'id' => 'submit-number' %>
      <% end %>
  <div>
<% end %>

Restart your local web server, refresh your local browser, you should be able to see this at first glance :

default view
Default view

Now type any number in the field, and submit the form. Something like this should appear :

Turbo Frame in action
Turbo Frame in action

Only the frame part changed, the first title and first link didn’t move.

Hotwire : Turbo streams

Theory

According to the docs,

Turbo Streams deliver page changes over WebSocket, SSE or in response to form submissions using just HTML and a set of CRUD-like actions.

In other words, you can either :

  • Update a block of HTML when responding to a POST/PUT/PATCH/DELETE action (GET will not work)
  • Broadcast a change to all users, without the need for any browser refresh.

The most simple example

Change the app/controllers/other_controller.rb like this :

1
2
3
4
5
6
7
8
9
class OtherController < ApplicationController

  def post_something
    respond_to do |format|
      format.turbo_stream {  }
    end
  end

end

And change your routes.rb like this :

1
2
3
4
5
6
7
8
9
10
11
12
Rails.application.routes.draw do
  get 'home/index'
  get 'other/index'

  get '/home/turbo_frame_form' => 'home#turbo_frame_form', as: 'turbo_frame_form'
  post '/home/turbo_frame_submit' => 'home#turbo_frame_submit', as: 'turbo_frame_submit'

  # Add this line below
  post '/other/post_something' => 'other#post_something', as: 'post_something'

  root to: "home#index"
end

Good ! Now each time the ‘/other/post_something’ endpoint is reached, rails will automagically try to find the app/views/other/post_something.turbo_stream.erb template.

Add app/views/other/post_something.turbo_stream.erb with the following content :

1
2
3
4
5
<turbo-stream action="append" target="messages">
  <template>
    <div id="message_1">This changes the existing message!</div>
  </template>
</turbo-stream>

Good ! It means that the response will try to append (see the “action” attribute) the template of turbo-frame with id “messages”.

Finally change app/views/other/index.html.erb with the following content :

1
2
3
4
5
6
7
8
9
10
11
12
<h1>This is another page</h1>
<div><%= link_to "go to home page", root_path %></div>

<div style="margin-top: 3rem;">
  <%= form_with scope: :any, url: post_something_path do |form| %>
      <%= form.submit 'Post something' %>
  <% end %>
  <turbo-frame id="messages">
    <div>An empty message</div>
  </turbo-frame>
</div>

Now launch your local web server

1
bin/rails s

Open your web browser, and go to the “other” page.

Other page
Other page

Click on the “Post something” button :

Line added
Line added

Good ! Turbo Streams allows developers to append a message after a submission, without any page reload. It happens rather seamlessly.

What if we want to “replace” the message, instead of “append” a new message ?

Change app/views/other/post_something.turbo_stream.erb with the following content :

1
2
3
4
5
<turbo-stream action="replace" target="messages">
  <template>
    <div id="message_1">This changes the existing message!</div>
  </template>
</turbo-stream>

Only the attribute “action” changed on line 1 : action is now replaced.

You can check that everything is working properly in your local browser.

Hotwire : Turbo native

Turbo native helps to build applications on mobile devices, this will not be covered here. However we won’t remove this paragraph, so that you’ll be fully aware that Turbo Native is part of Hotwire :)

Stimulus

Hotwire actually has a JS tool, as if the goal of Hotwire is to avoid it. The reason is that Turbo-* tools are not enough to cover all scenarios. There are some cases where JS is still needed. In order to limit the need for it, Stimulus considers the HTML as the single source of truth (for those who know Redux).

Change app/views/other/index.html.erb like this :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<h1>This is another page</h1>
<div><%= link_to "go to home page", root_path %></div>

<div style="margin-top: 2rem;">
  <%= form_with scope: :any, url: post_something_path do |form| %>
      <%= form.submit 'Post something' %>
  <% end %>
  <turbo-frame id="messages">
    <div>An empty message</div>
  </turbo-frame>
</div>

<div style="margin-top: 2rem;">
  <h2>Stimulus</h2>  
  <div data-controller="hello">
    <input data-hello-target="name" type="text">
    <button data-action="click->hello#greet">
      Greet
    </button>
    <span data-hello-target="output">
    </span>
  </div>
</div>

Change app/javascript/controllers/hello_controller.js like this :

1
2
3
4
5
6
7
8
9
10
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = [ "name", "output" ]

  greet() {
    this.outputTarget.textContent =
      `Hello, ${this.nameTarget.value}!`
  }
}

Open your browser at localhost, go to the “other” page, and play with the example.

This example is directly picked from the docs. It’s enough to understand Stimulus’ goal : add interactivity on the front-end side.

Concluding thoughts

Hotwire is great. The best news is that UX - which means your end user, drives the hype and innovation nowadays.

Less JavaScript, more responsiveness, they said.

That’s partially true. In practice, it takes some time to really handle it correctly. We’ve hit some problems about accessibility and some complicated use-cases that Stimulus wasn’t able to tackle. Outside of this, it will probably save you some jQuery/vanillaJS headaches. For a MVP, Hotwire could be seen as optional, since it adds a layer of complexity - this is why we skipped it for bootrails.

However if you like it you can get a good training here : https://hotrails.dev

No free lunch. But choices.

Enjoy !

This post is licensed under CC BY 4.0 by the author.