Will Little Subscribe

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.

Continue to Part 9 →