The Path to Rails 8.1: From Vue and jQuery to Stimulus
"How do you delete that much code and lose nothing?" That's the question we left you with at the end of our last post. The code was a 350-line Vue component that powered our pre-flight DNS resolution pop-up. The answer was to move it to the server. It's now a Turbo Frame and a Stimulus controller.
That pop-up wasn't the only one. When we started our move to the Rails 8.1 defaults, the DNSimple app had about 50 Vue components and a lot of jQuery. Today it has none of either.
This is the second post in our series. In the first, we replaced rails-ujs with Turbo Drive and found side effects that our old JavaScript had been hiding for years. This post is about the JavaScript itself, and where it went.
Our business logic used to live in two places: Ruby on the server and JavaScript in the browser. Now it lives in one place, in one programming language. The JavaScript that's left uses one library, Stimulus. When something breaks, there's one place to look. When we add a feature, there's one pattern to follow.
But before we show you how we moved everything to the server, it's worth explaining why we couldn't keep what we had.
Why Vue had to go
Vue did its job for years. But importmaps can't compile a Vue single-file component, so Vue needed its own build step. That build step stood between us and a Rails app with no JavaScript bundler.
jQuery broke in a different way. Most of our jQuery was page scripts that attached to js-* selectors on DOMContentLoaded. Turbo Drive doesn't fire that event on each visit, so those scripts stopped working as soon as a page moved to Turbo.
How we decided how things were migrated to our new stack
We asked the same three questions about every component:
- Can a native HTML element do this, with no JavaScript?
- If not, can the server render the HTML, instead of the browser fetching JSON and building the HTML itself?
- If neither, what is the smallest piece of JavaScript that can own what is left?
If you start at question three, you get a one-for-one port from Vue to Stimulus. Most of what our Vue components did wasn't browser logic at all. They took data the server already had and turned it into HTML in the browser.
So the answer wasn't always Stimulus. The native <dialog> element replaced our Vue modal. Some site widgets became small Web Components. Some components became a Turbo Frame with no JavaScript at all.
Why Stimulus
When we still needed JavaScript, we used Stimulus. A Stimulus controller connects when its element enters the page and disconnects when the element leaves. Turbo swaps the <body> on each visit, so we need that lifecycle. Without it, each component has to find and set up its elements on every visit, and remove any listeners it added to the document.
The DNS resolution check
Some actions in the app only work when your domain's DNS points at DNSimple, such as adding records, setting up email forwarding, or ordering a certificate. Before you do one of these, the app checks your domain. Is the registration expired? Is the domain delegated to another provider? Is the zone inactive? If something is wrong, a dialog tells you what it is and how to fix it.
Before: a Vue component
The check was a 350-line Vue component with 17 props and six pieces of state. It fetched the results as JSON and built the HTML for five failure states in the browser. Every page that used it also ran a domain query, a permission check, and a price lookup on every render, only to fill in those props.
When the check passed, the component clicked a hidden link to continue. The link's target URL and HTTP method came in as props:
this.ajax(this.dnsResolutionCheckDomainUrl)
.then((response) => {
const checks = response.getAt('response', []);
const isPass = checks.length === 0;
if (isPass)
this.clicker(this.$refs.iUnderstand);
else {
this.checks = checks;
this.isLoading = false;
}
})
After: a Turbo Frame and a Stimulus controller
Now the server renders every state. The endpoint returns HTML for a Turbo Frame instead of JSON. On a pass, the frame gets an empty marker. On a failure, it gets the full message for that check, with the correct permissions, prices, and links already in it.
The Stimulus controller has one value: the URL of the check. It only does what a frame can't do by itself:
- It intercepts the click on the control that the check guards.
- It opens the dialog and loads the frame.
- It replays the original click when you continue.
- It sets a cookie when you ask us to stop checking for 24 hours.
The old component copied the target URL and method into props. The new controller keeps a reference to the link or button you clicked, and clicks it again:
intercept (event) {
const action = event.target.closest('a, button, [role="button"]') || event.currentTarget;
if (action === this.bypassedAction) {
this.bypassedAction = null;
return;
}
event.preventDefault();
event.stopPropagation();
this.pendingAction = action;
this._open();
this._load();
}
continue (event) {
event?.preventDefault();
this._close();
if (!this.pendingAction) return;
const action = this.pendingAction;
this.pendingAction = null;
this.bypassedAction = action;
action.click();
}
The bypass flag stops the controller from catching its own replayed click. When you click Refresh, the controller calls frame.reload(), and Turbo cancels any request still in progress. In Vue, we needed an isLoading guard for that.
The line count
The Vue version had about 430 lines, with its child component, partial, and helper. The new version had about 400: a 192-line controller and two partials.
The server now decides what the check shows, including permissions and prices. Pages no longer run three extra queries to render the check.
Next in the series
Removing Vue and jQuery didn't just delete code. It moved our business logic back to one place, in one language, where anyone working in the app, human or agent, can find it.
If you're planning the same move, our advice is to ask three questions: can a native HTML element do this, if not, can the server render the HTML, and if neither is true, what's the smallest piece of JavaScript that can own what's left? Don't measure success by lines deleted. Measure it by how little the browser has to know.
Next up is CSS. We retired Bootstrap and Tachyons for Tailwind, one subapp at a time, and used ViewComponents to keep the markup consistent. Our old alert helper had 106 call sites. Today they all use one ViewComponent. How do you restyle an entire app one piece at a time? We'll show you in the next post.
Working on a similar migration and have questions? Get in touch. We're happy to share what we learned.
Dallas Read
Dream. Risk. Win. Repeat.
We think domain management should be easy.
That's why we continue building DNSimple.