---
title: Dynamic forms with Stimulus
teaser: 'Progressively enhance your server-generated forms with Stimulus.

  '
tags: stimulus,rails,hotwire,web
author: Sean Doyle
published_on: 2022-02-01
---

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][].

[progressively enhance]: https://developer.mozilla.org/en-US/docs/Glossary/Progressive_Enhancement
[source code]: https://github.com/thoughtbot/hotwire-example-template/tree/hotwire-example-stimulus-dynamic-forms
[suite of tests]: https://github.com/thoughtbot/hotwire-example-template/tree/hotwire-example-stimulus-dynamic-forms/test
[commit-by-commit]: https://github.com/thoughtbot/hotwire-example-template/compare/hotwire-example-stimulus-dynamic-forms

## 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][]:

```ruby
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
```

[Active Record]: https://guides.rubyonrails.org/active_record_basics.html
[Action Text]: https://edgeguides.rubyonrails.org/action_text_overview.html
[enumeration]: https://edgeapi.rubyonrails.org/classes/ActiveRecord/Enum.html
[validations]: https://edgeguides.rubyonrails.org/active_record_validations.html

Our `Document` records are managed by a conventional `DocumentsController`
class:

```ruby
# 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">`][radio] elements,
collects the `content` through an Action Text-powered [`<trix-editor>`][trix]
element, and submits the [`<form>`][form] element as a `POST` request to the
`DocumentsController#create` action:

[group]: https://edgeapi.rubyonrails.org/classes/ActionView/Helpers/FormBuilder.html#method-i-collection_radio_buttons
[trix]: https://trix-editor.org
[radio]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/radio
[form]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/form

```erb
<%# 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](https://images.thoughtbot.com/blog-vellum-image-uploads/gVLCYBB9QQq0Nyw2ASxy_150657727-09919557-322c-4697-b529-0703b418c470.png)

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

When the submission's data is invalid, the controller responds with a [422
Unprocessable Entity][422] 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][_errors] is omitted here, but draws inspiration from the
template that [Rails scaffolds for new models][scaffolds]:

[redirect]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Redirections
[422]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/422
[scaffolds]: https://github.com/rails/rails/blob/984c3ef2775781d47efa9f541ce570daa2434a80/railties/lib/rails/generators/erb/scaffold/templates/_form.html.erb.tt#L2-L12
[_errors]: https://github.com/thoughtbot/hotwire-example-template/blob/hotwire-example-stimulus-dynamic-forms/app/views/application/_errors.html.erb

![Validation error messages rendered above the form's fields](https://images.thoughtbot.com/blog-vellum-image-uploads/VMkuufpQWuUuureWAcof_150657724-98d59bc0-4eda-4f75-bc83-3e2f587e3ec8.png)

## 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][fieldset-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":

```diff
--- 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][tw-disabled] with its [`hidden` class][tw-hidden]:

```diff
-    <%= 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.

[fieldset-disabled]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/fieldset#attr-disabled
[boolean attribute]: https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes#boolean_attributes
[:disabled]: https://developer.mozilla.org/en-US/docs/Web/CSS/:disabled
[display: none]: https://developer.mozilla.org/en-US/docs/Web/CSS/display#box
[tw-disabled]: https://tailwindcss.com/docs/hover-focus-and-other-states#disabled
[tw-hidden]: https://tailwindcss.com/docs/display#hidden

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?

[XMLHttpRequest]: https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest
[fetch]: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API

### 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:

```diff
--- 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:

[HTTP GET]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/GET

```diff
--- 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:

```diff
--- 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:

<video controls muted src="https://user-images.githubusercontent.com/2575027/150658326-72a0a8e6-c131-41c6-a364-e049d6f7f982.mov"></video>

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.

[HTTP specification]: https://tools.ietf.org/html/rfc2616#section-3.2.1
[conventional wisdom]: https://stackoverflow.com/a/417184
[URL parameters]: https://developer.mozilla.org/en-US/docs/Learn/Common_questions/What_is_a_URL#parameters
[formmethod]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button#attr-formmethod
[formaction]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button#attr-formaction
[HTTP GET]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/GET

## 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][noscript].
Descendants of `<noscript>` elements are present when JavaScript is unavailable
to the browser, and ignored otherwise:

[noscript]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/noscript

```diff
--- 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:

[Stimulus Controller]: https://stimulus.hotwired.dev/handbook/hello-stimulus#controllers-bring-html-to-life
[identifier]: https://stimulus.hotwired.dev/reference/controllers#identifiers

```diff
--- 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:

```diff
--- 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][fieldset-disabled]. We're relying on the
[`field_name`][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`][field_id] view helper to consistently generate
matching `[id]` and `[aria-controls]` attributes.

[field_name]: https://edgeapi.rubyonrails.org/classes/ActionView/Helpers/FormBuilder.html#method-i-field_name
[field_id]: https://edgeapi.rubyonrails.org/classes/ActionView/Helpers/FormBuilder.html#method-i-field_id
[input]: https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/input_event
[Action descriptors]: https://stimulus.hotwired.dev/reference/actions
[name]:https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#attr-name
[aria-controls]: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-controls

```javascript
// 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.

```diff
--- 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"][]:

```diff
--- 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 %>
```

[autocomplete="off"]: https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete#values

<video controls muted src="https://user-images.githubusercontent.com/2575027/150658649-45b9aa1d-fe28-4a71-8772-0fb9c4502640.mov"></video>

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][jsx]).
* 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][redux]).

[jsx]: https://reactjs.org/docs/introducing-jsx.html
[instance state]: https://reactjs.org/docs/state-and-lifecycle.html#adding-local-state-to-a-class
[redux]: https://redux.js.org

In fact, we never serialize to or deserialize from <abbr title="JavaScript
Object Notation">JSON</abbr> 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.

[HTML Specification]: https://dev.w3.org/html5/spec-LC/

### 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:

```erb
<%= 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][select-multiple]. Let's change the `fields` controller
to support that possibility:

[select-multiple]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/select#attr-multiple

```diff
--- 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
```

<video controls muted src="https://user-images.githubusercontent.com/2575027/150658732-bb552dc2-4f25-4f26-b33b-ca539da6ac4b.mov"></video>

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

[Rule of Least Power]: https://www.w3.org/2001/tag/doc/leastPower.html
