Skip to content
← All posts
5 min read Dawid Skłodowski

Migrating jQuery to Stimulus, page by page

Most established Rails apps carry years of jQuery, and a big-bang rewrite is a trap. A deep-dive on migrating jQuery to Stimulus incrementally — the coexistence strategy, the patterns that translate, and how to do it without stopping feature work.

Most Rails apps with any history carry a sediment of jQuery — years of $(document).ready, event handlers bound by selector, DOM manipulation scattered across files. It works, but it is hard to maintain, fragile under Turbolinks/Turbo, and increasingly out of step with a Hotwire-based app. The goal is to migrate it to Stimulus, and the critical decision is how: a big-bang rewrite is a classic trap — it stops feature work, risks regressions everywhere at once, and rarely finishes. The right approach is incremental, page by page. Here is how to do that.

Why incremental, not big-bang

The temptation with legacy code is to set aside a few weeks and “just rewrite the JavaScript.” It almost never works: the jQuery touches more than you think, regressions appear in places nobody remembered, and feature work stalls while it drags on. Incremental migration — converting one behaviour at a time while the rest keeps working — is the strangler pattern applied to front-end JS, the same instinct as the Mongo-to-Postgres migration. jQuery and Stimulus coexist happily during the transition (they are just JavaScript on the same page), so you migrate at a sustainable pace, shipping features the whole time, and the app is always working. No freeze, no big risky cutover.

The coexistence setup

The first step is making the two live side by side cleanly. You add Stimulus (now standard with Hotwire) alongside the existing jQuery, and from then on the rule is simple: all new JavaScript is Stimulus, and you convert existing jQuery opportunistically. That rule alone stops the problem growing — the jQuery is now a finite, shrinking pile rather than something you keep adding to.

A practical note on Turbo: if you are also moving to Turbo/Turbolinks, the old jQuery’s $(document).ready problem (handlers firing once and dying on navigation) becomes acute — which is extra motivation, because Stimulus controllers don’t have that problem (they attach to markup whenever it appears). Often the jQuery causing Turbo headaches is exactly the jQuery to migrate first.

The translation patterns

Most jQuery-to-Stimulus conversion follows a handful of mechanical patterns. Once you internalise them, the work is steady rather than puzzling:

  • $(document).on("click", ".foo", …) → a Stimulus action. Event delegation by selector becomes a declarative data-action in the markup pointing at a controller method:

    <!-- before: handler bound somewhere in a .js file by selector -->
    <!-- after: -->
    <button data-controller="toggle" data-action="toggle#flip">…</button>
  • $(".something") DOM queries → targets. Instead of querying the DOM by selector, name the element as a target and use this.somethingTarget — no fragile selectors, scoped to the controller.

  • State stored in classes / data attributes via jQuery → Stimulus classes and values. Where jQuery toggled classes or read data- attributes ad hoc, use the controller’s classes and values APIs so configuration lives in the markup, typed.

  • $(document).ready(initWidget) → the controller’s connect(). Initialisation that ran on ready moves into a controller, which runs connect() whenever its element appears — and correctly re-runs after Turbo navigation, fixing the bug at the same time.

The conversion is largely finding the jQuery for one behaviour, identifying its element, and re-expressing it as a small controller with actions and targets. It is repetitive but straightforward, which is exactly what makes it safe to do incrementally.

Do it page by page (or behaviour by behaviour)

The practical unit of migration is a page or a self-contained behaviour. Pick one — say, the modal on the product page, or all the toggles in the settings screen — convert just that to Stimulus, test it, ship it, and move on. Several advantages fall out:

  • Each migration is small and shippable. You convert one widget, verify it, and deploy — low risk, no giant PR, no long-lived branch.
  • You build a reusable controller library as you go. The first time you convert a dropdown, you write a generic dropdown controller; the next dropdown reuses it. The migration produces the small library of generic controllers from the “Stimulus that scales” post, so later conversions get faster.
  • Feature work never stops. Because you migrate opportunistically — often converting the jQuery on a page you’re already touching for a feature — the migration rides along with normal work rather than competing with it.

A useful tactic: migrate the jQuery on a page when you have to touch that page anyway for a feature or bug. The code is already in your head, the testing is already happening, and the migration is nearly free. Over time, the highest-traffic and most-changed pages convert naturally, and the remaining jQuery is on stable pages nobody touches — which is fine to leave until last (or forever).

The pitfalls

A few things to watch:

  • Global jQuery plugins and shared state. Some legacy jQuery relies on plugins or global state that several pages share; untangling those needs more care than a self-contained widget. Map the dependencies before converting.
  • Don’t convert dead code. Some jQuery handles features no longer used. Before migrating a behaviour, check it is still needed — deleting beats converting.
  • Keep controllers small as you convert. The temptation is to dump a whole page’s jQuery into one big controller. Resist it (per the Stimulus-that-scales discipline) — convert into several small, single-purpose controllers, or you have just moved the mess.
  • Test the converted behaviour. A migration is a great moment to add the system/feature test the jQuery never had, so the conversion is verified and stays working.

Verdict

Migrating jQuery to Stimulus is a real and worthwhile cleanup, but the how decides whether it succeeds: a big-bang rewrite is a trap, while an incremental, page-by-page migration — jQuery and Stimulus coexisting, all new JS as Stimulus, existing jQuery converted opportunistically — makes it safe, shippable, and compatible with ongoing feature work. The translation is mechanical (delegated handlers → actions, DOM queries → targets, ready → connect), the unit is a page or a behaviour, and the migration conveniently builds your reusable controller library as it goes. Convert the jQuery on pages you’re touching anyway, keep the resulting controllers small, delete dead code rather than port it, and the legacy pile shrinks steadily without a freeze. Years of jQuery become a tidy set of Stimulus controllers — not in one heroic rewrite, but one page at a time.