Hotwire: Server-rendered live previews

Sean Doyle

Rails 7.0 is touted as one of the framework’s most ambitious to date. With the major version bump comes deprecations and backward-compatibility-breaking changes. As part of the upcoming release, rails-ujs will be deprecated in favor of the Hotwire suite of packages as Rails’ default JavaScript framework.

Let’s explore how Hotwire fits in with the rest of the Rails ecosystem! We’ll build an Article drafting experience that previews changes “live” as they’re made.

We’ll start with an out-of-the-box Rails installation that utilizes Turbo Drive, Turbo Streams, and Stimulus to then progressively enhance concepts and tools that are built directly into browsers. Plus, it’ll degrade gracefully when JavaScript is unavailable!

The code samples contained within omit the majority of the application’s setup. While reading, know that the bulk of the application’s code was generated by a rails new command

The rest of the source code from this article can be found on GitHub.

Drafting Articles

We’ll start by using Rails model generator to create some Article scaffolding code to serve as a starting point for our extensions and customizations:

bin/rails generate scaffold Article content:text
bin/rails db:migrate

We won’t be making any changes to the vast majority of the generated code, but there are two view partials that will be changed the most throughout the rest of the article. To have context for those changes, it’s worth becoming familiar with them before we get started: app/views/articles/_form.html.erb and app/views/articles/_article.html.erb.

The articles/form partial renders a single <textarea> field for our Article model’s content, along with an <input type="submit"> element:

<%= form_with(model: article) do |form| %>
  <% if article.errors.any? %>
    <div id="error_explanation">
      <h2><%= pluralize(article.errors.count, "error") %> prohibited this article from being saved:</h2>

      <ul>
        <% article.errors.each do |error| %>
          <li><%= error.full_message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div class="field">
    <%= form.label :content %>
    <%= form.text_area :content %>
  </div>

  <div class="actions">
    <%= form.submit %>
  </div>
<% end %>

Similarly, the articles/article partial renders the value of the content as plain text:

<div id="<%= dom_id article %>" class="scaffold_record">
  <p>
    <strong>Content:</strong>
    <%= article.content %>
  </p>

  <p>
    <%= link_to "Show this article", article %>
  </p>
</div>

The rest of the generated code serves as the foundation for creating, reading, updating, and destroying Article instances. While it’s crucial, they’re implementation details that we can ignore for the sake of this example.

Previewing our changes

HTTP request-response exchanges and full HTML

documents will serve as a stable foundation for the feature.

Let’s implement it through <form> submissions that are handled by Rails’ router, controller, and view layers. The HTTP response will redirect back to the Article form, with the contents encoded into the URL as a query parameter, and rendered as a preview of a published Article.

In this first version, the application doesn’t make use of any Hotwire concepts.

First, let’s introduce a :create route that handles POST requests to generate Article previews:

--- a/config/routes.rb
+++ b/config/routes.rb
  Rails.application.routes.draw do
    resources :articles
+  resources :previews, only: :create
    # For details on the DSL available within this file, see https://guides.rubyonrails.org/routing.html
  end

Next, we’ll declare a <button> element within the article/form partial’s <form> element to submit to the new POST /previews route:

--- a/app/views/articles/_form.html.erb
+++ b/app/views/articles/_form.html.erb
    <div class="actions">
      <%= form.submit %>
+    <%= form.button "Preview Article", formaction: previews_path %>
    </div>
  <% end %>

The <button> element affords an alternative means of submitting the form by declaring a formaction=“/previews” attribute. Setting [formaction="/previews"] overrides where the <form> submits to when the <button> is clicked, enabling the secondary <button> to supplement the primary submission <button>, which continues to submit to the route specified by the <form action="/articles"> element’s [action] attribute when clicked.

To ensure that clicking the <button> submits a POST request, regardless of the <form method="…"> attribute or a Rails-generated <input type="hidden" name="_method" value="…"> element, invoke form.method with name: and value: options:

--- a/app/views/articles/_form.html.erb
+++ b/app/views/articles/_form.html.erb
    <div class="actions">
      <%= form.submit %>
-    <%= form.button "Preview Article", formaction: previews_path %>
+    <%= form.button "Preview Article", formaction: previews_path,
+          name: "_method", value: "post" %>
    </div>
  <% end %>

The PreviewsController declared in app/controllers/previews_controller.rb handles the POST /previews request by constructing an instance of an Article from params corresponding to the <form> element’s submitted fields, then redirects back to the GET /articles/new route:

class PreviewsController < ApplicationController
  def create
    @preview = Article.new(article_params)

    redirect_to new_article_url(article: @preview.attributes)
  end

  private

  def article_params
    params.require(:article).permit(:content)
  end
end

When redirecting to the GET /articles/new route, we’ll encode the content value into URL query parameters by passing the Hash returned by Article#attributes to the new_article_url under the article scope.

It’s worth noting that according to the HTTP specification, there are no limits on the length of a URI:

The HTTP protocol does not place any a priori limit on the length of a URI. Servers MUST be able to handle the URI of any resource they serve, and SHOULD be able to handle URIs of unbounded length if they provide GET-based forms that could generate such URIs.

  • 3.2.1 General Syntax

In practice, conventional wisdom suggests that URLs over 2,000 characters are risky.

To read those values and pass them along to the Article instance constructed by the articles#new action, we’ll need to make some tweaks to the ArticlesController#article_params and ArticlesController#new methods so that the articles#new action supports reading from query parameters when they’re present:

--- a/app/controllers/articles_controller.rb
+++ b/app/controllers/articles_controller.rb
@@ -12,7 +12,7 @@ class ArticlesController < ApplicationController

    # GET /articles/new
    def new
-    @article = Article.new
+    @article = Article.new(article_params)
    end

    # GET /articles/1/edit
@@ -53,6 +53,6 @@ class ArticlesController < ApplicationController

      # Only allow a list of trusted parameters through.
      def article_params
-      params.require(:article).permit(:content)
+      params.fetch(:article, {}).permit(:content)
      end
  end

The Article record assigned to the @article instance variable is eventually passed to the articles/form partial and made available through the article partial-local variable. Within the articles/form partial, render the articles/article partial using the pre-populated article reference:

--- a/app/views/articles/_form.html.erb
+++ b/app/views/articles/_form.html.erb
    </div>

+  <div class="field">
+    <strong>Preview:</strong>
+    <div id="article_preview">
+      <%= render partial: "articles/article", object: article %>
+    </div>
+  </div>
+
    <div class="actions">
  <% end %>

To ensure a consistent structure between the articles#show and the articles/content partial rendered within the articles#new template, we’ll change the articles/article partial to also render the articles/content partial.

--- a/app/views/articles/_article.html.erb
+++ b/app/views/articles/_article.html.erb
  <div id="<%= dom_id article %>" class="scaffold_record">
    <p>
      <strong>Content:</strong>
-    <%= article.content %>
+    <%= render partial: "articles/content", object: article.content %>
    </p>

The app/views/articles/_content.html.erb partial will serve as an example of server-side rendering. In our case, we’ll transform the content into HTML by passing its plain-text value to Action View’s simple_format helper:

<%= simple_format content %>

The #simple_format view helper wraps any text separate by two newlines (\n\n) in <p> elements.

We’re being intentional minimal here. If you’re feeling imaginative, consider an articles/content partial with more a complicated set of transformations (like Markdown parsing or other rich-text styling).

Progressively Enhancing the experience with Hotwire

Since Rails generators provide out-of-the-box integration with Turbo and Stimulus, our application’s <a>-initiated navigations and <form>-powered submissions are augmented by Turbo Drive.

In spite of that, we haven’t capitalized on any opportunities to implement any Hotwire-specific capabilities. Let’s build atop the foundation we’ve established for POST /previews requests by progressively enhancing the experience with Turbo Stream.

After we make our changes, responses to POST /previews submissions will embed server-generated HTML into <turbo-stream> elements, which our client-side application can render.

In this case, Turbo will process the <turbo-stream action="replace"> elements that contain the Article preview HTML by finding corresponding elements within the client’s current document replacing their HTML contents, without redirecting or navigating the page, all without a single line of application-specific JavaScript.

Turbo Streaming updates to the document

On the client-side, Turbo Drive monitors our page’s submit events, and intervenes whenever <form> elements are submitted. During <form> submissions, Turbo Drive will ensure that the resulting HTTP requests are transmitted with the Accept: text/vnd.turbo-stream.html, text/html, application/xhtml+xml header.

On the server-side, Turbo Rails handles requests with Accept: text/vnd.turbo-stream.html, text/html, application/xhtml+xml by categorizing them with the turbo_stream format in the same way that Rails transforms Accept: text/html requests into an html format or Accept: application/json requests into a json format.

Having a dedicated turbo_stream format affords Turbo Rails applications with the full suite of Rails’ rendering of capabilities. For example, we can leverage the dedicated turbo_stream rendering format by modifying our controller code to render the previews/create.turbo_stream.erb template when responding to requests with the Turbo Stream Accept header:

--- a/app/controllers/previews_controller.rb
+++ b/app/controllers/previews_controller.rb
    def create
      @preview = Article.new(article_params)

-    redirect_to new_article_url(article: @preview.attributes)
+    respond_to do |format|
+      format.html { redirect_to new_article_url(article: @preview.attributes) }
+      format.turbo_stream
+    end
    end

The template for the articles#create action (declared in app/views/previews/create.turbo_stream.erb) embeds our articles/article partial into a <turbo-stream> element:

<%= turbo_stream.update "article_preview" do %>
  <%= render partial: "articles/article", object: @preview, as: :article %>
<% end %>

We’re using the Turbo Rails-provided turbo_stream helper to generate our <turbo-stream> elements. The update action is most appropriate for our use case, since it will replace the descendants of the targeted element while retaining the element itself.

Since we’re hard-coding the <turbo-stream> element’s target attribute to article_preview, we’re coupling our template to the requesting page’s structure and naming. If we were to pass the article_preview along in the request, we could make our response more flexible and limit the appearance of that value to a single place: the article/form partial. We can pass the identifier value along by encoding it into the URL as the render_into query parameter:

--- a/app/views/articles/_form.html.erb
+++ b/app/views/articles/_form.html.erb
    <div class="actions">
      <%= form.submit %>
      <%= form.button "Preview Article", name: "_method", value: "post",
-          formaction: previews_path %>
+          formaction: previews_path(render_into: "article_preview") %>
    </div>
  <% end %>

Then, we’ll change our response template to read the "article_preview" value from the params[:render_into] key:

- <%= turbo_stream.update "article_preview" do %>
+ <%= turbo_stream.update params[:render_into] do %>
    <%= render partial: "articles/article", object: @preview, as: :article %>
  <% end %>

Let’s review!

At this point, our end-users can draft an Article and can preview their changes by clicking on a Preview Article button to request the server-rendered HTML version.

Live previews as you type

Let’s enhance that experience even more by cutting out the need to click the Preview Article button.

Stimulus is one of packages included in the Hotwire suite. Stimulus Controllers enable our applications to transform browser-side events into function calls on our controller instances by routing them as Stimulus Actions.

We’ll start by declaring a form controller to augment our <form> element. To utilize the form identifier, we’ll declare the app/javascript/controllers/form_controller.js file:

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
}

Then we’ll render the <form> element with [data-controller="form"] attribute:

--- a/app/views/articles/_form.html.erb
+++ b/app/views/articles/_form.html.erb
-<%= form_with(model: article) do |form| %>
+<%= form_with(model: article, data: { controller: "form" }) do |form| %>
    <% if article.errors.any? %>
      <div id="error_explanation">
        <h2><%= pluralize(article.errors.count, "error") %> prohibited this article from being saved:</h2>

To grant direct access to the <button> element’s HTMLButtonElement instance, we’ll make our form controller aware of it by declaring [data-form-target="preview"] attribute on the on the <button>:

--- a/app/views/articles/_form.html.erb
+++ b/app/views/articles/_form.html.erb
    <div class="actions">
      <%= form.submit %>
      <%= form.button "Preview Article", formaction: previews_path(render_into: "article_preview"),
-          name: "_method", value: "post" %>
+          name: "_method", value: "post",
+          data: { form_target: "preview" } %>
    </div>
  <% end %>

In our controller, we’ll add support for accessing the HTMLButtonElement instance through the previewTarget property by declaring "preview" as a Stimulus Target:

--- b/app/javascript/controllers/form_controller.js
+++ b/app/javascript/controllers/form_controller.js
  import { Controller } from "@hotwired/stimulus"

  export default class extends Controller {
+   static get targets() { return [ "preview" ] }
  }

Whenever the contents of <input>, <select>, or <textarea> elements’ values change, browsers fire an input event. To route those events as Stimulus Actions, we’ll declare [data-action="input->form#preview"] on the <form> element:

--- a/app/views/articles/_form.html.erb
+++ b/app/views/articles/_form.html.erb
-<%= form_with(model: article, data: { controller: "form" }) do |form| %>
+<%= form_with(model: article, data: { controller: "form", action: "input->form#preview" }) do |form| %>
    <% if article.errors.any? %>
      <div id="error_explanation">
        <h2><%= pluralize(article.errors.count, "error") %> prohibited this article from being saved:</h2>

Whenever any <form> element descendant fires an input event, Stimulus will respond by calling the preview() method in the form controller (as instructed by the directive declared in the form[data-action] attribute).

The preview() action handles the event by finding the element annotated with the [data-form-target="preview"] attribute (in this case, our Preview Post button), and programmatically clicking it.

--- b/app/javascript/controllers/form_controller.js
+++ b/app/javascript/controllers/form_controller.js
  import { Controller } from "@hotwired/stimulus"

  export default class extends Controller {
    static get targets() { return [ "preview" ] }
+
+   preview() {
+     this.previewTarget.click()
+   }
  }

When end-users visit the application with JavaScript-enabled browsers, their changes to the <form> fields will submit the <form> automatically.

In the spirit of progressive enhancement, we’ll want to ensure that the feature gracefully degrades when JavaScript is unavailable. To do so, we’ll make sure that the Preview Post is always rendered to the page. Then whenever JavaScript is available, we’ll hide it. We’ll extend the form controller to manage the <button> element’s visibility.

In the absence of JavaScript, Turbo won’t have an opportunity to inject the Accept: text/vnd.turbo-stream.html into the Accept header, so requests made by submitting the <form> will have the Accept: text/html header. When those requests are handled by our server, the response will redirect like it did before we introduced any turbo_stream-specific code.

During the form controller’s lifecycle, Stimulus invokes the connect() function. Once the controller is connected, we can hide the <button> by annotating it with the hidden attribute:

  import { Controller } from "@hotwired/stimulus"

  export default class extends Controller {
    static get targets() { return [ "preview" ] }
+
+   connect() {
+     this.previewTarget.hidden = true
+   }

    preview() {
      this.previewTarget.click()
    }
  }

Improving even further

For example, if we want to guard against flooding our clients and servers with extraneous requests whenever a swift keyboardist quickly enters text, it would be worthwhile to debounce preview submissions:

--- a/app/javascript/controllers/form_controller.js
+++ b/app/javascript/controllers/form_controller.js
@@ -1,8 +1,13 @@
  import { Controller } from "@hotwired/stimulus"
+import debounce from "https://cdn.skypack.dev/lodash.debounce"

  export default class extends Controller {
    static get targets() { return [ "preview" ] }

+  initialize() {
+    this.preview = debounce(this.preview.bind(this), 300)
+  }
+
    connect() {
      this.previewTarget.hidden = true
    }

    preview() {
      this.previewTarget.click()
    }
  }

Wrapping up

We’ve built an Article drafting experience that provides end-users with a preview of the final version, live as they type. The experience relies on Turbo Streams and Stimulus to progressively enhance tools and concepts built directly into browsers, and will gracefully degrade to relying upon HTML and HTTP in scenarios where JavaScript is unavailable.

Our implementation is light on JavaScript code, never encodes our Article records into JSON representations, and doesn’t include a single line of application-specific code calling to XMLHttpRequest or fetch, in spite of sourcing all of its HTML’s structure and data from the server.

These omissions are at the core of Hotwire’s value proposition. Turbo, specifically, demonstrates that applications can treat <form> elements as declarative HTML alternatives to imperative Asynchronous JavaScript and XML (AJAX) invocations. They sit within a page’s document, inert and ready to be executed at a moment’s notice by end-users. An application’s <form> elements are the bedrock for any and all Hotwire-augmented end-user experiences.