Pitfalls & Burns

Stimulus

Tightly Coupled Controllers

One controller built for a single feature, with autosave, character counting, and autosizing all tangled together, versus small controllers composed in the markup.

app/views/profiles/edit.html.erb
pitfall
shared
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<div class="mx-auto max-w-2xl p-8">
  <h1 class="text-2xl font-bold">Edit profile</h1>

  <%= form_with model: @profile, url: profile_path(@profile), data: {
        controller: "autosave",
        autosave_saved_class: "text-green-600!",
        autosave_failed_class: "text-red-600!",
        action: "turbo:submit-start->autosave#saving turbo:submit-end->autosave#saved"
      } do |form| %>
    <div class="mt-6"
         data-controller="character-count"
         data-character-count-limit-value="<%= Profile::BIO_LIMIT %>"
         data-character-count-exceeded-class="text-red-600!">
      <%= form.label :bio, class: "block font-medium" %>
      <%= form.text_area :bio, rows: 3,
            class: "mt-1 w-full resize-none rounded border-2 border-gray-300 bg-white p-2 inset-shadow-sm",
            data: {
              controller: "autosize",
              character_count_target: "input",
              action: "input->autosave#save input->autosize#resize input->character-count#render"
            } %>
      <div class="flex justify-between text-sm">
        <span data-character-count-target="counter" class="text-gray-500"></span>
        <span data-autosave-target="status" class="text-gray-500"></span>
      </div>
    </div>
  <% end %>
</div>
The profile bio editor. Both versions behave identically.

The scenario

We're adding a bio field to a user profile page. The bio has a 280-character limit, and the spec asks for three things: show a live character count, grow the textarea to fit what's typed, and autosave the form as the user types, with a small "Saving…" / "Saved" status.

The burn

The natural first move is one Stimulus controller that does all of it, named after the feature it serves. In profile_bio_controller.js, a single update() action on the textarea updates the counter, resizes the input, and schedules a save, and two more actions report save progress.

There's nothing sloppy here. It uses targets and actions, and the methods are short and tidy. The problem is that it's built for exactly one place. The limit of 280 is hard-coded in JavaScript, the colours are hard-coded class names, and the three behaviors can only be had together.

Sooner or later another textarea needs to autosize without a character count, or another form needs to autosave without having a textarea at all. With everything coupled to the profile bio, the choices are copy-and-trim or a growing pile of options on a controller that was never meant to be general.

The fix

Stimulus works best when each controller implements one general behavior, and the markup composes them for a specific use. So profile_bio_controller.js splits into three:

  • autosave_controller.js goes on the form. It debounces a requestSubmit() and shows the save status.
  • autosize_controller.js goes on the textarea and does nothing but resize it.
  • character_count_controller.js goes on a div wrapping the textarea and its counter.

In edit.html.erb, one input event now fans out to three actions: input->autosave#save input->autosize#resize input->character-count#render. The limit comes from the server as a value, Profile::BIO_LIMIT, the same constant the model validates against, and the colours come in as Stimulus CSS classes. profiles_controller.rb and profile.rb don't change at all.

Why it matters

The two versions behave identically; the difference is purely structural. But structure is what decides the cost of the next feature. Each small controller can now be dropped onto any element that needs it, and none of them knows about profiles.

Tightly coupled doesn't mean messy. It means built like an appliance for one countertop. A useful test when naming a controller: if the name describes a page or a model rather than a behavior, it's probably doing too much.

Composition also moves change into the view. Now that save feedback is its own concern, swapping the status text for a toast is a view-level change, not surgery on a controller that does everything.

Read More