Hotwire with Turbo Frames, Turbo Streams, StimulusReflex, and CableReady :: Full-stack web development "Hello World" tutorials for entrepreneurs: Part 8 of 10
This post is part 8 of a 10-part series within a series that is designed to teach full-stack web development for entrepreneurs. Here we’ll dive into the recently released Hotwire stack with Ruby on Rails and leverage Hotwire’s Turbo Frames and Turbo Streams. We’ll compare side-by-side how Turbo Frames can be used alongside StimulusReflex, and how CableReady can be used to expand upon the current feature set of Turbo Streams. In addition, since we already set up a comparable “Hello World” example in Redux/React in parts 5, 6, and 7 of this mini-series, we’ll demonstrate how “HTML over the wire” solutions like Hotwire and StimulusReflex/CableReady can potentially save startup founders a significant amount of time - compared to Redux/React - to achieve modern, reactive web experiences for users.
If you are an experienced web developer and curious to dive right into the code, feel free to clone this repo and specifically check out this commit, which I walk through in detail below. Any/all feedback welcome (email me at will@wclittle.com). Thanks!
Installing the turbo-rails gem and updating JavaScript packages
Continuing from the previous tutorial in this mini-series, assuming we have already upgraded to Rails 6.1 (or were using it already from the start), we’ll begin by opening up our Gemfile and removing the turbolinks gem line (it’s no longer needed with the new turbo-rails gem). we’ll also bump our stimulus_reflex gem to "~> 3.4" and add gem 'turbo-rails' to a new line on the bottom.
We’ll also open our package.json file and remove the "@rails/ujs" line, the "cable_ready" line, and the “turbolinks” line.
After editing these two files we’ll go ahead and enter the following commands in our terminal at the quotesapp root folder:
- bundle update
- yarn upgrade
These commands will make sure we have the latest versions of our gems and JavaScript packages in place.
As an important part of the install process, let’s open up our config>environments>development.rb file and comment out line 59. We are going to want “config.assets.debug” set to false by default with our new Turbo patterns.
Next, open up the app>javascript>packs>application.js file and remove these two lines:
require("@rails/ujs").start()
require("turbolinks").start()
And to finish the install, open up your app>views>layouts>application.html.erb file and have lines 9-12 be:
<%= stylesheet_link_tag 'application', media: 'all' %>
<%= javascript_pack_tag 'application' %>
<%= yield :head %>
<%= turbo_include_tags %>
You’ll notice we removed the old turbolinks commands and added our new tags for Turbo.
Simplifying our welcome_controller and adding a new method
Next, open up your app>controllers>welcome_controller.rb file and simplify it to the following:
class WelcomeController < ApplicationController
include CableReady::Broadcaster
def index
@quote = Quote.new
@quotes = Quote.all.order(created_at: :desc)
end
def turbo_quotes
@quote = Quote.new
@quotes = Quote.all.order(created_at: :desc)
@prefix = "turbo-"
end
def create_quote
Quote.create(quote_params)
end
private
def quote_params
params.require(:quote).permit(:content, :author_name)
end
end
The reason why we removed the cable_ready and ActionCable lines here is because we are going to move them to our quote.rb model file in order to refactor our code. It’s best practice when writing software to “Don’t Repeat Yourself” (DRY), and since we had a Reflex, an API controller, and a standard Rails controller doing things to our Quotes, we’ll want to move that code into one place in order to remain DRY.
But first, let’s add the app>views>turbo_quotes.html.erb file and make it contain this:
<turbo-frame id="turbo_quotes">
<h2>Turbo Quotes</h2>
<%= render partial: 'quotes_form', locals: {prefix: 'turbo-'} %>
<ul id="quotes_list_turbo">
<%= render partial: 'quote', collection: @quotes, locals: {prefix: 'turbo-'} %>
</ul>
</turbo-frame>
The reason why we are adding those “turbo-” prefixes in here is because we’re going to reuse our Quotes form from before and stick it in a partial. There should never be two elements in the DOM with the same ID, so we need to declare the ID our form and quotes differently.
Open up your config>routes.rb file and have a new line 5 contain:
get '/turbo-quotes', to: 'welcome#turbo_quotes', as: 'turbo_quotes'
This will allow our new frame to “lazy load” into our view (which we’ll set up further below).
Finally, we’ll open up our app>controllers>v1>quotes_controller.rb file and have it simply be:
class Api::V1::QuotesController < ApplicationController
include CableReady::Broadcaster
def index
respond_to do |format|
format.json { render json: Quote.order(created_at: :desc).all }
end
end
def create
quote = Quote.create(content: params[:content], author_name: params[:author])
respond_to do |format|
format.json { render json: quote }
end
end
def destroy
Quote.find(params[:id]).destroy
respond_to do |format|
format.json { render json: params[:id] }
end
end
end
Again, we are refactoring things here since we’ll be moving our “html over the wire” calls into our quote.rb model file (which we’ll do further below).
Now, let’s go ahead and create that partial containing our form to create quotes.
We’ll create a file called app>views>welcome>_quotes_form.html.erb and in it put:
<%= form_with model: @quote, url: '/create-quote', id: "#{prefix}rails_quote_form", data: {controller: 'hello', action: 'turbo:submit-end->hello#reset'} do |f| %>
Quote: <br /> <%= f.text_area :content, required: true %> <br />
Author: <br /> <%= f.text_field :author_name, required: true %>
<br /><br />
<%= f.submit "Create Quote" %>
<% end %>
Now, you’ll notice a couple things different here than before. Not only did we add the optional prefix variable, but we called a Stimulus controller method to reset our form when our Turbo form is finished submitting. While we used to be able to reset the form from our “create_quote.js.erb” file that was rendered when we used a non-turbo form action, now our form is submitting with a “turbo stream” format.
Accommodating Turbo Streams
This means we need to do two things. First, let’s create a blank file called app>welcome>create_quote.turbo_stream.erb. We don’t need to put anything in it since we’ll be adding our newly created quotes with a push over the wire (which we’ll do below).
Second, let’s add the following method to our app>javascript>controllers>hello_controller.js file ( add these to new lines 13-15):
reset() {
this.element.reset()
}
This will successfully reset our form now since we called this method from our form partial.
Now, since we’re going to call our quotes list from two different places, let’s add in our optional prefix ID. Open up your app>views>welcome>_quote.html.erb partial file and change line 1 to be:
<li id="<%= prefix %>quote-<%= quote.id %>"
This gives us flexibility to use it in two different places.
Updating our main index view
Let’s go ahead and update the following from line 33 down to the bottom:
<h2>Rails Quotes</h2>
<%= render partial: 'quotes_form', locals: {prefix: nil} %>
<ul id="quotes_list">
<%= render partial: 'quote', collection: @quotes, locals: {prefix: nil} %>
</ul>
<%= react_component 'ReactQuotesApp' %>
<%= turbo_stream_from 'hello_turbo' %>
<turbo-frame id="turbo_quotes" src="/turbo-quotes">
<p>Loading...</p>
</turbo-frame>
If you haven’t been following along from earlier in this series, this may seem strange to you. What we’re doing here for educational purposes is first setting up a “normal” Rails form, then we’re inserting a full Redux/React app within our app (which we built in parts 5/6/7 of this mini-series). Then we are adding in our Turbo stream, and also our Turbo Frame to lazy load in our quotes form and list.
Creating our “fat” quotes model via our refactor.
Now, open up your app>models>quote.rb file and have it now contain all this:
class Quote < ApplicationRecord
include CableReady::Broadcaster
after_create_commit -> {
cable_ready['hello_quotes'].insert_adjacent_html(
selector: '#quotes_list',
position: 'afterBegin',
html: ApplicationController.new.render_to_string(partial: 'welcome/quote.html.erb', locals: {quote: self, prefix: nil})
).broadcast
ActionCable.server.broadcast("hello_quotes", {
type: "RQA::CreateQuoteSuccess",
response: {
entities: {
quotes: {
"#{id}": self
}
},
result: id
}
}
)
broadcast_prepend_to 'hello_turbo', target: "quotes_list_turbo", partial: "welcome/quote", locals: { prefix: "turbo-" }
}
after_destroy_commit -> {
cable_ready['hello_quotes'].remove(
selector: "#quote-#{id}"
).remove(
selector: "#turbo-quote-#{id}"
).broadcast
ActionCable.server.broadcast("hello_quotes", {
type: "RQA::DeleteQuoteSuccess",
response: {
data: id.to_i
}
}
)
}
end
Obviously there’s a lot going on here, so we’ll discuss it from top to bottom.
- We’re creating our “after_create_commit” method so that the lines in here are run after we have successfully saved our new quote from our Rails, React, or Turbo sections.
- The “cable_ready” section is adding our quote to our Rails list.
- The ActionCable second is adding our quote to our Redux/React app.
- The “broadcast_prepend_to” is our Turbo Stream that is sending the new HTML into our Turbo quotes list.
- After a quote is deleted from clicking on any quote, the delete method from our API controller or Reflex then triggers our “after_destroy_commit”, which uses CableReady to remove our quote from our Rails and Turbo sections, and ActionCable to update our Redux/React app.
- NOTE: the reason why we didn’t use a Turbo Stream to remove our quote from our Turbo section is because the current base functionality of the broadcast_remove_to method doesn’t support custom DOM ids. The method just assumes our element to be removed is “quote_[id]”. This is a good example where using CableReady is appropriate, since it currently has more built out features.
Simplifying our Reflex
Now, since we put all our after save and after destroy lines in our model, we can open up our app>reflexes>quotes_reflex.rb file and have it just contain:
class QuotesReflex < StimulusReflex::Reflex
def destroy
id = element.dataset[:id]
Quote.find(id).destroy
morph :nothing
end
end
As we talked about previously in this mini-series, we’re using a “nothing morph” here since we are updating our DOM from our direct CableReady broadcast. This accommodates all listening browser sessions.
Final testing, review, and push your commit to your Github repository
Now, at this point you should be able to fire up your Rails server (“rails s”) and open two browser windows side by side and see everything working. You can add a quote in any one of the six forms (i.e. the Rails, React, and Turbo forms in each browser window) and see the quote added to all six lists when you submit a form. In addition, when you click on a quote in any one of the six lists, that quote will disappear from all lists.
Be sure to review all your files as we’ve discussed previously, and then:
- git add .
- git commit -a -m "Add new Hotwire section to use Turbo Frames/Streams with Stimulus, StimulusReflex, and CableReady"
- git push
That’s it for now! In the next two parts of this mini-series we’ll walk through linting and testing to create better experiences for ourselves and code collaborators in our app.