Paying for Free Listeners

Manually wiring (and dutifully unwiring) event listeners in connect(), boilerplate that data-action descriptors replace entirely.
The account menu. Both versions behave identically.

The scenario

We need a simple account menu: a button that toggles a dropdown open and closed, and a dropdown that also closes when you click anywhere outside it or press Escape.

The burn

The instinct is that three inputs need three event listeners, so we wire them up ourselves. In connect() we bind each handler, add a listener on the button, on document, and on the element. In disconnect() we remove all three again.

Every line of that is correct. removeEventListener silently does nothing unless it receives the exact same function that was added, so we have to create the bound copies once and store them, and that's what the three bind lines are for. Get that subtlety slightly wrong, say by passing a fresh .bind(this) to removeEventListener, and the listener leaks quietly on every page visit.

The problem is not that the code is wrong. It's that we're paying for something the framework already gives away. This shape shows up surprisingly often, and especially in code written by unguided coding agents.

The fix

Stimulus actions declare listeners in the HTML, and Stimulus adds and removes them for us as the controller connects and disconnects.

The button gets click->toggle#toggle. The outside click becomes click@window->toggle#hide, a global listener declared with the @window suffix, with a contains() guard in the handler so clicks inside the menu don't close it. Escape becomes keydown.esc->toggle#dismiss on the component element, using a key filter.

The controller shrinks to three short methods and no lifecycle callbacks at all. Hotwire is trying to help you write less JavaScript, and that usually means writing a little more HTML.

Why it matters

Actions are not just fewer lines than addEventListener. Because they are declared on elements, they are scoped: the Escape action fires only while focus is inside the component, so independent widgets don't all close on one keypress and a menu inside a dialog doesn't steal the dialog's Escape. Manual listeners on document have no idea where they live.

The boilerplate also costs you at every edit. Add an input and you add a bound handler, a listener, and a removal, and you re-read the lifecycle to make sure the three still match. With actions, adding a behavior is adding an attribute.

Sometimes the refactor is no controller at all. A native <dialog> closes on Escape and manages focus by itself, and popover="auto" gives full light dismiss with nested popovers closing innermost-first. Check what the platform does before writing a toggle controller.

Read more

The code

Pitfall · app/controllers/menus_controller.rb
pitfall
refactored
app/controllers/menus_controller.rb
1
2
3
4
class MenusController < ApplicationController
  def show
  end
end