Dynamic forms with Stimulus

Sean Doyle

Imagine a page to share a Document with varying levels of access. Marking a Document as “publish” grants public access, marking it as “draft” limits access to the document’s creator, and marking it as “passcode protected” grants access to anyone who knows the passcode.

A missing “passcode” value shouldn’t block the creation of a Document with “publish”- or “draft”-level access. Likewise, a Document marked with “passcode protected” access without a “passcode” value is invalid.

Interactivity is a core selling point for client-side rendering frameworks. Their value propositions are at their most compelling when they demonstrate their ability to change a page’s shape and content in response to end-user actions.

If we built this page with a client-side rendering framework, our form could store the selected level of access in-memory as a JavaScript object. We could use that stored value to determine whether or not to render parts of the page to collect a “passcode” value. In response to a change in the access level, our framework could re-render the pertinent portions of the page, all without additional communication with the server.

Unfortunately, server-side rendering frameworks don’t have that luxury. The server renders the page once, and only once when responding to an HTTP request. If we built a version of this feature with a server-side rendering framework, what would it take to achieve a similar level of interactivity and network efficiency?

Progressively enhancing server-generated HTML

We’ll start by establishing a baseline version that renders HTML retrieved over HTTP without any JavaScript. Our version will rely on the server to always render all of the form’s fields, including those that collect the “passcode”.

Our initial version will rely on full-page navigations and round-trips to the server to fetch updated HTML. It’ll even work in the absence of JavaScript.

Once we’ve established a foundation, we’ll progressively enhance the form with JavaScript, making it more interactive with each incremental improvement.

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 commit-by-commit.

Our starting point

We’ll declare a Document model backed by Active Record. The Document class defines an enumeration to outline the possible levels of access, accepts Action Text content, and declares presence validations:

class Document < ApplicationRecord
  enum :access, publish: 0, draft: 1, passcode_protected: 2

  has_rich_text :content

  with_options presence: true do
    validates :content
    validates :passcode, if: :passcode_protected?
  end
end

Our Document records are managed by a conventional DocumentsController class:

# app/controllers/documents_controller.rb

class DocumentsController < ApplicationController
  def new
    @document = Document.new
  end

  def create
    @document = Document.new document_params

    if @document.save
      redirect_to document_url(@document)
    else
      render :new, status: :unprocessable_entity
    end
  end

  def show
    @document = Document.find params[:id]
  end

  private

  def document_params
    params.require(:document).permit(
      :access,
      :passcode,
      :content,
    )
  end
end

The app/views/documents/new.html.erb template collects the Document records’ access level through a group of <input type="radio"> elements, collects the content through an Action Text-powered <trix-editor> element, and submits the <form> element as a POST request to the DocumentsController#create action:

<%# app/views/documents/new.html.erb %>

<section class="w-full max-w-lg">
  <h1>New document</h1>

  <%= form_with model: @document do |form| %>
    <%= render partial: "errors", object: @document.errors %>

    <%= field_set_tag "Access" do %>
      <%= form.collection_radio_buttons :access, Document.accesses.keys, :to_s, :humanize do |builder| %>
        <span>
          <%= builder.radio_button %>
          <%= builder.label %>
        </span>
      <% end %>
    <% end %>

    <%= field_set_tag "Passcode protected" do %>
      <%= form.label :passcode %>
      <%= form.text_field :passcode %>
    <% end %>

    <%= form.label :content %>
    <%= form.rich_text_area :content %>

    <%= form.button %>
  <% end %>
</section>

A form collecting information about a Document, including its access level and content

When the submission is valid, the record is created, the data is written to the database, and the controller serves an HTTP redirect response to the DocumentsController#show action.

When the submission’s data is invalid, the controller responds with a 422 Unprocessable Entity status and re-renders the app/views/documents/new.html.erb template to include the app/views/application/_errors.html.erb partial. That partial’s source code is omitted here, but draws inspiration from the template that Rails scaffolds for new models:

Validation error messages rendered above the form's fields

Dynamic form fields without JavaScript

So far, our page’s starting point serves as a robust foundation that relies on fundamental concepts built-into the web. The form collects information and submits it to the server, and even works in browsers with JavaScript disabled.

With our foundation in place, we can start to make incremental improvements. For example, always rendering the “passcode” field, regardless of the currently selected “access” level might confuse an end-user. To enhance that experience, we’ll introduce a mechanism to hide “passcode” fields and ignore any already provided “passcode” values when the selected level of access is “publish” or “draft”.

We can render the “passcode” field’s ancestor <fieldset> element with the disabled attribute. The attribute controls whether or not to encode values from its descendant fields into the form submission’s request.

When rendering the app/views/documents/new.html.erb template, we’ll mark the <fieldset> element with the [disabled] boolean attribute if the Document record’s “access” level is anything other than “passcode protect”:

--- a/app/views/documents/new.html.erb
+++ b/app/views/documents/new.html.erb
-    <%= field_set_tag "Passcode protected" do %>
+    <%= field_set_tag "Passcode protect", disabled: !@document.passcode_protect? do %>
       <%= form.label :passcode %>
       <%= form.text_field :passcode %>
     <% end %>

When the <fieldset> element is [disabled], it matches the :disabled pseudo-class. Our application can rely on that pseudo-class to control its visibility within the page. For example, when the <fieldset> is disabled, we can apply the display: none rule by combining Tailwind’s disabled: variant with its hidden class:

-    <%= field_set_tag "Passcode protect", disabled: !@document.passcode_protect? do %>
+    <%= field_set_tag "Passcode protect", disabled: !@document.passcode_protect?, class: "disabled:hidden" do %>
       <%= form.label :passcode %>
       <%= form.text_field :passcode %>
     <% end %>

While responding to an invalid submission, the DocumentsController#create controller action and subsequent app/views/documents/new.html.erb template rendering will share the same Document instance. This means that along with any validation error messages, the view will render the <fieldset> element’s [disabled] attribute and the corresponding <input type="radio"> element based on whether the instance is passcode protected.

Since our servers are responsible for rendering the page’s HTML, we need to communicate with the server to render HTML in response to a client-side change to the “access” level. What would it take to render new HTML from the server without using XMLHttpRequest, fetch, or any JavaScript at all?

Fetching remote data without JavaScript

Browsers provide a built-in mechanism to submit HTTP requests without JavaScript code: <form> elements. End-users can submit <form> elements to issue HTTP requests by clicking <button> and <input type="submit"> elements. What’s more, those <button> elements are capable of overriding where and how that <form> element transmits its submission by through their formmethod and formaction attributes.

Alongside our <input type="radio"> elements (grouped within the “Access” field set), we’ll add a “Select access” <button> element to keep the field set and access level synchronized:

--- a/app/views/documents/new.html.erb
+++ b/app/views/documents/new.html.erb
     <%= field_set_tag "Access" do %>
       <%= form.collection_radio_buttons :access, Document.accesses.keys, :to_s, :humanize do |builder| %>
         <span>
           <%= builder.radio_button %>
           <%= builder.label %>
         </span>
       <% end %>
+      <button formmethod="get" formaction="<%= new_document_path %>">Select access</button>
     <% end %>

The <button> element’s [formmethod="get"] attribute directs the <form> to submit as an HTTP GET request and the [formaction="/documents/new"] attribute directs the <form> to submit to the /documents/new path. This HTTP verb and URL path pairing might seem familiar: it’s the same request our browser will make when we visit the current page.

Submitting <form> as a GET request encodes all the fields’ values into URL parameters. We can read those values in the DocumentsController#new action whenever they’re provided, and use them to render the <form> element and its fields:

--- a/app/controllers/documents_controller.rb
+++ b/app/controllers/documents_controller.rb
 class DocumentsController < ApplicationController
   def new
-    @document = Document.new
+    @document = Document.new document_params
   end

Since the DocumentsController#new action might handle requests that don’t encode any URL parameters, we also need to change the DocumentsController#document_params method to return an empty hash in the absence of a params[:document] value:

--- a/app/controllers/documents_controller.rb
+++ b/app/controllers/documents_controller.rb
   def document_params
-    params.require(:document).permit(
+    params.fetch(:document, {}).permit(
       :access,
       :passcode,
       :content,

If an “access” level is encoded into the request’s URL parameters, those values will be forwarded along to the Document instance referenced while rendering the app/views/documents/new.html.erb template.

That instance’s access level controls whether or not the “passcode” <fieldset> element will be rendered as [disabled], which in turn controls its visibility within the page:

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.

In our example’s case, a URL query string exceeding 2,000 characters is a real risk, since a Document record’s content could exceed that limit on its own, regardless of other form field names and values. When deploying this pattern in your own applications, it’s worthwhile to assess this risk on a case by case basis.

Dynamic form fields with JavaScript

We’ve reached the limits of what’s possible without relying on JavaScript. There are still opportunities for more incremental enhancements to the experience. Let’s toggle the “Access” <fieldset> element’s [disabled] attribute entirely on the client-side, without any communication with the server.

To ensure continued support for our JavaScript-free baseline, so we’ll nest the <button formmethod="get"> within a <noscript> element. Descendants of <noscript> elements are present when JavaScript is unavailable to the browser, and ignored otherwise:

--- a/app/views/documents/new.html.erb
+++ b/app/views/documents/new.html.erb
       <%= form.collection_radio_buttons :access, Document.accesses.keys, :to_s, :humanize do |builder| %>
         <span>
           <%= builder.radio_button %>
           <%= builder.label %>
         </span>
       <% end %>

+      <noscript>
         <button formmethod="get" formaction="<%= new_document_path %>">Select access</button>
+      </noscript>
     <% end %>

To manage our form’s JavaScript behavior, we’ll declare a Stimulus Controller. The fields token serves are our controller’s identifier. We’ll modify our <form> element so that it declares fields token within a [data-controller] attribute:

--- a/app/views/documents/new.html.erb
+++ b/app/views/documents/new.html.erb
-  <%= form_with model: @document do |form| %>
+  <%= form_with model: @document, data: { controller: "fields" } do |form| %>
     <%= render partial: "errors", object: @document.errors %>

     <%= field_set_tag "Access" do %>

We’ll route input events dispatched by our <input type="radio"> elements to our fields controller so that we can respond to changes in selection. We’ll render each element with a [data-action] attribute that encoding the input->fields#enable" token:

--- a/app/views/documents/new.html.erb
+++ b/app/views/documents/new.html.erb
     <%= field_set_tag "Access" do %>
       <%= form.collection_radio_buttons :access, Document.accesses.keys, :to_s, :humanize do |builder| %>
         <span>
-          <%= builder.radio_button %>
+          <%= builder.radio_button aria: { controls: form.field_id(:access, builder.value, :fieldset) },
+                                   data: { action: "input->fields#enable" } %>
           <%= builder.label %>
         </span>
       <% end %>
     <% end %>

Stimulus treats [data-action] attributes as Action descriptors that instruct Controllers on how to respond to events that dispatched throughout the document. The matching fields#enable implementation declared in the controller controls the form’s <fieldset> elements.

First, our controller loops through the form’s <fieldset> elements, marking any element with a [name] attribute that matches the <input type="radio"> element’s [name] as disabled. We’re relying on the field_name view helper to consistently generate matching [name] attributes.

Then, the controller reads the changed <input type="radio"> element’s name and aria-controls attributes. If it finds a <fieldset> element whose [id] attribute is referenced by the <input type="radio"> element’s [aria-controls] attribute, the controller removes the [disabled] attribute. We’re relying on the field_id view helper to consistently generate matching [id] and [aria-controls] attributes.

// app/javascript/controllers/fields_controller.js

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  enable({ target }) {
    const elements = Array.from(this.element.elements)
    const selectedElements = [ target ]

    for (const element of elements.filter(element => element.name == target.name)) {
      if (element instanceof HTMLFieldSetElement) element.disabled = true
    }

    for (const element of controlledElements(...selectedElements)) {
      if (element instanceof HTMLFieldSetElement) element.disabled = false
    }
  }
}

function controlledElements(...selectedElements) {
  return selectedElements.flatMap(selectedElement =>
    getElementsByTokens(selectedElement.getAttribute("aria-controls"))
  )
}

function getElementsByTokens(tokens) {
  const ids = (tokens ?? "").split(/\s+/)

  return ids.map(id => document.getElementById(id))
}

We’ll update the app/views/documents/new.html.erb template to encode the <fieldset> element’s [id] and [name] attributes.

--- a/app/views/documents/new.html.erb
+++ b/app/views/documents/new.html.erb
-    <%= field_set_tag "Passcode protect", disabled: !@document.passcode_protect?, class: "disabled:hidden" do %>
+    <%= field_set_tag "Passcode protect", disabled: !@document.passcode_protect?, class: "disabled:hidden",
+                                id: form.field_id(:access, :passcode_protected, :fieldset),
+                                name: form.field_name(:access) do %>
       <%= form.label :passcode %>
       <%= form.text_field :passcode %>
     <% end %>

Finally, we’ll opt-out of autocompletion. Without explicitly opting out of autocompletion, browsers could attempt to optimize the experience by restoring state from a previous visit. Those state restorations don’t dispatch events in the document in the same way as user-initiated selections would. For the sake of consistency, render each <input type="radio"> element with autocomplete=“off”:

--- a/app/views/documents/new.html.erb
+++ b/app/views/documents/new.html.erb
     <%= field_set_tag "Access" do %>
       <%= form.collection_radio_buttons :access, Document.accesses.keys, :to_s, :humanize do |builder| %>
         <span>
-          <%= builder.radio_button aria: { controls: form.field_id(:access, builder.value, :fieldset) },
+          <%= builder.radio_button autocomplete: "off",
+                                   aria: { controls: form.field_id(:access, builder.value, :fieldset) },
                                    data: { action: "input->fields#enable" } %>
           <%= builder.label %>
         </span>
       <% end %>
       <button formmethod="get" formaction="<%= new_document_path %>">Select access</button>
     <% end %>

We’ve made several improvements along the way, and have achieved our interactivity goals. Let’s acknowledge the techniques that weren’t necessary to include in our implementation:

  • We never render within our JavaScript code (for example, with .jsx templates).
  • We don’t connect elements to or disconnect elements from the document.
  • We don’t translate changes made to the “passcode” field into an in-memory data store (for example, storing instance state or a global state container).

In fact, we never serialize to or deserialize from JSON at all. Most client-rendered applications generate their HTML from server-sourced data. Sometimes, that data is already encoded into HTML; sometimes it’s encoded into JSON and stored in-memory in a bespoke data structure with an application-specific schema.

By relying on the document to serve as our storage our server-rendered application avoids storing stateful data entirely. The document stores state in elements’ attributes and content. The structure of the data is flexible, but is ultimately bound by the limitations outlined in the HTML Specification. Our application treats the document as its single source of truth.

Supporting other form fields

While <input type="radio"> elements are an appropriate choice for our set of three possible choices, it’s worthwhile to consider what’s necessary to handle a larger set of choices. Let’s consider replacing our set of <input type="radio"> buttons with a <select> with a list of <option> elements:

<%= field_set_tag do %>
  <%= form.label :access %>
  <%= form.select :access, [], {}, autocomplete: "off",
                  data: { action: "change->fields#enable" } do %>
    <% Document.accesses.keys.each do |value| %>
      <%= tag.option value.humanize, value: value,
                                     aria: { controls: form.field_id(:access, value, :fieldset) } %>
    <% end %>
  <% end %>
<% end %>

While this particular <select> element is limited to a single selected choice at a time, it’s possible for other <select> elements to have multiple selected options at once. Let’s change the fields controller to support that possibility:

--- a/app/javascript/controllers/fields_controller.js
+++ b/app/javascript/controllers/fields_controller.js
 export default class extends Controller {
   enable({ target }) {
-    const selectedElements = [ target ]
+    const selectedElements = "selectedOptions" in target ?
+      target.selectedOptions :
+      [ target ]

     for (const field of this.element.elements.namedItem(target.name)) {
       if (field instanceof HTMLFieldSetElement) field.disabled = true

Wrapping up

We set out to make our server-rendered form page more interactive. We established a foundational version guided by the Rule of Least Power.

We started with a sturdy and robust foundation built atop HTML. We relied on HTTP requests to ensure our page was functional in the absence of JavaScript. From there, we leveraged Stimulus’s ability to route browser-based events and infer application state from the document.

By enhancing Rails-rendered HTML, we made several incremental JavaScript-powered progressive enhancements without the need for client-side re-rendering.