The scenario
We're letting users import subscribers to a mailing list by pasting addresses into a textarea: separated by commas, semicolons, or new lines, with or without a name, as in Jane Doe <jane@example.com>. Before adding anyone, they should be able to check the list and see which addresses are valid, which aren't, and how many duplicates were dropped. So the form gets two buttons: one to check, one to add.
The burn
Checking doesn't save anything, so it feels like a client-side job. We write email_list_controller.js to split the text, pull names out of angle brackets, trim, downcase, de-duplicate, validate against a regex, count, pluralize, build the HTML, and escape it by hand, because template literals don't escape anything.
But we can't trust the client, so the server was always going to do all of that too when the list is submitted. subscriber_imports_controller.rb parses the same text with Subscriber::Email.parse_list, and the model validates with URI::MailTo::EMAIL_REGEXP. Now there are two implementations of the same logic, and the JavaScript one exists only to agree with the Ruby one, forever.
They already don't. The sample list includes robert@example.com. with a sentence-ending period. The JavaScript regex calls it valid; Ruby doesn't. The preview promises seven subscribers and the import adds six. Other inputs disagree too, and in both directions: mailto:jane@example.com passes in JavaScript only, while user@localhost passes in Ruby only.
The fix
Let the server do the preview. The "Check list" button in new.html.erb stays in the same form but gets a formaction, so it posts the same textarea to a separate endpoint. subscriber_import_previews_controller.rb calls Subscriber::Email.parse_list and nothing else, and create.turbo_stream.erb renders _preview.html.erb into the page.
One form, two buttons, two endpoints, and one parser. The preview and the import run the same Ruby against the same regex the model validates with, so drift isn't unlikely, it's impossible. The Stimulus controller is gone entirely, and ERB escapes output for free.
Why it matters
A client-side copy of server logic isn't an optimization; it's a second implementation with its own bugs. The moment parsing produces structured results rather than plain strings, keeping two engines in step costs double.
It doesn't have to be a whole feature to count. Building a URL in JavaScript when the server could pass it in as a Stimulus value is the same instinct at a smaller size.
This one is hard to spot, and sometimes it's a genuine tradeoff rather than a flaw. But it's the question to keep asking with Hotwire: can, and should, the server be doing this work?
Read More
- Turbo Handbook: Streams, including answering a form submission with a Turbo Stream.
- MDN: the button element, for
formaction. - Stimulus Handbook: Values, for handing server-computed data like URLs to a controller.