Categories
Code

Building beautiful, fast apps with Rails and StimulusJS

Initforthe, the software company I run, specialises in building Ruby on Rails apps. We build systems for lots of different clients and they come in all shapes and sizes. But one thing is common across them all: none of them are SPAs (Single Page Applications).

For me, SPAs try too hard, and ultimately fail. They have to re-invent how browsers work and that’s a feat in itself. And then you have to ensure you’ve got business logic validation happening on the server where the data still has to live, and on the client too. Further, the learning curve is enormous, and the benefit to most businesses is next to zero.

Recently, the guys at Basecamp launched Hey.com, a new email service built entirely in Rails, StimulusJS, Turbolinks and server-rendered HTML. Not a single SPA-style bit of code in sight. And that’s how we build software too.

That’s not to say it’s been without its challenges. We shifted from jQuery to StimulusJS and vanilla Javascript when it was released, and since then we’ve been simplifying our code.

In this post (beware, it’s going to get long), I’m going to show you some of the tools we use (and have open sourced) to keep development time down and our code clean.

So let’s begin…

Remote requests rendering HTML

We built stimulus-rails-ujs a while back and we use it everywhere. In forms, we apply it to render form validation errors:

def create
  @post = Post.new resource_params
  if @post.save
    redirect_to posts_path, notice: 'Post saved'
  else
    render partial: 'form', status: :unprocessable_entity
  end
end
<%= form_with model: @post, html: { data: { controller: 'rails-ujs', action: 'ajax:error->rails-ujs#error' } } do |f| %>
  <%= render 'errors', f: f %>
  <div>
    <%= f.label :title %>
    <%= f.text_field :title %>
  </div>
<% end %>

It can also be used to add content to or replace content on the page. Let’s say that instead of redirecting to posts_path we want to render the Post instead:

def create
  @post = Post.new resource_params
  if @post.save
    render @post
  else
    render partial: 'form', status: :unprocessable_entity
  end
end
<div id="posts"></div>
<%= render 'form' %>
<%= form_with model: @post, html: { data: { controller: 'rails-ujs', action: 'ajax:error->rails-ujs#error ajax:success->rails-ujs#success', placement: 'append', response_target: '#posts' } } do |f| %>
  <%= render 'errors', f: f %>
  <div>
    <%= f.label :title %>
    <%= f.text_field :title %>
  </div>
<% end %>

Adding modals

I’m going to leave the CSS aspect of this out and assume you’re either using a prefab toolkit like TailwindCSS or you’re rolling your own.

Let’s say you want to present a form in a modal. In an SPA, you’d have to have all that HTML client-side, but with our tools, we don’t need to do any of that.

First, let’s add an alias for the text/html MIME Type. This will allow us to do things like new_post_path(format: :modal). We’ll also add a modal layout:

Mime::Type.register_alias 'text/html', :modal
<div class="modal">
  <%= yield %>
</div>

So we can use our rails-ujs controller on a link or button to append the response to the body. So now you’ve got your modal showing up. You can use the same controller within it for the form again, and even render the same partial as you have above. NB: you’ll need to rename it to _form.erb if you want to use the same partial for multiple layout formats, or you can have multiple files if you want them to look slightly different.

Make it pretty and fade in

So until now, you’ve got a fairly basic modal structure. You can fetch it, stick it in the DOM and it’ll render. But let’s say you want to make modals fade in from hidden.

We’ll bring in two more stimulus controllers here: stimulus-reveal and stimulus-existence.

The idea behind stimulus-existence is to let you do stuff when an element is added to the DOM, and remove it when it’s no longer needed. We use this a lot for modal dialog windows. Once you’ve closed the modal, you don’t need it any more, and if it needs to be rendered, the server will send it back over the wire. So remove it and it won’t cause any issues with element IDs and so on.

Separately, stimulus-reveal allows you to hide/show elements on any event you can pass in to a Stimulus action. It can be used for modals, custom selects, dropdowns, navigation menus (we’ve used it for the menu on initforthe.com and for the dropdowns on service pages. You’re not limited to hiding and showing one element, so you can toggle multiple elements within the controller namespace at once.

As a bonus, you can add transitions to the toggle (you can also add click away actions and keypress actions too, but that’s all in the documentation), and so that’s what we’ll do here to our modal layout, so all modals fade in nicely (I’m using TailwindCSS classes, but you can use your own too):

<div data-controller="reveal existence"
     data-action="reveal:hidden->existence#remove existence:added->reveal#show">
  <div hidden data-reveal data-transition
       data-transition-enter="transition transform ease-out duration-500"
       data-transition-enter-start="translate-x-full"
       data-transition-enter-end="translate-x-0"
       data-transition-leave="transition transform ease-out duration-300"
       data-transition-leave-start="translate-x-0"
       data-transition-leave-end="translate-x-full">
    <button data-action="click->reveal#hide">Close me!</button>
    <%= yield %>
  </div>
</div>

The existence controller dispatches an event when it’s connected to the element, which allows us to listen to it and begin the reveal. When you click the Close me! button, the reveal controller completes its hide, and dispatches an event which we listen for in order to remove the element from the DOM.

Clearly, none of the above code examples will actually look pretty, but that’s only because I’ve left out the styles to make them look nice.

What you should be able to see though is how you can leverage server-side rendering along with some StimulusJS sprinkles that you can use all over the place, both separately and together, to cover 95% of your use cases (at least that’s been my experience). It’s saved us a good 30% of the time we need to develop any given feature.

None of the stuff we’ve done above requires Rails at all. You could use these with any server-rendering technique as long as you’re sending fully formed HTML over the wire. But if you haven’t used Ruby on Rails or seen it in action, you’re missing out.