If you're a web developer, you must have heard the terms Progressive Enhancement and Progressive Web Apps (PWAs). What about progressively building your web application, introducing advanced techniques only when needed?
I recently listened to an episode of the Full-Stack Radio (Building HEY with Hotwire), where David Heinemeier Hansson talked about progressive web application development using Hotwire:
Start with the absolute simplest. Don't even do Frames, don't even do anything. Do Turbo Drive to start, just build the thing with normal forms, normal everything, and when you run into issues like... this part is reset everytime I add new stuff. What's the smallest thing I could do to fix that? Alright, let's do a frame.
After some time, it's getting too complicated and you need more fidelity, or you need to send the updates live over websocket, change it to Turbo Streams. But you should always think like 'how can we start from the absolute baseline? and then progressively enhance your application one step at a time.'
Don't start out with the most expensive fireworks at the first minute. No, save that stuff. Keep your powder dry.
To paraphrase, Turbo Drive gives you the majority of the benefits out-of-box, without having to do anything. When you need more interactivity on your components, you introduce Turbo Frames, and finally, when you need to update multiple elements on your page in a single response, you introduce Turbo Streams.
This approach sounded really interesting, so I decided to give it a try by building and progressively enhancing a small UI component using all three Turbo frameworks: Drive, Frames, and Streams.
I am going to build a simple habit tracker, which lets you keep track of the number of days you've consistently followed a habit. You can increment the counter and also decrement it to reduce the count, all without writing a line of JavaScript. This is how it looks in action.
First, we'll build the basic, fully-functioning counter with Turbo Drive. Then, we'll improve it using Turbo Frames. Finally, we'll optimize it further with Turbo Streams. In fact, I also built the whole counter in pure JavaScript, using the Stimulus library, but decided to leave it out for another post.
If you need a brief overview of Hotwire, check out the following article:
A word of caution before you get started. This article is more than 4,000 words long, so don't try to finish it in one sitting. Instead, try to learn one Turbo framework a day by reading the docs and completing the related section of this post.
You should be able to finish everything in 2-3 days. I hope that after reading this article, you'll have a much better understanding of Hotwire and when to use each of its component frameworks. If you get stuck at any point, feel free to email me any time and I will try my best to help you get unstuck.
What You'll Learn:
- Setting up the Rails Application
- step 1: generate the habit model
- step 2: setup a route to show a habit
- step 3: create a controller and action
- step 4: create a view to render the habit
- Build with Turbo Drive
- step 1: create routes to update the counter
- step 2: add controller actions to handle requests
- step 3: update view to make requests
- step 4: (optional) disable turbo drive
- Build with Turbo Frames
- step 1: wrap the habit in a turbo frame
- step 2: there's no step 2
- Build with Turbo Streams
- step 1: add ids to elements of interest
- step 2: extract markers to a partial
- step 3: render template containing turbo streams
Does that sound good? Let's get started.
Setting Up the Rails Application
Let's create a new Rails app named daily-habits, and launch it using the bin/dev
command. Note that I am using Tailwind CSS for styling the components.
$ rails new daily-habits --css=tailwind
$ cd daily-habits
$ bin/dev
We'll start by creating the core data model for our application: a Habit.
Step 1: Generate the Habit Model
The very first thing that we're going to do is to generate a model named Habit with two properties: name
and count
, indicating the name of the habit and how many days you've followed that habit.
$ bin/rails generate model habit name:string count:integer
After running this command, Rails will generate a few files for you, including a database migration file. Let's run the database migration, so a habits
table is created in the database.
$ bin/rails db:migrate
Now open the Rails console and create a sample habit named 'Write Every Day', because writing is awesome and you all should write every day.
$ bin/rails console
> Habit.create(name: 'Write Every Day', count: 5)
That's it. Now we have some data to work with and display on the screen. Next, we'll set up the route where we'll access the above habit.
Step 2: Setup a Route to Show a Habit
Open the config/routes.rb
file and add a new route to it.
Rails.application.routes.draw do
resources :habits, only: [:show]
end
This route instructs Rails to call the show
action method on the HabitsController
class, when a user visits the path /habits/1
. You can also access this route programmatically using the URL helper named habit_path
, passing an instance of Habit
model.
If you want to learn routing in detail, check out the following article.
Now that we have a valid route to show the habit, we need a controller and action to handle the incoming request.
Step 3: Create a Controller and Action
Let's use a generator to generate a HabitsController
class with a show
action.
$ bin/rails generate controller habits show
It will generate quite a few files, but we're only interested in the controller class. Edit the show
action on the HabitsController
class to set up an instance of habit.
class HabitsController < ApplicationController
def show
@habit = Habit.first
end
end
At this point, we have a habit to show in our views, which we'll tackle next.
Step 4: Create A View to Render the Habit
Before building the view to show a habit, we'll tweak the application layout that Rails generated for us, so we have a nice area in the middle of the page to work with.
Open the application.html.erb
file under the views
directory, and update the <main>
tag to add the following classes.
<main class="mx-auto mt-28 w-96 p-5 border rounded">
<%= yield %>
</main>
Don't worry if they look confusing. All we're doing is center-aligning the main
tag (with mx-auto
and w-96
), adding some padding (p-5
) and margin (mt-28
), and wrapping it inside a rounded border. Makes sense?
Now that we have a sandboxed area to play with, let's add a view to display the habit. For the HabitsController#show
action, Rails will assume the location of the view file to be under views/habits
directory, in a file named show.html.erb
.
Let's open that file and add the following code to it. To add some extra spice to our habit tracker, I've added the streak markers below the habit, similar to how GitHub shows your commit streak. This will motivate us to follow the habit every day.
<div class="text-center font-bold text-gray-700" id="habit-name">
<%= @habit.name %>
</div>
<div class="mt-3 flex justify-center items-center space-x-5">
<button class="btn bg-red-300 inline-block shadow-lg">-</button>
<div class="text-4xl font-bold"><%= @habit.count %></div>
<button class="btn bg-green-300 inline-block shadow-lg">+</button>
</div>
<div class="mt-3 p-2 flex justify-center space-x-1">
<% @habit.count.times do %>
<div class="inline-block border p-1 bg-green-400"></div>
<% end %>
</div>
Note: Don't copy + paste any code. Type everything by hand. As you type each class, save and refresh the browser to see the changes taking effect. The bin/dev
command we used to launch the web server also compiles the Tailwind class names to actual CSS, so you should instantly see the changes.
It's a magical experience. Soon, once you get the muscle memory for Tailwind class names, the development will become so much more productive and enjoyable.
One last thing: do you see the btn
class on the <button>
elements? It's not a Tailwind class. I added that so we could group the common styles for frequently used components and reuse them. To add it, we'll open the application.tailwind.css
file and add the following code to it.
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer components {
.btn {
@apply rounded-lg py-1 px-3 inline-block font-medium cursor-pointer shadow;
}
}
The class names should be self-explanatory. If you don't understand something, refer to the Tailwind CSS documentation.
Alright, we've finished the basic setup for this project. At this point, we have scaffolded the counter with a model, routes, controller, and views. Life's good.
Now visit localhost:3000/habits/1
, you should see our habit with the initial count.
However, it's not working yet. Nothing happens when you click the buttons.
Let's fix it.
Build with Turbo Drive
The magic of Turbo Drive is that you don't even notice it. It's just there, in the background, making your navigation faster.
In this section, we'll make the counter work by updating the count when you press the buttons.
I highly recommend that you read the Turbo Drive documentation before reading this section. For a brief introduction, check out this article:
In a nutshell, when you click a link or submit a form, Turbo Drive does the following:
- Prevent the browser from following the link,
- Change the browser URL using the History API,
- Request the new page using a fetch request
- Render the response HTML by replacing the current
<body>
element with the response and merging the<head>
element’s content.
The JavaScript window
and document
objects as well as the <html>
element persists from one rendering to the next.
The same goes for an HTML form. Turbo Drive converts Form submissions into fetch requests. Then it follows the redirect and renders the HTML response.
As a result, your browser doesn’t have to reload, and the app feels much faster.
We're going to use the buttons (embedded inside forms) to increment and decrement the counter. Whenever the user presses the buttons, it will submit the form to the backend, which will update the habit count, and redirect to the updated habit.
As mentioned earlier, Turbo Drive will follow the redirect and update the whole body with the new response, all without reloading the browser.
Enough talking. Let's write some code.
Step 1: Create Routes to Update the Counter
Let's create two new routes to handle the requests to increment and decrement the counter.
Rails.application.routes.draw do
resources :habits, only: [:show] do
member do
post :plus
post :minus
end
end
end
If you haven't seen the above syntax with member
before, learn more about them here: Custom, Non-Resourceful Routes in Rails
The above route configuration will generate the following routes:
$ daily-habits git:(main) ✗ bin/rails routes -g habit
Prefix Verb URI Pattern Controller#Action
plus_habit POST /habits/:id/plus(.:format) habits#plus
minus_habit POST /habits/:id/minus(.:format) habits#minus
habit GET /habits/:id(.:format) habits#show
Since the prefix for the routes is plus_habit
and minus_habit
, we can generate the corresponding URLs with plus_habit_path
and minus_habit_path
, respectively.
Now that we have the routes for the button clicks, let's add the actions to update the habits.
Step 2: Add Controller Actions to Handle Requests
Let's add two actions named plus
and minus
to increment and decrement the habit count. Note that I'm using the before_action
callback to set the habit before all actions. This eliminates the duplicated code to set the habit.
class HabitsController < ApplicationController
before_action :set_habit
def show
end
def plus
@habit.update(count: @habit.count + 1)
redirect_to @habit
end
def minus
@habit.update(count: @habit.count - 1)
redirect_to @habit
end
private
def set_habit
@habit = Habit.find_by(id: params['id'])
end
end
After updating the habit, we're redirecting the user to the habit, which renders the show
action. It will fetch and render the updated habit. Makes sense?
Alright, we're getting closer to the working implementation. Let's update the view to use the URLs for the above routes.
Step 3: Update View to Make Requests
We're going to replace the plain button tags with the button_to
helper method provided by Rails. In addition to making the code expressive, it also creates a form that is submitted to the given URL. Nothing else needs to change.
<div class="text-center font-bold text-gray-700" id="habit-name">
<%= @habit.name %>
</div>
<div class="mt-3 flex justify-center items-center space-x-5">
<%= button_to '-', minus_habit_path(@habit), class: 'btn bg-red-300 inline-block shadow-lg' %>
<div class="text-4xl font-bold"><%= @habit.count %></div>
<%= button_to '+', plus_habit_path(@habit), class: 'btn bg-green-300 inline-block shadow-lg' %>
</div>
<div class="mt-3 p-2 flex justify-center space-x-1">
<% @habit.count.times do %>
<div class="inline-block border p-1 bg-green-400"></div>
<% end %>
</div>
As you can see above, whenever the user clicks the minus button, it makes an HTTP POST
request to the minus_habit_path(@habit)
, which returns /habits/1/minus
URL.
Upon receiving the request on the above endpoint, the minus
controller action will decrement the habit count by one and redirect to the show_habit_path(@habit)
, which returns habits/1
.
Finally, Turbo Drive will follow the redirect request by making the fetch
request to habits/1
. Upon receiving the request, the show
controller action will fetch the updated habit from the database and render it.
The form submissions (and link clicks) are handled by the Turbo library. It also makes the subsequent redirect, and updates the body with the resulting response.
And we are done! The counter component should be fully functioning now. Reload the browser and click the buttons a few times. The habit count should update accordingly.
Step 4 (Optional): Disable Turbo Drive
Let's try this again, this time with Turbo Drive disabled. I want to prove to you that Turbo Drive is indeed making our application work like a single-page application.
In the app/javascript/application.js
file, add the following code to disable Turbo Drive.
Turbo.session.drive = false
Now reload the browser and click on the counter buttons. Notice something different? Every time you update the counter, the browser reloads.
This is the magic of Turbo Drive, it can update your web pages without doing a full reload.
Okay, enable Turbo Drive again. The counter should still function as expected without reloading the browser. You could just deploy it to production and be on your way with the next feature, but we're going to improve it using Turbo Frames.
Improve? But what's there to improve, you ask? Isn't it already behaving like a SPA? Well, yes, it is. But still, it's doing a lot more work than needed. Let's see how.
Build with Turbo Frames
We've learned that Turbo Drive replaces the <body>
element and merges the contents of <head>
without reloading the browser. If you open the network tab and follow the redirect, you'll see that Rails renders the entire view for the show action.
It's not that big of a deal in our case, where there's a single component and not too much HTML. However, for most large applications, you'll have a whole lot of other components on the screen, such as a header, a sidebar, some banner image at the top, a comments section, a blog post, etc.
We don't want to re-render all of these parts on our website every time someone updates the counter. Agreed, Rails is only replacing the body, but still, there can be a lot of HTML to send over the wire. What if instead of sending everything, we could only send the updated counter component?
Turbo Frames let us fix this issue by only sending specific HTML that we need to update, and keeping everything else on the page as it is.
Once again, I encourage you to review the Turbo Frames documentation to learn the basics. In a nutshell,
Turbo Frames allow predefined parts of a page to be updated on request. Any links and forms inside a frame are captured, and the frame contents automatically updated after receiving a response.
Regardless of whether the server provides a full document, or just a fragment containing an updated version of the requested frame, only that particular frame will be extracted from the response to replace the existing content.
Sounds great, right? Let's see how to implement it.
Step 1: Wrap the Habit in a Turbo Frame
The very first thing we are going to do is to wrap the element that we want to update dynamically (i.e. the habit component), inside a <turbo-frame>
tag and assign it a unique id.
<turbo-frame id="habit-1">
...
</turbo-frame>
To make this even more convenient, Rails provides a nice helper function via the turbo-rails
gem, which is included in your project by default.
<%= turbo_frame_tag 'habit-1' do %>
...
<% end %>
What's more, you can even pass an object to the turbo_frame_tag
function. Rails will call the dom_id
function on that object to ensure a unique id. Check out the turbo-rails
documentation to learn more about this helper.
Let's update the show.html.erb
view to wrap its content inside a turbo frame.
<%= turbo_frame_tag @habit do %>
<div class="text-center font-bold text-gray-700" id="habit-name">
<%= @habit.name %>
</div>
<div class="mt-3 flex justify-center items-center space-x-5">
<%= button_to '-', minus_habit_path(@habit), class: 'btn bg-red-300 inline-block shadow-lg' %>
<div class="text-4xl font-bold"><%= @habit.count %></div>
<%= button_to '+', plus_habit_path(@habit), class: 'btn bg-green-300 inline-block shadow-lg' %>
</div>
<div class="mt-3 p-2 flex justify-center space-x-1">
<% @habit.count.times do %>
<div class="inline-block border p-1 bg-green-400"></div>
<% end %>
</div>
<% end %>
If you reload the browser and inspect the HTML, you'll see the generated <turbo-frame>
tag.
Now that we've wrapped our counter inside a Turbo Frame, any clicks or form submissions made inside that frame will be intercepted by Turbo. Then it will make the fetch request to get a response, extract the <turbo-frame>
element with the matching id from the response, and replace the existing <turbo-frame>
with the new content.
This process is very similar to Turbo Drive, but instead of replacing the whole body, we are only replacing the matching Turbo Frame, which is definitely an improvement.
Step 2: There's No Step 2
That's it. We're done. Reload the browser and check if the counter still works as expected.
When you click the buttons, Turbo will submit the form to the above actions, which will update the habit count and redirect to the habit. Then, Turbo will follow the redirect to the show
action.
The show
action returns the response HTML containing the updated habit, which is also wrapped inside a <turbo-frame>
tag. Upon receiving the response, Turbo will extract the matching turbo frame and update the existing frame with it.
What's more, the response HTML is reduced to only the specific data needed to update the component. We're not sending the remaining HTML, including the <head>
content anymore.
The whole process is kind of magical, isn't it?
Here's what goes on behind the scenes to make it work.
The turbo-rails gem checks if this header is present, and uses a different, minimal layout for the response, instead of the standard application.html.erb.
Since Turbo will extract only the matching turbo frame, you don't need the rest of the original layout. Learn More.
This solves the problem we discussed at the beginning of this section. If there're any heavy components on this page, they don't need to be updated.
At this point, we have a working, efficient counter implementation.
Can we still improve it?
We are now going to use Turbo Streams to update specifically the parts of the component that actually need to change, without affecting the rest of it. Sounds good?
Let's see how to do it.
Build with Turbo Streams
Let's revisit our view template for the habit counter again. I've also removed the turbo_frame_tag
, we won't need it anymore.
<div class="text-center font-bold text-gray-700" id="habit-name">
<%= @habit.name %>
</div>
<div class="mt-3 flex justify-center items-center space-x-5">
<%= button_to '-', minus_habit_path(@habit), class: 'btn bg-red-300 inline-block shadow-lg' %>
<div class="text-4xl font-bold"><%= @habit.count %></div>
<%= button_to '+', plus_habit_path(@habit), class: 'btn bg-green-300 inline-block shadow-lg' %>
</div>
<div class="mt-3 p-2 flex justify-center space-x-1">
<% @habit.count.times do %>
<div class="inline-block border p-1 bg-green-400"></div>
<% end %>
</div>
The component is made up of four elements:
- The name of the habit
- The buttons to update the count
- The habit count
- The streak markers
As things stand now, upon clicking the buttons, we're replacing the whole component, including all of the above four elements. However, the only elements that are changing are the habit count (#3) and the streak markers (#4).
It would be nice if we could specifically target those two elements while leaving the other two as they were.
In our small example, this is not a big deal at all, but you could imagine having a large component with a few heavy parts that are updated less frequently and a few parts that update quite often.
If we're replacing the whole component each time an element changes, we have to rebuild all the heavy components that didn't change, which is not very efficient. This problem is similar to our discussion on Turbo Frames, just scoped to the context inside the frame.
Consider another scenario. What if one of the elements that need to be updated after changing the count lies outside the scope of the frame? e.g. ringing a notification at the top-right corner? Turbo Frame can't modify it, as it can only target one frame element at a time.
Turbo Frames won't let us target and update multiple elements on the page.
Turbo Streams solve this problem. Once again, I encourage you to read the Turbo Streams documentation and then return to this section.
In a nutshell,
Turbo Streams deliver page changes as fragments of HTML wrapped in self-executing <turbo-stream>
elements. Each stream element specifies an action together with a target ID to declare what should happen to the HTML inside it.
Turbo Streams let us target and update multiple elements on the page in a single request, which is pretty cool, if you think about it.
We are going to use Turbo Streams to update only the elements that change, namely the habit count and the streak markers.
Step 1: Add IDs to the Elements of Interest
To modify the elements on the page, we need to specify their IDs in the turbo stream response. In our case, we want to update the habit count and the green streak markers. So let's wrap those elements inside separate <div>
tags with IDs.
<div class="text-center font-bold text-gray-700" id="habit-name">
<%= @habit.name %>
</div>
<div class="mt-3 flex justify-center items-center space-x-5">
<%= button_to '-', minus_habit_path(@habit), class: 'btn bg-red-300 inline-block shadow-lg' %>
<div class="text-4xl font-bold">
<div id="habit-count"><%= @habit.count %></div>
</div>
<%= button_to '+', plus_habit_path(@habit), class: 'btn bg-green-300 inline-block shadow-lg' %>
</div>
<div class="mt-3 p-2 flex justify-center space-x-1">
<div id="habit-markers">
<% @habit.count.times do %>
<div class="inline-block border p-1 bg-green-400"></div>
<% end %>
</div>
</div>
Step 2: Extract Markers to a Partial
Reusing server-side templates is a major goal of Hotwire.
This step is optional, but it will let us reuse the partial to avoid duplication. You'll soon learn how.
Let's extract the streak markers to a partial named _habit_markers.html.erb
in the app/views/habits
directory.
<div id="habit-markers">
<% habit.count.times do %>
<div class="inline-block border p-1 bg-green-400"></div>
<% end %>
</div>
This is the resulting show.html.erb
view after extracting the partial:
<div class="text-center font-bold text-gray-700" id="habit-name">
<%= @habit.name %>
</div>
<div class="mt-3 flex justify-center items-center space-x-5">
<%= button_to '-', minus_habit_path(@habit), class: 'btn bg-red-300 inline-block shadow-lg' %>
<div class="text-4xl font-bold">
<div id="habit-count"><%= @habit.count %></div>
</div>
<%= button_to '+', plus_habit_path(@habit), class: 'btn bg-green-300 inline-block shadow-lg' %>
</div>
<div class="mt-3 p-2 flex justify-center space-x-1">
<%= render 'habit_markers', habit: @habit %>
</div>
The reason we extracted the partial is we want to reuse it inside the turbo stream response.
Step 3: Render Template Containing Turbo Streams
The last step is to create and render a new template called result.turbo_stream.erb
after updating the count.
class HabitsController < ApplicationController
# ...
def plus
@habit.update(count: @habit.count + 1)
render :result
end
def minus
@habit.update(count: @habit.count - 1)
render :result
end
# ...
end
The result
template contains two <turbo-stream>
elements that replace the habit count and the streak markers, respectively.
<%# app/views/habits/result.turbo_stream.erb %>
<%= turbo_stream.replace 'habit-count' do %>
<div id="habit-count"><%= @habit.count %></div>
<% end %>
<%= turbo_stream.replace 'habit-markers' do %>
<%= render 'habit_markers', habit: @habit %>
<% end %>
Note: I'm using the turbo_stream.replace
helper function provided by the turbo-rails
gem. Check out the turbo-rails
documentation for more details.
Reload the browser, and click the buttons a few times. The counter should be working as expected.
If you open the network tab, you'll notice that the response HTML is even smaller, only containing two <turbo-stream>
tags.
It is pure magic.
That's a wrap. As you saw, it's very easy to progressively enhance your web application as your needs grow.
In fact, I also implemented the above counter in pure JavaScript using Stimulus, but this article is getting way too bigger, and I doubt any of you would be still interested in doing a deep dive into Stimulus right now. So I will leave it for the next article.
Here're some additional resources, if you want to learn more about Hotwire.
Resources
- Navigate with Turbo Drive
- Decompose with Turbo Frames
- Come alive with Turbo Streams
- A Brief Introduction to Hotwire
- You Don't Need Rails to Start Using Hotwire
I hope you found this article useful and that you learned something new.
If you have any questions or feedback, didn't understand something, or found a mistake, please leave a comment below or send me an email. I look forward to hearing from you. If you get stuck at any point, feel free to email me and I'll try to help you get unstuck.
Please subscribe to my blog if you'd like to receive future articles directly in your email. If you're already a subscriber, thank you.