Pitfalls & Burns

Stimulus

So Much for Server-Side

A parse-and-validate engine the server must own anyway, rebuilt in JavaScript to power a preview. Two implementations, guaranteed to disagree.

app/models/subscriber.rb
pitfall
shared
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Subscriber < ApplicationRecord
  SAMPLE_EMAILS = <<~EMAILS
    Jane Doe <jane@example.com>
    mike@example.com, lisa.park@example.org
    tom.b@example.net; MIKE@example.com
    Maria Diaz <maria+news@example.com>
    maria+news@example.com
    <hank@example.co.uk>
    jane.example.com
    robert@example.com.
  EMAILS

  validates :email, presence: true, uniqueness: true,
    format: { with: URI::MailTo::EMAIL_REGEXP }
end
Checking and importing a pasted list of subscribers.

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