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 HTMLdocuments 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.