Hotwire Form Widgets That Stay Plain-Rails on the Wire
Rich form controls — a multi-select combobox, a self-submitting form, a self-erasing confirmation — built so the wire stays boring. The through-line: the client invents no wire format; it materializes ordinary HTML inputs and lets Rails parse what Rails already knows how to parse. Grounded in Fizzy's combobox controllers, auto-submit lifecycle, and additive form_with decorator helpers.
You're building the filter bar on a Kanban board. It has a control that says "Assigned to…" — click it, a little panel drops open, you check off three teammates, and the board narrows to cards assigned to any of them. Click "Status…" and pick exactly one of Open / Closed / Not Now. These are rich form controls: a multi-select pill, a single-select dropdown, a thing that shows "Assigned to Alice, Bob, or Carol" as its label. Nothing a bare <input type="text"> can do.
So here's the question that decides the whole design: when the user finishes picking, what does the server receive?
That question has a tempting wrong answer, and it's the one you'd reach for first.
The naive version: invent a little protocol
You've got a fancy widget on the client. The server needs to know what got selected. The obvious move is to have the widget report its selection in some shape you make up, and then teach the server to parse that shape. In Rails, that looks like a controller that receives a blob and pulls it apart:
class CardsController < ApplicationController
def index
# the widget POSTed a JSON body like {"assignees":[4,9,12],"status":"closed"}
selection = JSON.parse(request.body.read)
assignee_ids = selection["assignees"] # hope it's an array
status = selection["status"] # hope it's a string, not an array
@cards = Current.account.cards
@cards = @cards.assigned_to(assignee_ids) if assignee_ids.present?
@cards = @cards.with_status(status) if status.present?
# ...and now do CSRF by hand, because this isn't a real form post
end
end
Three things just went wrong, and Rails was right there the whole time. First, you invented a wire format — some JSON shape that exists nowhere except in your head and this one parser — and now the widget and the controller have to agree on it forever; rename "assignees" to "assignee_ids" on one side and the other silently reads nil. Second, you threw away params: no strong-parameters whitelist, no automatic array-vs-scalar coercion, no permit. You're hand-checking is_a?(Array) instead. Third — the quiet one — by POSTing a bespoke JSON body instead of a real <form>, you also stepped outside the CSRF token Rails stamps onto every form that performs an unsafe request, so now you're reintroducing that protection by hand, badly.
The naive shape is a new protocol per widget: the client serializes some bespoke structure, the server hand-parses it, and the two are coupled by a contract only you remember.
The through-line of this entire tutorial is the escape from that: the client invents no wire format. It lets Rails parse what Rails already knows how to parse. A combobox doesn't report {"assignees":[...]}. It makes the page contain <input type="hidden" name="assignee_ids[]" value="4"> — three of them — and then a plain form_with submits, and the server reads params[:assignee_ids] as ["4","9","12"] with zero glue. The widget's whole job is to materialize ordinary form inputs. Everything downstream is the Rails you already know.
Beat 1 — A combobox that materializes real hidden inputs
Look at the actual control. Here is Fizzy's multi-select "Assigned to…" filter, top to bottom (filters/settings/_assignees.html.erb:1-16 — the relevant frame; the list of checkable people follows):
<% filter = user_filtering.filter %>
<%= tag.div class: "quick-filter",
data: {
controller: "dialog filter multi-selection-combobox",
action: "keydown.esc->dialog#close ...",
filter_show: user_filtering.show_assignees?,
multi_selection_combobox_no_selection_label_value: "Assigned to…",
multi_selection_combobox_label_prefix_value: "Assigned to" } do %>
<button type="button" class="btn input input--select ..." data-action="click->dialog#toggle:stop">
<span class="overflow-ellipsis" data-multi-selection-combobox-target="label"></span>
</button>
<template data-multi-selection-combobox-target="hiddenFieldTemplate">
<%= hidden_field_tag "assignee_ids[]", nil, data: { filter_settings_target: "field" } %>
</template>
...
Stop on the <template> (_assignees.html.erb:14-16). That is the hinge of the entire tutorial, and it's pure Rails. It's a hidden_field_tag "assignee_ids[]" — the server rendered that. The server decided the field's name. The server decided it should be an array ([]). And because the enclosing thing is a real Rails <form>, anything else Rails wants stamped onto a form — like the CSRF token on an unsafe submit — gets stamped for free, by the framework, exactly as it always does. The <template> element is HTML's built-in "inert blueprint": its contents are parsed but not active — not submitted, not displayed — until something clones them into the live document.
So what does the widget do? When you check off a person, it clones that template once per selection, sets each clone's value, and appends it to the form (multi_selection_combobox_controller.js:120-127):
#addHiddenFields() {
this.#selectedValues().forEach(value => {
const [ field ] = this.hiddenFieldTemplateTarget.content.cloneNode(true).children
field.removeAttribute("id")
field.value = value
this.element.appendChild(field)
})
}
Read it in plain terms, ignoring that it's JavaScript: for each selected person, stamp out a copy of the server's hidden-input blueprint, fill in the id, and drop it in the form. Select Alice, Bob, and Carol, and the form now literally contains:
<input type="hidden" name="assignee_ids[]" value="4">
<input type="hidden" name="assignee_ids[]" value="9">
<input type="hidden" name="assignee_ids[]" value="12">
The widget never built a JSON object. It built form inputs — the same inputs you'd get from three checkboxes you typed by hand. The fancy multi-select pill is, on the wire, indistinguishable from a boring HTML form.
"Why clone a server-rendered
<template>instead of just building the inputs in JavaScript? It's only an<input>— the client couldcreateElementit." Because the moment the client builds the input, the client owns the input's name, its array-ness, and anything else Rails cares about — and now that knowledge lives in two places that drift. Render the blueprint on the server and the nameassignee_ids[]is authored exactly once, by the side that also writes the strong-parameters list that reads it. The client copies a decision; it never makes one. That's the difference between "the client speaks Rails forms" and "the client and server share a secret protocol."
Now the payoff, on the server. The filter form posts as an ordinary GET, and the controller's strong parameters read it with no special handling at all (filter/params.rb:4-18):
PERMITTED_PARAMS = [
:assignment_status,
:indexed_by,
:sorted_by,
:creation,
:closure,
card_ids: [],
column_ids: [],
assignee_ids: [],
creator_ids: [],
closer_ids: [],
board_ids: [],
tag_ids: [],
terms: []
]
assignee_ids: [] is the standard strong-parameters idiom for "permit an array." Because the form contained three inputs named assignee_ids[], Rails parsed them into params[:assignee_ids] == ["4", "9", "12"], and permit(assignee_ids: []) whitelists exactly that. No JSON.parse. No is_a?(Array). No bespoke parser. The combobox spoke Rails' native form language, and Rails' native form parser understood it for free. Count the edge cases this line absorbs for free: array coercion, the scalar-vs-array distinction, and the whitelist — all of it falls out of "the widget made real inputs and let params do its job." (The CSRF token rides the same free ride on any mutating form; this particular filter is a GET, so Rails skips it — but that's Rails making the call, not you.)
Single-select is the same trick, smaller
The single-select "Status…" dropdown is the identical idea with one input instead of many. Its template renders a scalar hidden field (filters/settings/_indexed_by.html.erb:15-17):
<template data-combobox-target="hiddenFieldTemplate">
<%= hidden_field_tag :indexed_by, nil, data: { filter_settings_target: "field" } %>
</template>
hidden_field_tag :indexed_by — no [], because status is one value, not many. The single-select controller keeps exactly one such field and just rewrites its value as the selection changes (combobox_controller.js:67-71):
#buildHiddenField() {
const [field] = this.hiddenFieldTemplateTarget.content.cloneNode(true).children
this.element.appendChild(field)
return field
}
Same cloneNode from the same kind of <template>; the only difference is one field whose value gets overwritten versus N fields appended. And on the server, params[:indexed_by] is a plain string permitted by :indexed_by in that same list. The multi and the single share the mechanism — clone a server-authored hidden-input blueprint — and differ only in the field's name shape (name[] vs name), which is exactly the difference Rails already encodes between an array param and a scalar one. The widget's "single vs multi" distinction is Rails' "scalar vs array" distinction; they're not two facts kept in sync, they're one fact expressed once.
"What about the label — 'Assigned to Alice, Bob, or Carol'? Surely that's custom client logic." It is client logic, but watch what it reaches for. The label is built by
toSentence(labels, { two_words_connector: " or ", last_word_connector: ", or " })(multi_selection_combobox_controller.js:46-49) — a hand-rolled mirror of Rails' ownArray#to_sentence, down to thetwo_words_connector/last_word_connectoroption names (text_helpers.js:15-37). Even the display helper declines to invent something new; it ports a helper the reader already knows from ERB. The instinct runs all the way down: speak Rails, even in the parts that never touch the wire.
The contrast: the naive widget serializes a JSON object the server must hand-parse, coupling two sides to a protocol you maintain forever and stepping outside Rails' form-level CSRF on any mutating submit. Fizzy's combobox materializes <input type="hidden" name="assignee_ids[]"> cloned from a server-rendered <template>, so a plain form_with reads params[:assignee_ids] through ordinary strong parameters — the same code that would read three hand-typed checkboxes. The widget got fancier; the wire stayed boring.
Beat 2 — Forms that submit themselves
The combobox materializes the inputs. But the user never clicks a "Submit" button — the board re-filters the instant you finish picking. How does the form go without a submit button, and without you wiring up a "when the dropdown changes, POST it" handler?
The naive version is a round-trip you orchestrate by hand: read the new value, build a request, fire it, swallow the response. That's the JSON-protocol problem again wearing a different hat. The Rails-shaped answer is: the form submits itself, as a real form submission, and the framework does the rest.
Two mechanisms cooperate, and both are worth seeing as Rails-visible contracts.
First, the act of submitting. Fizzy's tiny form controller exposes one method (form_controller.js:26-28):
submit() {
this.element.requestSubmit()
}
The load-bearing word is requestSubmit(), not submit(). They sound interchangeable; they are not. Calling form.submit() bypasses HTML's native validation and bypasses Turbo — the browser does a raw, full-page navigation, and every Hotwire benefit evaporates. requestSubmit() behaves exactly as if a real submit button were clicked: native required/validation fires first, the submit event dispatches, and Turbo intercepts it to do its frame-targeted, no-full-reload thing. One word is the difference between "I left Rails-and-Hotwire" and "I stayed inside them." The widget triggers a real submission; it doesn't simulate one.
Second — and this is the prettier half — how does the combobox tell the form to submit? Look back at the checkable list item from Beat 1. Its button carries this (filters/settings/_assignees.html.erb:30):
<button type="button" class="btn popup__btn"
data-action="dialog#close multi-selection-combobox#change filter-settings#change form#submit">
Read data-action left to right: clicking this button closes the dialog, tells the combobox to record the change (which materializes the hidden inputs from Beat 1), tells the filter-settings controller to react, and then tells the form to submit.
That is the same instinct as everything else in the series: declare intent as data on the wire, let the framework route it. The naive version would have the combobox hold a reference to the form and call form.submit() directly — a hard dependency baked into JavaScript. Here the dependency is expressed as data the server controls, so re-wiring "what happens when you pick someone" is editing an ERB attribute, not refactoring two JS classes.
"Isn't 'event names declared in a data attribute' just a JavaScript thing I'm being asked to admire?" No — it's the Rails-visible contract, which is the only part you author. Campfire reaches for the identical pattern: its Stimulus controllers
dispatchnamed events that other controllers listen for, an event bus (presence_controller.js:21,read_rooms_controller.js:20). The convergence is the lesson: two unrelated 37signals products both route widget intent as named events rather than direct calls. The difference is where they put the seam — Campfire kept its event bus background, internal to the JS; Fizzy lifts the wiring up into thedata-actionattribute the server renders, which makes the contract teachable and editable in ERB. Same force — declare intent as data, don't hard-wire calls — exposed at the layer you actually touch.
You can see the self-submitting form at its absolute minimum elsewhere, stripped of the combobox entirely. When Fizzy detects the browser is in a different timezone than the user's saved one, it silently corrects it with a form that exists only long enough to fire (layouts/shared/_time_zone.html.erb:2-4):
<%= auto_submit_form_with url: my_timezone_path, method: :put do %>
<%= hidden_field_tag :timezone_name, timezone_from_cookie.name %>
<% end %>
There's no button and no user action at all. The auto_submit_form_with helper (Beat 3) attaches a controller that, on connect(), submits the form once with requestSubmit() and — on a successful turbo:submit-end — removes itself from the DOM (auto_submit_controller.js:4-22):
connect() {
this.element.addEventListener("turbo:submit-end", this.#handleSubmitEnd.bind(this), { once: true })
this.submit()
}
submit() {
this.#markAsBusy()
this.#disableSubmit()
this.element.requestSubmit()
}
#handleSubmitEnd(event) {
if (event.detail.success) {
this.element.remove()
} else {
this.#clearBusy()
this.#enableSubmit()
}
}
It's the whole Turbo form lifecycle as a single self-erasing object: connect → submit → on success, delete; on failure, re-enable and let the user retry. A form that does exactly one job and then disappears, with no controller waiting on it and no cleanup code — because its own turbo:submit-end is the signal to remove it. And because this one is a PUT — a mutating request — it does carry the form-level CSRF token Rails stamps automatically; the interesting Rails-side fact isn't the JavaScript, it's that this is still a real PUT /my/timezone with a real hidden_field_tag :timezone_name, read by an ordinary controller through ordinary params. Self-submitting didn't make it special. It's the same boring form, fired by connect() instead of a click.
The contrast: the naive auto-filter reads a value and hand-builds a request, re-inventing validation and CSRF and coupling the widget to the form in code. Fizzy declares the submission as form#submit in a data-action string the server renders, fires it with requestSubmit() so native validation and Turbo both stay in force, and — where there's no user at all — lets a form submit itself on connect() and delete itself on success. Every one of those is a real form submission Rails parses normally; nothing invented a transport.
Beat 3 — The decorator helper that glues it on, additively
There's one seam left. Beat 2's self-submitting form needs the auto-submit Stimulus controller attached. But the filter form already has a form controller on it, and the combobox list items already wire filter-settings and multi-selection-combobox. If attaching "auto-submit" meant overwriting the form's data-controller, you'd clobber the controllers already there. The widgets would fight over one attribute.
The naive fix is to remember, at every call site, to write out the full controller list by hand: data: { controller: "auto-submit form filter-settings" } — and the day you add a controller and forget one call site, a widget silently goes dead. The Rails-shaped fix is a decorator helper that merges its contribution onto whatever's already there, instead of replacing it (forms_helper.rb:2-11):
def auto_submit_form_with(**attributes, &)
data = attributes.delete(:data) || {}
data[:controller] = "auto-submit #{data[:controller]}".strip
if block_given?
form_with **attributes, data: data, &
else
form_with(**attributes, data: data) { }
end
end
This is a thin wrapper around form_with — it forwards every attribute and the block untouched. Its only job is one line: take whatever data[:controller] the caller passed and prepend "auto-submit " to it. Pass data: { controller: "form" } and you get "auto-submit form"; pass nothing and you get "auto-submit" (the .strip cleans the trailing space). The helper is additive — it composes its Stimulus controller alongside the caller's, so two controllers coexist on one form and neither has to know about the other.
The sibling helper in the same file shows the additive merge at full strength, composing both controllers and actions (forms_helper.rb:13-23):
def bridged_form_with(**attributes, &)
data = attributes.delete(:data) || {}
controllers = [ data[:controller], "bridge--form" ].compact.join(" ").strip
actions = [
data[:action],
"turbo:submit-start->bridge--form#submitStart",
"turbo:submit-end->bridge--form#submitEnd"
].compact.join(" ").strip
data[:controller] = controllers
data[:action] = actions
...
[ data[:action], "turbo:submit-start->bridge--form#submitStart", ... ].compact.join(" ") is the whole pattern in one line: take the caller's actions (which might be nil), drop the nils with compact, append this helper's own actions, and join into one space-separated string. Then data[:action] = actions writes the merged string back — the caller's wiring survives; the helper's wiring is added, not substituted. compact is what makes "the caller passed nothing" and "the caller passed three actions" the same code path — no if data[:action].present? branch, because compact absorbs the nil.
This is the same helpers-as-domain-vocabulary instinct that lives deeper in [[see F3: Views, Partials & Helpers]] — a helper that wraps form_with so a call site reads as intent (auto_submit_form_with) rather than mechanism (a data-controller string you maintain). Here it's the specific glue that lets the form-widget patterns of Beats 1 and 2 stack on one form without colliding. The widget author writes auto_submit_form_with; the controller-merge is the helper's problem, declared once.
The contrast: the naive call site hand-writes the full data-controller list every time and clobbers it the day two features want the same form. Fizzy's auto_submit_form_with (forms_helper.rb:2-11) prepends its controller additively and bridged_form_with (forms_helper.rb:13-23) merges both controllers and actions via compact.join, so independent Stimulus features compose onto one form_with without any of them knowing the others exist.
The whole journey, on one screen
Trace one filter action end to end. You open the "Assigned to…" combobox and check Alice, Bob, Carol. The list item's data-action runs its little pipeline — multi-selection-combobox#change clones the server-rendered <template> three times into <input type="hidden" name="assignee_ids[]"> fields, then form#submit calls requestSubmit(). Because it's requestSubmit(), native validation runs and Turbo intercepts; the form posts as an ordinary GET inside the cards_container frame. On the server, strong parameters permit(assignee_ids: []) read params[:assignee_ids] == ["4","9","12"] — no parser, no JSON.parse — and the board re-renders narrowed to those three. The combobox got fancy; the wire carried assignee_ids[]=4&assignee_ids[]=9&assignee_ids[]=12, which is to say, nothing Rails didn't already speak. And the auto-submit controller that made a button-less form go was glued on additively by auto_submit_form_with, coexisting with the form controller already there.
That's the aha, stated plainly: a rich form widget never needs a wire format of its own. Its entire job is to make the page contain ordinary form inputs — the right names, the right array-shape, and (on any mutating submit) the CSRF token the server already stamped — and then submit a real form. Every fancy thing happens in the browser; the moment data crosses the wire, it's the boring HTML form Rails has parsed since 2004. You built a multi-select pill, a self-correcting timezone form, and a self-erasing confirmation, and you never wrote a parser, a serializer, or a single params workaround.
Key Takeaways — Patterns to Steal
When a rich widget needs to report a selection, the reflex is to serialize a bespoke JSON shape and hand-parse it server-side with
JSON.parseandis_a?(Array)checks — which couples both sides to a protocol you maintain forever and steps outside Rails' form-level CSRF on any mutating submit. Don't invent a wire format. Have the widget materialize real<input type="hidden">elements with server-decided names, so a plainform_withreads them through ordinary strong parameters. Fizzy's combobox clones a server-rendered<template>(multi_selection_combobox_controller.js:120-127) holdinghidden_field_tag "assignee_ids[]"(filters/settings/_assignees.html.erb:14-16), sopermit(assignee_ids: [])(filter/params.rb:12) readsparams[:assignee_ids]as an array with zero glue.Author the field's name on the server, never in JavaScript. The instant the client builds the input, it owns the name and array-shape Rails cares about, and that knowledge drifts across two files. Render the blueprint as a
<template>in ERB so the name is written exactly once — by the same side that writes thepermitlist reading it. The single-select dropdown proves the symmetry: its template is a scalarhidden_field_tag :indexed_bywith no[](filters/settings/_indexed_by.html.erb:15-17), so the widget's single-vs-multi distinction is Rails' scalar-vs-array param distinction — one fact, not two kept in sync.To submit a form from code, reach for
requestSubmit(), neverform.submit(). The rawsubmit()skips HTML's native validation and bypasses Turbo entirely, dropping you into a full-page reload that throws away every Hotwire benefit;requestSubmit()fires validation and lets Turbo intercept, exactly as a real button click would. Fizzy's one-lineform#submitaction is justthis.element.requestSubmit()(form_controller.js:26-28).Don't hard-wire one widget to call another's method — that bakes a dependency into JavaScript that only a refactor can change. Declare the chain of intentions as data in the markup the server renders. Fizzy's list-item button carries
data-action="...multi-selection-combobox#change filter-settings#change form#submit"(filters/settings/_assignees.html.erb:30), a publisher/subscriber pipeline authored in ERB where no controller imports another. It's the same dispatched-event-bus instinct Campfire uses (presence_controller.js:21) — both products route intent as named events, but Fizzy lifts the wiring into the editabledata-actionattribute.A form that needs no submit button can fire itself and clean itself up. Attach a controller that submits on
connect()and removes the form on a successfulturbo:submit-end— the whole Turbo lifecycle as one self-erasing object, no waiting controller and no cleanup code. Fizzy'sauto_submit_controller.js:4-22does exactly this, and_time_zone.html.erb:2-4uses it to silentlyPUTa corrected timezone through an ordinaryhidden_field_tag :timezone_nameandparams— self-submitting didn't make the form special, it's still a boring Rails form fired byconnect()instead of a click.When two independent Stimulus features both want to ride one form, hand-writing the full
data-controllerlist at every call site clobbers whatever was already there the day you add the second feature. Write a decorator helper that merges its contribution additively ontoform_withinstead of replacing it. Fizzy'sauto_submit_form_withprepends its controller —"auto-submit #{data[:controller]}".strip(forms_helper.rb:2-11) — andbridged_form_withmerges both controllers and actions via[ data[:action], "..." ].compact.join(" ")(forms_helper.rb:13-23), wherecompactmakes "caller passed nothing" and "caller passed three" the same code path, so features compose on one form without knowing about each other.Speak Rails even where it never touches the wire. Fizzy's combobox builds its display label with
toSentence(labels, { two_words_connector: " or ", last_word_connector: ", or " })(multi_selection_combobox_controller.js:46-49), a hand-rolled mirror of Rails'Array#to_sentencedown to the option names (text_helpers.js:15-37). The instinct runs all the way down — port the helper you already know rather than invent a new one — so the widget never grows a vocabulary the rest of the app doesn't share.