Skip to content

Instantly share code, notes, and snippets.

@simonista
Created October 30, 2025 18:57
Show Gist options
  • Save simonista/126af38e64b004347d9e205cb7d66c3f to your computer and use it in GitHub Desktop.
Save simonista/126af38e64b004347d9e205cb7d66c3f to your computer and use it in GitHub Desktop.
Turbo Rails tutorial in Rails 8.1

Turbo Rails tutorial in Rails 8.1

Notes for updating the Hotrails.dev Turbo Rails tutorial to Ruby 3.4 / Rails 8.1

  • I just did rails new quote-editor --css=sass. I figure SQLite and javascript import maps are just fine and closer to the defaults. We'll see how this pans out :)
  • I'm using gem "turbo-rails", "~> 2.0"
  • I adjusted the controller to use params.expect(quote: [:name])
  • It was helpful for me to generate a scaffold controller and compare to the controller in the tutorial, just because I am used to scaffold controllers, to see what was different/simplified.
  • I'm using gem "simple_form", "~> 5.3.0"
  • Headless chrome was already the default so I didn't need to adjust anything
  • The top-level CSS file is named application.scss not application.sass.scss
  • @include is deprecated in SASS. Everything worked, but I decided to fix the deprecation warnings. Here's what was needed:
    • Stop including mixins/media at the top level.
    • Change all other top level @include to @use
    • In files that used the media mixin, put @use "mixins/media"; at the top of the file
    • change references to media to media.media
  • No changes needed
  • "the creation and the edition" is a typo, "edition" here is not a word that means editing. I would reformat this sentence to use "creating and updating" to match the language in the tests. There are several other instances of the word "edition" on this page that are incorrect as well.
  • Add refresh to the list of stream types

NOTE: I experimented to see if I could use the new turbo morphing from turbo 2 as another route to solve the problems presented in this chapter. But I couldn't get it to play nicely with frames. I'm planning to play around with it some more. But it would be great for someone who groks it really well to present another version of this chapter that uses and discusses the trade-offs.

It does seems like there are some edge cases of morphing that are still getting worked out, or at least some "sharp knife" edges that would be helpful to explain. See hotwired/turbo#1083 and https://thoughtbot.com/blog/turbo-morphing-woes.

Note from the future: I found a good use case for exploring morphing and the refresh stream type in Chapter 8, and I feel like I understand it much better. Keep reading!

  • Instead of setting up redis, I just used the web-console gem so that my events were happening in the same process that the cable was connected to. To do this I put console on a new line in QuotesController#index, and then used the console window at the bottom of the browser on the /quotes page.
  • I opted to use the new bin/rails generate authentication feature from the Security Guide instead of Devise. It was mostly a drop in replacement with just some minor tweaks to the tutorial code to account for different naming conventions. There was a bit of extra work to get the sign in page styled, I wrapped it in the container div and switched to simple_form, which was a little awkward because there isn't really a rails model to build the form on, but I ended up with:

    <main class="container">
      <%= simple_form_for "session", url: session_path do |f| %>
        <%= f.input :email_address, input_html: { autofocus: true } %>
        <%= f.input :password %>
        <%= f.submit 'Sign in', class: "btn btn--secondary" %>
      <% end %>
    </main>

    This also required a small tweak to the generated controller to account for the scoped params:

    if user = User.authenticate_by(params.expect(session: [:email_address, :password]))

    In system tests, I first needed to make an auth helper. In test_helpers/session_test_helper.rb I added the following:

    module SessionApplicationTestHelper
      def sign_in_as(user)
        Current.session = user.sessions.create!
    
        ActionDispatch::Request.new(Rails.application.env_config).tap do |request|
          request.cookie_jar.signed[:session_id] = { value: Current.session.id, httponly: true, same_site: :lax }
        end
      end
    
      def sign_out
        Current.session&.destroy!
        ActionDispatch::Request.new(Rails.application.env_config).tap do |request|
          request.cookie_jar.delete(:session_id)
        end
      end
    end

    Then in the system test, I needed to include the new module inside the class:

    class QuotesTest < ApplicationSystemTestCase
      include SessionApplicationTestHelper
    
      # ...
    end

    And then you can use the helper sign_in_as users(:accountant). This did create some intermittent issues with the generated auth tests when both controller and system tests are run together (bin/rails test:all). I didn't dive deeply into why, but I might be contaminating the session cookie or something.

  • Turbo.session.drive = false didn't work, I had to add data-turbo="false" to the body tag.
  • Stimulus controllers are auto-loaded now.
  • Another interesting take on getting flash to work with turbo
  • There is some discussion around using layouts with turbo streams as another alternative to accomplish this, but it's not well documented. After some chats with Claude and reading the source, I finally understood that, due to a quirk in how turbo frame's are recognized, turbo stream responses from inside a frame look for a layout named turbo_rails/frame while turbo stream responses from the top level look for the normally named application layout. So if you create both application.turbo_stream.erb and turbo_rails/frame.turbo_stream.erb, you can use the layout idea to inject flash messages without as much duplication.
  • I was a little frustrated that the chapter round-up did not point out the trade-off that the empty state does not disappear when the first new quote form appears, it only disappears after that first new quote is created. And it doesn't mention that in the version hosted with the tutorial, you actually use the first method, not the :only-child method. I do recognize that it's a useful pattern to teach and potentially more helpful for notifications than for our quotes.

  • Because of ^, I went down a rabbit hole of exploring other alternatives and their trade-offs. The first was to use the new morphing feature by adding

        <meta name="turbo-refresh-method" content="morph">
        <meta name="turbo-refresh-scroll" content="preserve">

    to application.html.erb and replacing the broadcasts_to line in the Quote model with broadcasts_refreshes_to ->(quote) { [quote.company, "quotes"] }. This worked (and as a bonus, helped click for me that maybe refreshes are more helpful as a broadcast than as a direct turbo stream response). The trade-off here is that if the second user has an edit form open, it gets reset to the show partial when the refresh morph happens. But to me this is a better trade-off than what is presented in the chapter

  • Then I decided to see what "While we could play with the callbacks and override the default options of the broadcasts_to method" actually entailed. What I ended up with was:

    after_create_commit ->{ broadcast_render_later_to company, "quotes", template: "quotes/create" }
    after_update_commit ->{ broadcast_render_later_to company, "quotes", template: "quotes/update" }
    after_destroy_commit ->{ broadcast_render_to company, "quotes", template: "quotes/destroy" }

This didn't quite work because the templates use @quote (instance variable) and broadcast_render_to sends the local variable quote, so I had to update each template to reference @quote || quote. The trade-off here is things aren't quite as elegant and simple, but the advantage that both the local and streaming user get the exact same updates (except for flash updates, but that's probably desirable). In a larger codebase I'm not sure whether tying our controller-rendered turbo stream templates with our model-rendered cable stream updates would be an advantage or a liability.

  • "prevent a quote from having multiple times the same date" should be "prevent a quote from having the same date multiple times".

  • "uniqueness constraint for the couple quote_id and date" using "couple" here is technically fine, but not very commonly heard. Usually you'd say "tuple" or just "columns".

  • Since you already have an index on [:date, :quote_id], you don't also need an index on :date. All multi-column indexes can be used with a subset (prefix only) of the columns.

  • I had to apply https://8yd.no/article/date-input-height-in-safari to get the form looking right in Safari.

  • Can't add only: [... :destroy] and load the page before the destroy method exists, this is now raising an error as of a recent rails version.

  • Typo "turbo_fram_tag"

  • The chapter ends without talking about live updates. I went for the simple broadcast refreshes approach. I added the following to the line_item_dates model:

      broadcasts_refreshes_to ->(lid) { [lid.quote, "line_item_dates"] }

    and this to the quotes show view:

    <%= turbo_stream_from @quote, "line_item_dates" %>
  • I also notice we don't add empty states. This seems like a perfect "exercise for the reader" since it's pretty much the same as the quotes empty state, except you need to pass the @quote through to scope to dates. It was a fun little exercise.

  • "there is a small glitch in our application right." typo should be "right now." at the end.
  • TODO for practice: add stream syncing.
  • No notes! Nice tight little chapter to round things out.

Conclusion

I think this tutorial is incredibly valuable at helping someone gain a mental model of how turbo frames and streams work and work together. After going through it all, most of it is still very relevant to the most current rails version. I hope these contributions can help others to continue to get value out of it while being able to try it in the latest version.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment