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