Lessons learned with Hotwire

We've been using Hotwire for the past couple of months and we've largely tried to stick to the Rails way of doing things, but there have been a few times when we've been unsure on the correct approach. This article explains how we've handled flash messages, modals, and async actions.

Lessons learned with Hotwire

Flash messages

We rely on Rails flash messages to render in response to controller actions. Typically we render them in the application.html.erb file:

<!DOCTYPE html>
<html>
  ...
  <body>
    <ul id="flash-messages">
      <% flash.each do |type, msg| %>
        <li data-turbo-cache="false"><%= msg %></li>
      <% end %>
    </ul>

    <%= yield %>
  </body>
</html>

With the introduction of turbo-frames and turbo-streams, content is often rendered outside of the application layout and so we need to wait for a full page render before new flash messages are displayed.

Instead we've created an application.turbo_stream.erb file. This is used as a layout for all turbo-stream views, allowing us to render things such as flash messages:

<%= turbo_stream.append "flash-messages" do %>
  <% flash.each do |type, msg| %>
    <li data-turbo-cache="false"><%= msg %></li>
  <% end %>
<% end %>

<%= yield %>

Flash message caching

Adding data-turbo-cache="false" is important so that Turbo does not cache flash messages. Otherwise, when previously visited pages are loaded, old flash messages may flash in and out of view as the cached HTML is applied and subsequently overwritten by the new page load.

Caveats

We have found one caveat with this approach; the application layout is only used when using view files with a turbo_stream extension. If your controller action responds directly with a turbo-stream then the application layout will be skipped.

Modals

We originally started using modals the same way as described in the blog post How to create modals using Hotwire, but we have since made a few adjustments to suit our workflow.

To start, we require an empty turbo-frame to be created in the application layout:

<!DOCTYPE html>
<html>
  ...
  <body>
    <%= yield %>

    <%= turbo_frame_tag "modal", target: "_top" %>
  </body>
</html>

We recommend adding a target attribute set to _top (we'll come back to this later).

To open a link in the turbo-frame we create a link with a data-turbo-frame attribute and value that matches the target frame, in this case "modal":

<%= link_to "View article", article_path(@article), data: { turbo_frame: "modal" } %>

When this link is clicked Turbo will fetch the page, but in order to render content into the targeted frame it expects the response to contain a matching turbo-frame tag. We could add the tag to the corresponding view:

<%= turbo_frame_tag "modal" do %>
  <div data-controller="modal" data-turbo-cache="false">
    <%= @article.content %>
  </div>
<% end %>

However, we prefer not to litter the view with that context.

We instead do this dynamically. When a Turbo link is triggered with a data-turbo-frame attribute, it adds a Turbo-frame HTTP header. We use this in the base application controller to load a different layout:

class ApplicationController < ActionController::Base
  layout lambda { |controller|
    if controller.request.headers["Turbo-frame"] == "modal"
      "modal"
    else
      "application"
    end
  }
end

We then create and use a modal.html.erb layout to wrap the contents in the required turbo-frame:

<%= turbo_frame_tag "modal" do %>
  <div data-controller="modal" data-turbo-cache="false">
    <%= yield %>
  </div>
<% end %>

Now any link with the attribute data-turbo-frame="modal" can load in a modal, without the corresponding view needing to be adapted.

Note that we also add data-turbo-cache="false" to the modal HTML element to ensure that Turbo does not cache the contents of a modal. Otherwise, similar to the flash messages, as a user visits previously visited pages old modals will flash in and out of view as the cached HTML is applied and is subsequently overwritten by the new page load.

When a link is opened in a turbo-frame it sets a src attribute on the frame to describe which URL has been loaded into it. If this attribute exists on page load then Turbo will fetch the URL and inject the response into the frame. So although we've prevented the contents of the modal from being cached by Turbo, the turbo-frame element is still cached and Turbo will re-fetch the contents based on it's src attribute.

This is particularly noticeable when you go back in the browser history to a page which had an open modal.

To resolve this we need to write some custom JavaScript to remove the src attribute when the modal is initialized. In the modal layout we've already added a Stimulus controller to the HTML element (data-controller="modal"), we just need to create the controller:

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  connect() {
    this.modalTurboFrame.removeAttribute("src");
  }

  get modalTurboFrame() {
    return document.querySelector("turbo-frame[id='modal']");
  }
}

If required, this controller can also be used to add methods to close the modal.

During initial setup of the turbo-frame used for modals, we added a target attribute set to _top. This ensures that all navigation within the modal runs against the full page, instead of loading new content within the modal.

There are some exceptions to this.

Firstly, we may want some links to open within the same modal (multi-step processes for example). For these links we can apply the data-turbo-frame="modal" attribute.

Secondly, if the modal has a form we may want the modal to close on success but re-render if there are errors. For this scenario we can put the form inside its own turbo-frame with a target set to _top. For example new.html.erb:

<h1>New Article</h1>

<%= turbo_frame_tag "article", target: "_top" do %>
  <%= render "form" %>
<% end %>

The controller for this should either redirect on success, or render the page with an unprocessable_entity error:

def create
  @article = Article.new(article_params)

  if @article.save
    redirect_to @article
  else
    render :new, status: :unprocessable_entity
  end
end

We also need to setup a turbo-stream response new.turbo_stream.erb:

<%= turbo_stream.replace "article" do %>
  <%= render "form" %>
<% end %>

Using a file is preferred over responding with the turbo-stream from the controller action directly, as it keeps the controller concise and it allows our custom application.turbo_steam.erb layout to be rendered too (which includes flash messages).

Additional modal params

Since we're rendering the modal HTML inside a layout, we have less control over it on a per-view basis. The simplest way to get around this is to use instance variables to pass data from the view to the layout. For example, a page title would need to render differently depending on whether the page is presented as a normal page or within a modal. The view can set the page title instance variable:

<% @page_title = "Edit article" %>

<%= render "form" %>

Both the modal.html.erb and application.html.erb layouts can then use this variable to render the page title however they want.

Caveats

This setup only supports one modal at a time. We haven't had a need for a multi-modal setup yet, as this is often considered an anti-pattern.

We also need to be careful if we have to set different layouts for specific controllers, as setting layout in a controller will override the lambda we set in the ApplicationController. We can work around this by either moving the required logic into the ApplicationController, or we could add support for an additional class variable such as default_layout which could be set in the specific controller and referred to by the ApplicationController.

Async actions

In some cases we want to trigger a controller action, but we're not expecting an HTML response apart from maybe a flash message.

Normally we could execute a HTTP redirect, but we can use turbo-streams to save ourselves the network traffic:

def endpoint
  flash.now[:notice] = "Endpoint triggered!"

  respond_to do |format|
    format.html { redirect_to request.referer }
    format.turbo_stream
  end
end

We also need to create an endpoint.turbo_stream.erb file, although this can be empty.

The controller action is stating that we can either respond to a HTML or a turbo-stream request. If it is a HTML request then redirect to the correct destination, however if it is a turbo-stream request then lookup and render the relevant view file. Since our turbo-stream views support flash messages, the response will include the flash message we set in the controller action.

Going forward

These approaches suit us for now, but we're still finding our feet with Hotwire and I'm sure these will evolve even further over time. So there may be a "lessons learned part 2" in the not so distant future!