DEV Community 👩‍💻👨‍💻

rhymes for The DEV Team

Posted on

How to combine Rails's Ajax support and Stimulus

In this post I'm going to explain how we are exploring adding snappier SPA-like interactivity to the Admin and what we've learned so far. I am using the word "exploring" because this is ongoing work, that is not visible yet in Forems, so it many or may not reflect the final version but I think there are useful lessons to be learned nonetheless.

I'm going to assume you're familiar with Ruby on Rails, Stimulus and the concept of componentization.

What we wanted to accomplish

Let's start with a video demo:

The goal here is to give the user a perception of interactivity, and we want to do that without unleashing a full client side single page application. Forem's Admin interface is mostly server rendered and we wanted to explore a path to progressively enhance the experience, stopping short of a rewrite.

What's the status of the current Admin?

Currently the Admin, on the backend, is a custom made collection of Rails controllers, for all intents and purposes a part of the Forem core app. It's not an external web app, it's also not generated by a third party gem. We think the Forem Creator (and their merry band of collaborators) experience is paramount and it has evolved from DEV's needs to now the larger Forem ecosystem's.

Being a custom app, grown over the years, it's admittedly a mix of technologies that we're trying to streamline, hence the need for some good old exploratory software development. On the frontend it currently uses: jQuery, Bootstrap, vanilla JS, Stimulus, Preact, a few web components and our custom Crayons design language.

Why did we explore an alternative?

The end goal is to reduce that to Crayons, Stimulus and use Preact or Web Components when absolutely needed, to foster a nimbler architecture, with reuse between the "frontoffice" website and the admin, where possible.

After discussing this with the team I set out to investigate the following assumption (not a direct quote): "We want users actions to be interactive, minimizing page reloads, and because of that we're going to send blocks of server rendered HTML to them by injecting the markup into the page"..

If this sounds like a barebones version of notable frameworks like Elixir's Phoenix LiveView, Rails's StimulusReflex or Hotwire Turbo, PHP's LiveWire, Django's Reactor... well, you're right! (Bonus: my colleague @jgaskins built a LiveView clone for Crystal)

You can sense a pattern in these frameworks, and the demand they fulfill.

In our case, though, we used none of them. I wanted to explore how far we could go without adding a whole framework and by using the tools we had a bit more in depth. This to lessen the cognitive load on anyone that's going to further this exploration or adopt this pattern for the Admin as a whole.

Aside from the obvious "why should I need a framework to send basic HTML to the client", we have plenty of frameworks and libraries on the client side already and frameworks usually take quite a while to be learned. Also, we're a small team.

So this is how I implented it:

  • Rails and HTML on the server side with a bit of JSON when needed. I cheated a bit the constraints I set for myself using GitHub's ViewComponent but you can achieve similar results using builtin Rails partials and this post isn't going into depth about ViewComponent.

  • Rails's UJS (Unobtrusive JavaScript) and Stimulus on the client side. UJS is a library builtin inside Rails that powers JavaScript interactions on the DOM via Rails special helpers, like link_to or button_to.

How does it all fit together?

Let's start from the goal again: a user clicks on a link, the client side sends a request to the server, some action is performed, some HTML is sent back, this HTML is injected in the page.

This is what happens when the user clicks on one of the gray boxes for example:

Clicking on "Emails", hits the EmailsController which renders the EmailsComponent (which, again, could just be a partial), the resulting HTML is sent to Stimulus which calls a JavaScript function injecting the HTML, thus finalizing the switch of the section.

Let's look at the code, one step at a time:

Initiating the contact between client and server

This is how the gray box titled "Emails" is defined in Rails:

<%= link_to admin_user_tools_emails_path(@user), remote: true,
                                                 data: { action: "ajax:success->user#replacePartial" },
                                                 class: "crayons-card box js-action" do %>
  <h4 class="crayons-subtitle-3 mb-4">Emails</h4>

  <span class="color-base-70">
    <%= pluralize(@emails.total, "past email") %>
    <% if @emails.verified %> - Verified<% end -%>
  </span>
<% end %>
Enter fullscreen mode Exit fullscreen mode

and this is an example of the resulting HTML:

<a
  class="crayons-card box js-action"
  href="/admin/users/13/tools/emails"
  data-remote="true"
  data-action="ajax:success->user#replacePartial"
>
  <h4 class="crayons-subtitle-3 mb-4">Emails</h4>

  <span class="color-base-70"> 7 past emails </span>
</a>
Enter fullscreen mode Exit fullscreen mode

There's a bit going on in such a small snippet of code, let's unpack:

  • href="/admin/users/13/tools/emails" identifies this as a regular HTML link, if I were to visit it with my browser I would get the same response JavaScript is going to be sent when the user activates the click.

  • data-remote="true" (the result of remote: true in Ruby) is how Rails determines if the link should be handled by Ajax or not. Rails calles these remote elements, they can be links, forms or buttons.

  • data-action="ajax:success->user#replacePartial" is how we connect Rails UJS
    and Stimulus together. data-action is a Stimulus action (the description of how to handle an event), ajax:success is a custom event triggered by Rails UJS.

This is what it all translates to: on initiating the click on link, let Rails UJS fetch the response via Ajax and, upon a successful response, handle the ajax:success event via the method replacePartial in the Stimulus UserController class.

This is a lot of behavior in a few lines. It reads like declarative programming with a good abstraction, working well if one wants to minimize the amount of custom JavaScript to write and thus needs to describe behavior directly in the templates :-)

The resource the link points to is a regular HTML snippet, this is what one sees if visited manually:

The great thing (in my opinion), is that the whole behavior in question still works in isolation: it's server side rendered, it redirects upon submission as it should by default, it is essentially a regular HTML form.

Being able to play with these components in isolation definitely speeds up development.

The whole section (which I called ToolsComponent on the server) works
in isolation:

What happens on the server when this request is sent?

Once again, let's start from the code:

module Admin
  module Users
    module Tools
      class EmailsController < Admin::ApplicationController
        layout false

        def show
          user = ::User.find(params[:user_id])

          render EmailsComponent.new(user: user), content_type: "text/html"
        end
      end
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

That's it. We tell Rails not to embed the component (or partial) in a layout, we load the user object and we tell the framework to render the HTML sending it back to the client as HTML (this last tiny detail is important, as Rails's "remote mode" defaults to text/javascript for the response, which is not very helpful to us in this case...).

What does the frontend do when it receives the HTML?

Let's look at the code once again:

<a
  class="crayons-card box js-action"
  href="/admin/users/13/tools/emails"
  data-remote="true"
  data-action="ajax:success->user#replacePartial"
>
  <h4 class="crayons-subtitle-3 mb-4">Emails</h4>

  <span class="color-base-70"> 7 past emails </span>
</a>
Enter fullscreen mode Exit fullscreen mode

We've instructed the app to trigger replacePartial inside the Stimulus
UserController, this is what it does:

replacePartial(event) {
  event.preventDefault();
  event.stopPropagation();

  const [, , xhr] = event.detail;

  if (event.target.classList.contains('js-action')) {
    this.toolsComponentTarget.classList.add('hidden');
    this.replaceTarget.innerHTML = xhr.responseText;
    this.announceChangedSectionToScreenReader();
  }
}
Enter fullscreen mode Exit fullscreen mode

This method:

  1. prevents the default behavior and stops propagation
  2. extracts the XMLHttpRequest injected by Rails
  3. hides the section we're looking at and shows the new one
  4. announces the change to the screen reader, as we are neither changing the URL nor doing a full page reload.

How did we make this accessible?

After discussing it with our resident accessibility guru, @suzanne, she suggested we use a "screen reader only" aria-live element:

<div
  class="screen-reader-only"
  data-user-target="activeSection"
  aria-live="polite"
></div>
Enter fullscreen mode Exit fullscreen mode

This is managed by Stimulus, which at the end of the action, fetches the title of the new section, announces it to the screen reader and changes the focus so the section is ready to be used.

Recap so far

So far we've seen quite a few things:

  • using Rails builtin capabilities to connect client side code and the server side via Ajax but using server side HTML
  • using Stimulus to listen in on the action and augment behavior as we see fit, keeping the code organized
  • replacing a section of HTML with another, that's self contained in a component that can be at least functional without JavaScript as well

How to send an email with Rails and Stimulus

Here we're going to show how this "connection" works, using sending an email as an example.

Let's start from the perspective of the user:

What does the email form do?

Given we're in the domain of UJS and Stimulus combined, we have to look at how they are connected:

<section
  data-controller="users--tools--ajax"
  data-action="ajax:success@document->users--tools--ajax#success ajax:error@document->users--tools--ajax#error">

  <!-- ... -->

    <%= form_with url: send_email_admin_user_path(@user) do |f| %>
      <!-- ... -->
    <% end -%>
</section>
Enter fullscreen mode Exit fullscreen mode

Our "Emails" section declares it needs a Stimulus controller named AjaxController and that it's going to dispatch to it the Rails UJS events ajax:success and ajax:error.

When the "Send Email" submit button is activated, Rails will send the form via Ajax to the server, which upon successful submission, will answer with data, in this case JSON.

What happens on the server?

Once again, code first:

if # email sent
  respond_to do |format|
    message = "Email sent!"

    format.html do
      flash[:success] = message
      redirect_back(fallback_location: admin_users_path)
    end

    format.js { render json: { result: message }, content_type: "application/json" }
  end
end
Enter fullscreen mode Exit fullscreen mode

If the email is sent the server figures out if it was a regular form submission and thus invokes a redirect or if it was a submission via Ajax (our case), it sends back a feedback message in JSON.

I am using JSON here because it fits well with the snackbar notifications, but we could send well styled HTML to inject for a richer interaction, same we did in the first part.

Specifying the content type is important, because Rails defaults to text/javascript for Ajax interactions.

What does the client do once it receives a successful response?

export default class AjaxController extends Controller {
  success(event) {
    const [data, ,] = event.detail;
    const message = data.result;

    // close the panel and go back to the home view
    document.dispatchEvent(new CustomEvent('user:tools'));

    if (message) {
      // display success info message
      document.dispatchEvent(
        new CustomEvent('snackbar:add', { detail: { message } }),
      );
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The "success" event handler extracts the feedback message sent by the server and then dispatches two custom events that asynchronously communicate with two different areas of the page:

  1. user:tools communicates with the Stimulus UsersController telling it to initiate a navigation back to the initial screen, the "Tools" section. How? Via this line in the HTML of the container page:

    data-action="user:tools@document->user#fetchAndOpenTools"
    
  2. snackbar:add communicates with the Stimulus SnackbarController telling it to add a new message to the stack of messages to show the user. I wrote a post if you're interested in how this part works.

Once the first event is received, the following function is invoked, which triggers an Ajax call, fetching the server side ToolsComponent's HTML and displaying it in the UI:

fetchAndOpenTools(event) {
  event.preventDefault();
  event.stopPropagation();

  Rails.ajax({
    url: this.toolsComponentPathValue,
    type: 'get',
    success: (partial) => {
      this.replaceTarget.innerHTML =
        partial.documentElement.getElementsByClassName(
          'js-component',
        )[0].outerHTML;
      this.announceChangedSectionToScreenReader();
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

Rails.ajax is builtin in Rails UJS, not very different from using window.fetch.

Conclusions

There's quite a bit going on here, depending on your level of familiarity with the major parts: Rails and Stimulus.

In my opinion Stimulus is really good to keep vanilla JS organized and to attach behavior to server side rendered HTML markup, in a declarative way.

By leveraging Rails builtin Ajax support and thin layer you can add interactivity without having to rely on larger frameworks or having to switch to client side rendering.

If this is something that fits your use case, only you know, but I hope this post showed you how to combine two frameworks to improve the user experience without a steep learning curve and thus increasing the level of developer productivity.

Resources

Aside from countless DuckDuckGo searches (there's little documentation on how to fit all the pieces together) and source code reading, I mainly spent time here:

Top comments (0)

🌚 Friends don't let friends browse without dark mode.

Sorry, it's true.