Fetching fresh content from the server is one of the earliest problems a team encounters when developing an interactive web application.
If they were build their application with a client-side rendering framework like
React, they might consider a line of questions like: “What’s necessary to
include in our
It’s tempting to start with a similar, JavaScript- and JSON-centric line of questions when building an application with Hotwire, Turbo, and Stimulus (for example, “How should my Stimulus Controllers make fetch requests?”).
Instead, it can be more fruitful to pose questions from an opposing perspective: “How long could we wait before we introduce our first Stimulus Controller? What would it take to build this without a Turbo Stream? Could we defer to the server for this? Would a full-page navigation work? Could these fetch requests be replaced with form submissions? What would it take to get started on this feature without Stimulus, Turbo, or any JavaScript at all?”.
Why?
Each line of application code is as much of a liability as it is an asset. Teams have a finite “innovation token” budget to spend on a project. They should reserve the majority of that budget for differentiating their product from the competition, and minimize the cost of inventing (or re-inventing) Web technologies. Relying on browsers and Web protocols as much as possible frees up time and attention to spend on what’s most important: the product.
Let’s build a page to collect shipping information with HTML Over the Wire. Our page will collect parts of the address (like street number, apartment, city, and postal code) with text fields, and will present a list of state options based on the currently selected country. Synchronizing the list of states with the selected country will be the main focus of our exploration.
Our initial version will forego JavaScript entirely, and will rely on a foundation of server-rendered HTML. We’ll leverage button clicks, form submissions, full-page navigations, and URL parameters to keep the list of state options synchronized with the selected country. Then, we’ll progressively enhance the page to automatically retrieve state options whenever country selection changes.
The code samples shared in this article omit the majority of the application’s
setup. The initial code was generated by executing rails new
. The rest of the
source code from this article (including a suite of tests) can be found
on GitHub, and is best read either commit-by-commit, or as a unified
diff.
Our starting point
We’ll rely on the city-state gem to provide the dataset of country and state
pairings. The Address
class will serve as our main data model, and declares
validations and convenience methods to access access city-state
-provided
data through the CS
class:
class Address < ApplicationRecord
with_options presence: true do
validates :line_1
validates :city
validates :postal_code
end
validates :state, inclusion: { in: -> record { record.states.keys }, allow_blank: true },
presence: { if: -> record { record.states.present? } }
def countries
CS.countries.with_indifferent_access
end
def country_name
countries[country]
end
def states
CS.states(country).with_indifferent_access
end
def state_name
states[state]
end
end
The app/views/addresses/new.html.erb
template renders a form that collects
Address
information. The page renders <input type="text">
elements to
collect street number (across a pair of “line” fields), city, and postal code.
The form renders a pair of <select>
elements to collect the country and state.
Since our starting point won’t support synchronizing the selected country and
its list of state options, the “Country” field is nested within a <fieldset>
element marked with the [disabled]
attribute so that it
remains unchanged. The United States is the default selection.
<%# app/views/addresses/new.html.erb %>
<section class="w-full max-w-lg">
<h1>New address</h1>
<%= render partial: "addresses/address", object: @address %>
<%= form_with model: @address, class: "flex flex-col gap-2" do |form| %>
<fieldset class="contents" disabled>
<%= form.label :country %>
<%= form.select :country, @address.countries.invert %>
</fieldset>
<%= form.label :line_1 %>
<%= form.text_field :line_1 %>
<%= form.label :line_2 %>
<%= form.text_field :line_2 %>
<%= form.label :city %>
<%= form.text_field :city %>
<%= form.label :state %>
<%= form.select :state, @address.states.invert %>
<%= form.label :postal_code %>
<%= form.text_field :postal_code %>
<%= form.button %>
<% end %>
</section>
The <fieldset>
element declares the .contents
Tailwind CSS
utility class (applying the display: contents rule) so that its descendants
participate in the <form>
element’s flexbox layout.
Outside the <form>
element, the template renders the
app/views/addresses/_address.html.erb
view partial to estimate a date of
arrival based on the selected country. The date formatted is with the
distance_of_time_in_words_to_now
view
helper:
<%# app/views/addresses/_address.html.erb %>
<aside id="<%= dom_id(address) %>">
<p>Estimated arrival: <%= distance_of_time_in_words_to_now address.estimated_arrival_on %> from now.</p>
</aside>
In practice, the contents are irrelevant but, for our example’s sake, represent a server-side calculation that is unknowable to the client.
Clicking the <button>
element submits a POST /addresses
request to the
AddresssesController#create
action. When the submission is valid, the record
is created, and the controller serves an HTTP redirect response to
the AddressesController#show
route. When the submitted data is invalid, the
controller responds with a 422 Unprocessable Entity status and re-renders
the app/views/addresses/new.html.erb
template:
# app/controllers/addresses_controller.rb
class AddressesController < ApplicationController
def new
@address = Address.new
end
def create
@address = Address.new address_params
if @address.save
redirect_to address_url(@address)
else
render :new, status: :unprocessable_entity
end
end
def show
@address = Address.find params[:id]
end
private
def address_params
params.require(:address).permit(
:country,
:line_1,
:line_2,
:city,
:state,
:postal_code,
)
end
end
Interactivity and dynamic options
So far, our starting point serves as a simple and sturdy foundation that relies on built-in concepts that are fundamental to The Web. The form collects information and submits it to the server, and even works in the absence of JavaScript.
With our ground work laid out, we can start to build incremental improvements to the experience. Our form’s biggest issue is its inability to collect a country or state outside of the United States. Let’s fix that!
While it might be tempting to render all possible country and states pairings directly into the document, that would require rendering about 3,400 elements in every form:
irb(main):001:0> country_codes = CS.countries.keys
=>
[:AD,
...
irb(main):002:0> country_codes.flat_map { |code| CS.states(code).keys }.count
=> 3391
Rendering that many elements would inefficient. Instead, we’ll render a single
country-state pairing, then retrieve a new pairing whenever the selected country
changes. To start, we’ll remove the <fieldset>
element’s [disabled]
attribute to support collecting countries outside the United States:
--- a/app/views/addresses/new.html.erb
+++ b/app/views/addresses/new.html.erb
- <fieldset class="contents" disabled>
+ <fieldset class="contents">
<%= form.label :country %>
<%= form.select :country, @address.countries.invert %>
</fieldset>
While the new <select>
provides an opportunity to pick a different country,
that choice won’t be reflected in the form’s list of state options. How might we
fetch an up-to-date <option>
element list from the server? Could we do it
without using XMLHttpRequest, fetch, or any JavaScript at all?
Refreshing content without JavaScript
Browsers support a built-in mechanism to submit HTTP requests without JavaScript
code: <form>
elements. When submitting <form>
elements, browsers transform
the element and its related controls into HTTP requests. The <form>
element’s
[action]
and [method]
attributes inform the
request’s URL and HTTP verb.
When a <button>
or <input type="submit">
element declares a
[formaction]
or [formmethod]
attribute, clicking
the element provides an opportunity to override where and how its form is
transmitted to the server.
Since the app/views/addresses/new.html.erb
template renders the <form>
element with [method="post"]
and [action="/addresses"]
, browsers will URL
encode its controls into into the body of
POST /addresses
HTTP requests. If we declared a second <button>
element to
override the verb and URL, we could re-use that encoding process to navigate to
a page with a list of state <option>
elements that reflects the selected
country.
We’ll add a “Select country” <button>
element, making sure to render it with
[formmethod]
and [formaction]
attributes to direct the form to submit a GET
/addresses/new
request:
--- a/app/views/addresses/new.html.erb
+++ b/app/views/addresses/new.html.erb
<fieldset class="contents">
<%= form.label :country %>
<%= form.select :country, @address.countries.invert %>
+
+ <button formmethod="get" formaction="<%= new_address_path %>">Select country</button>
</fieldset>
Submitting a GET /addresses/new
request encodes the form fields’ name-value
pairs into URL parameters. The AddressesController#new
action can read
those values action whenever they’re provided, and forward them along to the
Address
instance’s constructor:
--- a/app/controllers/addresses_controller.rb
+++ b/app/controllers/addresses_controller.rb
class AddresssController < ApplicationController
def new
- @address = Address.new
+ @address = Address.new address_params
end
Since the AddressesController#new
action might handle requests that don’t
encode any URL parameters (direct visits to /addresses/new
, for example), we
also need to change the AddressesController#address_params
method to return an
empty hash in the absence of a params[:address]
value:
--- a/app/views/addresses/new.html.erb
+++ b/app/views/addresses/new.html.erb
def address_params
- params.require(:address).permit(
+ params.fetch(:address, {}).permit(
:country,
:line_1,
:line_2,
:city,
:state,
:postal_code,
)
end
end
There are countries that don’t have states or provinces. We’ll add a conditional
guard against that case to our app/views/addresses/new.html.erb
template:
--- a/app/views/addresses/new.html.erb
+++ b/app/views/addresses/new.html.erb
+ <% if @address.states.any? %>
<%= form.label :state %>
<%= form.select :state, @address.states.invert %>
+ <% end %>
Submitting the form’s values as query parameters comes with two caveats:
Any selected
<input type="file">
values will be discardedAccording 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
Unfortunately, in practice, conventional wisdom suggests that URLs over 2,000 characters are risky.
Collecting file uploads, rich text content, or long-form prose would put us at risk. In our case, the combined lengths of our user-supplied values are unlikely to exceed the 2,000 character limit. When deploying this pattern in your own applications, it’s worthwhile to assess this risk on a case by case basis.
Refreshing content with JavaScript
In the absence of JavaScript, requiring that end-users click a secondary
<button>
to fetch matching state options is effective. When JavaScript is
available, it’s tedious and has potential to confuse or surprise. It an
interaction that begging to be progressively enhanced.
Before we explore the JavaScript-powered options, let’s preserve the behavior of
our JavaScript-free version. We’ll nest the “Select country” button within a
<noscript>
element so that it’s present in the absence of
JavaScript, and absent otherwise:
--- a/app/views/addresses/new.html.erb
+++ b/app/views/addresses/new.html.erb
<fieldset class="contents">
<%= form.label :country %>
<%= form.select :country, @address.countries.invert %>
+ <noscript>
<button formmethod="get" formaction="<%= new_address_path %>">Select country</button>
+ </noscript>
</fieldset>
In its place, we’ll introduce another <button>
element to serve a similar
purpose. We’ll render the <button>
element [formmethod]
and [formaction]
attributes that match its predecessor, but we’ll mark it with the hidden
attribute to visually hide it from the document:
--- a/app/views/addresses/new.html.erb
+++ b/app/views/addresses/new.html.erb
<fieldset class="contents">
<%= form.label :country %>
<%= form.select :country, @address.countries.invert %>
<noscript>
<button formmethod="get" formaction="<%= new_address_path %>">Select country</button>
</noscript>
+ <button formmethod="get" formaction="<%= new_address_path %>" hidden></button>
</fieldset>
While end-users won’t be able to click the button, JavaScript will be able to
click it programmatically whenever a change event fires on the “Country”
<select>
element. The end result will be the same as before: a GET
/addresses/new
request.
To interact with the <button>
, we’ll introduce our first Stimulus
Controller. We’ll name our controller with the element
identifier:
--- a/app/views/addresses/new.html.erb
+++ b/app/views/addresses/new.html.erb
+ <fieldset class="contents" data-controller="element">
<%= form.label :country %>
<%= form.select :country, @address.countries.invert %>
<noscript>
<button formmethod="get" formaction="<%= new_address_path %>">Select country</button>
</noscript>
<button formmethod="get" formaction="<%= new_address_path %>" hidden></button>
+ </fieldset>
Next, we’ll mark the hidden <button>
element with the
[data-element-target="click"]
attribute so that the element
controller
retains direct access to the element as a target:
--- a/app/views/addresses/new.html.erb
+++ b/app/views/addresses/new.html.erb
<fieldset class="contents" data-controller="element">
<%= form.label :country %>
<%= form.select :country, @address.countries.invert %>
<noscript>
<button formmethod="get" formaction="<%= new_address_path %>">Select country</button>
</noscript>
- <button formmethod="get" formaction="<%= new_address_path %>" hidden>
+ <button formmethod="get" formaction="<%= new_address_path %>" hidden
+ data-element-target="click"></button>
</fieldset>
Then, we’ll render the <select>
element with an Action descriptor to
route change
events dispatched by the <select>
element to the
element#click
action:
--- a/app/views/addresses/new.html.erb
+++ b/app/views/addresses/new.html.erb
<fieldset class="contents" data-controller="element">
<%= form.label :country %>
- <%= form.select :country, @address.countries.invert %>
+ <%= form.select :country, @address.countries.invert, {},
+ data: { action: "change->element#click" } %>
<noscript>
<button formmethod="get" formaction="<%= new_address_path %>">Select country</button>
</noscript>
<button formmethod="get" formaction="<%= new_address_path %>" hidden>
</fieldset>
For the sake of consistency, render the <select>
element with
autocomplete=“off” to opt-out of autocompletion. Without explicitly opting
out of autocompletion, browsers might automatically restore state from a
previous visit to the page. Those state restorations don’t dispatch events
throughout the document in the same way as user-initiated selections would.:
--- a/app/views/addresses/new.html.erb
+++ b/app/views/addresses/new.html.erb
<%= form.label :country %>
- <%= form.select :country, @address.countries.invert, {},
+ <%= form.select :country, @address.countries.invert, {}, autocomplete: "off",
data: { action: "change->element#click" } %>
The responsibilities of the element
controller’s click
action are extremely
limited: click any elements marked as a “click” target.
// app/javascript/controllers/element_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = [ "click" ]
click() {
this.clickTargets.forEach(target => target.click())
}
}
With those changes in place, our form submission initiates a GET
/addresses/new
request whenever the “Country” selection changes:
Refreshing fragments of content
While we’ve implemented automatic “Country” and “State” synchronization, there are still some quirks to address.
For example, because the <form>
submission triggers a full-page navigation,
our application discards any client-side state like which element has focus, or
how far the page has scrolled. Ideally, changing the selected “Country” would
fetch fresh “State” options in a way that didn’t affect the rest of the
client-side context.
What we need is a mechanism that fetches content, then renders it within a fragment of the page.
Refreshing content with Turbo Frames
The <turbo-frame>
custom element has been one of the most celebrated
primitives introduced during Turbo’s evolution from Turbolinks. Turbo
Frames provide an opportunity to decompose pages into self-contained
fragments.
Descendant <a>
or <form>
elements drive a <turbo-frame>
ancestor similar to how the would navigate an <iframe>
ancestor.
Also like <iframe>
elements, <a>
or <form>
elements elsewhere in the
document are able to drive a <turbo-frame>
by targeting it
through the [data-turbo-frame]
attribute.
During a frame’s navigation, it issues an HTTP GET
request based on the path
or URL declared by its [src]
attribute. The request encodes an Accept:
text/html, application/xhtml+xml
HTTP header, and expects an HTML
document in its response. When the frame receives a response, it scans the new
document for a <turbo-frame>
element that declares an [id]
attribute
matching its own [id]
. When a matching frame is found, the element replaces
the matching frame’s contents, and uses the extracted fragment to
replace its own contents. The rest of the response is discarded.
Throughout the frame’s navigation, the browser retains any client-side context
outside of the <turbo-frame>
element, like element focus or scroll depth.
We’ll nest the “State” <select>
element within a Turbo Frame, and drive it
based on changes to the “Country” <select>
element.
First, we’ll wrap the “State” fields in a <turbo-frame>
element with an [id]
attribute generated with the field_id
view helper:
--- a/app/views/addresses/new.html.erb
+++ b/app/views/addresses/new.html.erb
+ <turbo-frame id="<%= form.field_id(:state, :turbo_frame) %>" class="contents">
<% if @address.states.any? %>
<%= form.label :state %>
<%= form.select :state, @address.states.invert %>
<% end %>
+ </turbo-frame>
Next, we’ll change the hidden <button>
element to declare a
[data-turbo-frame]
attribute. Inspired by the formtarget attribute, the
[data-turbo-frame]
attribute enables <button>
and <input type="submit">
elements to drive targetted <turbo-frame>
elements, even if they aren’t
descendants of the element. To ensure that the value matches the <turbo-frame>
element’s [id]
, we’ll rely on the same field_id
view helper to generate the
[data-turbo-frame]
attribute:
--- a/app/views/addresses/new.html.erb
+++ b/app/views/addresses/new.html.erb
<button formmethod="get" formaction="<%= new_address_path %>" hidden
- data-element-target="click"></button>
+ data-element-target="click" data-turbo-frame="<%= form.field_id(:state, :turbo_frame) %>"></button>
Programmatic clicks to the <button>
still submit GET /documents/new
requests, but those requests now drive the <turbo-frame>
instead of the entire
page. By scoping the navigation to the <turbo-frame>
, the browser maintains
the rest of the client-side state. For example, the “Country” <select>
element
retains focus throughout the interaction:
Refining the request
Like we covered above, this form’s URL encoded data is unlikely to exceed the 2,000 character limit, so our implementation is “Good Enough” for our sample case. With that being said, it might not be “Good Enough” for yours.
To demonstrate other possibilities, let’s refine to the <form>
submission
mechanism. We’ll replace the <button>
element with an <a>
element and retain
the [data-element-target]
and [hidden]
attributes. Next, we’ll transform the
[formaction]
attribute into an [href]
attribute, and omit the
[formmethod]
attribute entirely:
--- a/app/views/addresses/new.html.erb
+++ b/app/views/addresses/new.html.erb
- <button formmethod="get" formaction="<%= new_address_path %>" hidden
- data-element-target="click" data-turbo-frame="<%= form.field_id(:state, :turbo_frame) %>"></button>
+ <a href="<%= new_address_path %>" hidden
+ data-element-target="click" data-turbo-frame="<%= form.field_id(:state, :turbo_frame) %>"></a>
HTMLAnchorElement is the browser-provided class that corresponds to the
<a>
element. It manages its [href]
attribute through a collection of
URL-inspired properties (hostname, pathname, hash, etc.). We’ll
extend URL that the server-side rendering has already encoded by translating the
“Country” <select>
element’s name-value pairing into the search property.
To control which values to encode into the search parameters, we’ll add the
search-params
identifier to the <fieldset>
element’s list of
[data-controller]
tokens, then we’ll implement a corresponding controller:
--- a/app/views/addresses/new.html.erb
+++ b/app/views/addresses/new.html.erb
- <fieldset class="contents" data-controller="element">
+ <fieldset class="contents" data-controller="element search-params">
<%= form.label :country %>
<%= form.select :country, @address.countries.invert, {}, autocomplete: "off",
data: { action: "change->element#click" } %>
When an element declares multiple controller identifiers, their order
corresponds to the order that their connect() and disconnect() lifecycle
callbacks fire. Neither our element
nor our search-params
controllers
declare connect()
or disconnect()
callbacks, so their declaration order is
not significant.
Next, we’ll grant the search-params
controller access to the <a>
element by
declaring the [data-search-params-target="anchor"]
attribute:
--- a/app/views/addresses/new.html.erb
+++ b/app/views/addresses/new.html.erb
<a href="<%= new_address_path %>" hidden
+ data-search-params-target="anchor"
data-element-target="click" data-turbo-frame="<%= form.field_id(:state, :turbo_frame) %>"></a>
We’ll route change
events dispatched by the <select>
element to the
search-params#encode
action by prepending an action descriptor to the
<fieldset>
element’s list of [data-action]
tokens:
--- a/app/views/addresses/new.html.erb
+++ b/app/views/addresses/new.html.erb
<fieldset class="contents" data-controller="search-params element">
<%= form.label :country %>
<%= form.select :country, @address.countries.invert, {}, autocomplete: "off",
- data: { action: "change->element#click" } %>
+ data: { action: "change->search-params#encode change->element#click" } %>
The order of the tokens in the [data-action="change->search-params#encode
change->element#click"]
descriptor is significant. According to the
Stimulus documentation for declaring multiple actions:
When an element has more than one action for the same event, Stimulus invokes the actions from left to right in the order that their descriptors appear.
In this case, we need search-params#encode
to precede element#click
so that
the name-value pair is encoded into the [href]
attribute before we drive the
<turbo-frame>
element.
Finally, we’ll implement the search-params
controller’s encode
action to
construct an URLSearchParams instance with the name
and value
properties
read from the change
event’s target element, then assign that
instance to each of the <a>
elements’ anchor.search properties:
// app/javascript/controllers/search_params_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = [ "anchor" ]
encode({ target: { name, value } }) {
for (const anchor of this.anchorTargets) {
anchor.search = new URLSearchParams({ [name]: value })
}
}
}
With those changes in place, the search-param
controller only encodes the
<select>
element’s name-value pair into the <a>
element’s [href]
attribute
(e.g. /addresses/new?address%5Bcountry%5D=US
). It omits the rest of the form’s
name-value pairings, then defers to the ensuing element#click
controller
action to programmatically click the element.
Refreshing content with Turbo Streams
After we’re finished celebrating our wins from introducing a Turbo Frame, we need to acknowledge the trade-off we made.
Unfortunately, since the refreshed fragment is limited to the <turbo-frame>
element, the frame discards the “Estimated arrival” portion of the response.
Since that text is generated based on the selected country, it falls out of
synchronization with the client-side state.
While it might be tempting to calculate the estimation and render it
client-side, or to refresh the text with a subsequent call to XMLHttpRequest
or fetch, there’s an opportunity to deploy another new Turbo primitive: the
<turbo-stream>
custom element.
Since its release, a sizable portion of the Turbo fanfare has been dedicated to
Streams and their ability to be broadcast over WebSocket
connections or encoded into form submission
responses. We won’t be using them in either of those
capacities. Instead, we’ll render a <turbo-stream>
element directly into the
document.
It’s important to acknowledge the difference between the <turbo-stream>
element and the text/vnd.turbo-stream.html
MIME Type. For example, the
turbo-rails engine checks for the presence of
text/vnd.turbo-stream.html
within incoming Accept HTTP request headers.
When detected, the Rails will include text/vnd.turbo-stream.html
in the
Content-Type HTTP response header. Coincidentally, responses with the
Content-Type: text/vnd.turbo-stream.html
header are also very likely to
contain <turbo-stream>
elements in their body.
Like <turbo-frame>
custom elements, <turbo-stream>
elements are valid HTML,
and can be rendered directly into documents. We’ll render a <turbo-stream>
element within the <turbo-frame>
element, so that when we navigate it, its
new content will encode an operation to refresh the “Estimated arrival” text.
A Turbo Stream is comprised of two parts: the operation and the contents. The
<turbo-stream>
element determines its operation from the
[action]
attribute. The element that the operation will
affect is referenced by [id]
through the [target]
attribute. The
operation’s contents are nested within a single descendant
<template>
element. On their own, <template>
elements are
completely inert. They’re ignored by the document regardless of whether or not
JavaScript is enabled.
We’ll render a <turbo-stream>
element with action=“replace” and a
[target]
attribute to reference the <aside>
element rendered in the
app/views/addresses/_address.html.erb
view partial. The <turbo-steam>
nests
renders the app/views/addresses/_address.html.erb
inside a <template>
element:
--- a/app/views/addresses/new.html.erb
+++ b/app/views/addresses/new.html.erb
<%= form.label :state %>
<%= form.select :state, @address.states.invert %>
<% end %>
+ <turbo-stream target="<%= dom_id(@address) %>" action="replace">
+ <template><%= render partial: "addresses/address", object: @address %></template>
+ </turbo-stream>
</turbo-frame>
Since the contents of the <turbo-stream>
element’s nested <template>
renders
the app/views/addresses/_address.html.erb
partial, we can call the
turbo_stream.replace
view helper provided by
turbo-rails:
--- a/app/views/addresses/new.html.erb
+++ b/app/views/addresses/new.html.erb
<%= form.label :state %>
<%= form.select :state, @address.states.invert %>
<% end %>
- <turbo-stream target="<%= dom_id(@address) %>" action="replace">
- <template><%= render partial: "addresses/address", object: @address %></template>
- </turbo-stream>
+ <%= turbo_stream.replace dom_id(@address), partial: "addresses/address", object: @address %>
</turbo-frame>
With that change in place, navigating the <turbo-frame>
element replaces
the list of “State” options and replaces the “Estimated arrival” text
elsewhere in the document, all without discarding other client-side state like
focus or scroll:
Keep in mind, using this strategy means that the server renders the
app/views/addresses/_address.html.erb
partial twice (once outside the <form>
element, and once nested within a <turbo-stream>
) and the browser parses
the content twice (once outside the <form>
element, and once when executing
the [action="replace"]
operation).
For text, content that isn’t interactive, and content that doesn’t load external resources, any negative end-user impact caused by double-parsing will be negligible. Double-loading an uncached external resource like an image or video might cause perceptible flickering during the second render. When deploying this pattern in your own applications, it’s worthwhile to assess this risk on a case by case basis.
Wrapping up
Let’s reflect on what we’ve built.
Our form provides a list of “State” options based on the selected “Country”. In the absence of JavaScript, the page relies on manual form submissions and full-page navigations to refresh the list. When JavaScript is enabled, the page relies on automatic form submissions, and Turbo Frame navigations. Throughout the process, our page maintains a server-calculated fragment of text based on the current “Country” selection. From start to finish, we relied on fundamental, Standards-based mechanisms, then progressively enhanced the experience with incremental improvements.
Let’s also reflect on some things that aren’t part of our implementation. The application code doesn’t include:
- additional routes or controllers dedicated to maintaining the “Country”-“States” pairing
- Turbo-aware code outside of the
app/views
directory - any calls to XMLHttpRequest or fetch
- any
async
functions or Promises - client-side templating of any kind
When brainstorming a new feature, start by asking: “How far can we get with full-page transitions, server-rendered HTML, and form submissions?”, then make incremental improvements from there.