Dynamic forms with Turbo

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 JSON schema? How will my components render the data fetched from the server? Where will we store our application state?”.

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.

A form collecting information about an Address with the Country select disable

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:

  1. Any selected <input type="file"> values will be discarded

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

    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.