← Blog

5 months ago

Reactive Rails: StimulusReflex

By Ben Vandgrift and Jonathan Greenberg

A Brief Study of StimulusReflex

This post continues our exploration of Reactive Rails tools with StimulusReflex(SR) and its underlying libraries CableReady(CR) and Stimulus. This is the third post in the series--if you need to catch up you can:

First Impression

StimulusReflex had been making a splash in recent years and especially the last few months: GoRails created an introductory video in mid-April of 2020, and less than two weeks later the SR team released a Twitter clone demo video of their own. Both videos promote using SR rather than heavy front-end frameworks. It would be eight months between these videos and the release of Hotwire.

The documentation and instrumentation for CR/SR seem quite mature and the development team is surprisingly accessible, friendly and generous on their Discord channel. This made integrating StimulusReflex straightforward, with a good human fallback if any hiccups happen.

At its core StimulusReflex is a set of patterns that provide some glue between Stimulus JS and CableReady. Like Hotwire, CableReady provides a mechanism to update the DOM by sending mostly HTML and operations to the client. Unlike Hotwire, CableReady solely depends on WebSockets[^1] while Hotwire only uses ActionCable. It is also worth noting that StimulusReflex leans on morphdom for some of its more advanced manipulations of the DOM.

Installation of StimulusReflex is pretty trivial following their docs but basically the steps were:

bundle add stimulus_reflex
bundle exec rails stimulus_reflex:install

With StimulusReflex installed, we can move on to integrating it with our bare bones application.

Customizing StimulusReflex

The model we'll be using StimulusReflex to manage is the Message, so we first need to generate the reflex classes we'll build on:

rails g stimulus_reflex message

This will create application_controller.js and message_controller.js files in app/javascript/controllers for housing the SR-specific JavaScript and create application_reflex.rb and message_reflex.rb in app/reflexes for the SR-specific Ruby.

The first test in our app was to see how StimulusReflex did with broadcasting new messages to all client streams. We found that to be as trivial (and well-documented) in StimulusReflex as it was in Hotwire, so we decided to send a different payload to the message's creator than the one sent to other room subscribers.

This task is somewhat unobvious. Thankfully, we received some advice from the SR Discord channel to check out The Logical Splitter Example in the docs. We were also advised to explore CableReady's extensible Custom Operations. Let's walk through how this played out.

First, we prepare our application layout. Inserting the user's id as a meta tag in the head of the layout will provide us with a reference we will use later:

<%# in app/views/layouts/application.html.erb %>

<%= tag(:meta, name: :cable_ready_id, content: current_user&.id) %>

In our MessageReflex class we specify that when created via reflex, the message should broadcast two html snippets, one for the html that everyone in the room will see (default_html), one snippet that is rendered when the current user is the author of the message (custom_html):

# in app/reflexes/message_reflex.rb
class MessageReflex < ApplicationReflex
  delegate :current_user, to: :connection

  def create
    message = room.messages.create(comment: element.value, user: current_user)

    message_broadcast(message, dom_id(room), :insertAdjacentHtml)
    morph :nothing
  end

  def message_broadcast(message, selector, operation, **opts)
    cable_ready[RoomChannel].logical_split(
      selector: selector,
      operation: operation,
      default_html: render(message, locals: { for_messenger: false }),
      custom_html: {
        [current_user.id] => render(message, locals: { for_messenger: true }),
      },
      additional_options: opts
    ).broadcast_to(room)
  end

  # ...

  def room
    @room ||= Room.find(element.dataset[:room_id])
  end
end

We can see that the channel the broadcast will use is RoomChannel, which we need to define. We're also providing a selector using dom_id(room) as the target html element of the broadcast.

The StimulusReflex::Reflex class, parent to ApplicationReflex, parent to our MessageReflex provides element, representing the html element that triggered the reflex. That element's dataset (all the data-* attributes of that element) includes a :room_id, allowing us to look up the specific Room we need.

As written, RoomChannel is boilerplate stuff. The stream_for indicates that for any given Room, a channel exists:

# app/channels/room_channel.rb
class RoomChannel < ApplicationCable::Channel
  def subscribed
    stream_for Room.find(params[:id])
  end
end

We'll add the Room's id to a div tag in our view using dom_id(@room) so the channel knows which element to target as a container. We also include the room's id in data-room-id-value=, which makes it a part of the element's dataset:

<%# in app/views/rooms/show.html.erb %>
<div id="<%= dom_id(@room) %>" class="pt-4 mr-4 flex flex-col"
    data-controller="room"
    data-room-id-value=<%= @room.id %>>
  <%= render @room.messages %>
</div>

Specifically, within the channel RoomChannel, the broadcast targets the logical_split custom operation. We define that custom operation in our application.js file:

// in app/javascript/packs/application.js
import CableReady from 'cable_ready'

// note the change in case convention!
// logicalSplit in js <-> logical_split in ruby
CableReady.DOMOperations['logicalSplit'] = detail => {
  const crId = document.querySelector('meta[name="cable_ready_id"]').content
  const custom = Object.entries(detail.customHtml).find(pair => pair[0].includes(crId))
  const html = custom ? custom[1] : detail.defaultHtml
  CableReady.DOMOperations[detail.operation]({
    element: detail.element,
    html: html,
    ...detail.additionalOptions
  })
}

import "controllers"

Finally we set up the channel as outlined in the manual using a room controller:

// in app/javascript/controllers/room_controller.js

import { Controller } from 'stimulus'
import CableReady from 'cable_ready'

export default class extends Controller {
  static values = { id: String }

  connect () {
    this.channel = this.application.consumer.subscriptions.create(
      {
        channel: 'RoomChannel',
        id: this.idValue,
      },
      {
        received (data) { if (data.cableReady) CableReady.perform(data.operations) }
      }
    )
  }

  disconnect () {
    this.channel.unsubscribe()
  }
}

Simple, right?

In many circumstances, there's no need to build all of this custom code. CableReady provides a diverse and comprehensive array of operations out of the box to manipulate the DOM and interact with the browser. We customized specifically because we wanted different visuals to highlight messages posted by the current user, which doesn't happen out of the box. (Recall that in our Hotwire implementation, we did this using CSS.) This cost us ~50 lines of custom code and some boilerplate.

One neat feature of CableReady is that is can be used almost anywhere in your Rails app. This is a fairly standard form for creating a message:

<div class="bg-primary bg-opacity-90 w-full p-4">
  <%= form_with model: [@room, Message.new] do |f| %>
    <div class="flex">
      <%= f.text_field(
        :comment,
        autocomplete: "off",
        placeholder: "Start a conversation",
        class: "rounded-full border p-3 flex-1 text-sm outline-none") %>
     </div>
   <% end %>
</div>

We can use CableReady::Broadcaster (which extends ActiveSupport::Concern) in our Message controller to broadcast a newly-created message:

# app/controllers/message_controller.rb
class MessageController < ApplicationController
  include CableReady::Broadcaster
  def create
    @message = @room.messages.create(message_params)

    cable_ready[RoomChannel].logical_split(
      selector: dom_id(@room),
      operation: :insertAdjacentHtml,
      default_html: render_to_string(@message, locals: { for_messenger: false }),
      custom_html: {
      [@message.user_id] => render_to_string(@message, locals: { for_messenger: true }),
    }
  )
    cable_ready.broadcast_to(@room)
  end
end

We could have used an after_create hook in the Message model for the same effect.

In exploring the reflex part of StimulusReflex a little more thoroughly, we decided to move the logic into a Reflex. This way we won't need a <form> tag at all. To do that, we add a reflex: key to our text field's data attribute:

<div class="flex bg-gray-100 bg-opacity-90 w-full p-4 max-w-screen-lg mx-auto"
     data-controller="message">
  <%= text_field_tag(:comment, "",
    autocomplete: "off",
    placeholder: "Start a conversation",
    data: { reflex: "change->Message#create", room_id: @room.id },
    id: "new-comment",
    class: "rounded-full border p-3 flex-1 text-sm outline-none") %>
</div>

StimulusReflex inherits the quirky syntax (change->Message#create) from the Stimulus framework, adding in the reflex: innovation. If it seems odd at first, it may grow on you. The change sets the event on which to act, Message refers to the MessageReflex class we defined earlier, and #create marks the method to call on that reflex. By defining a reflex on the element, we can take advantage of the client side callbacks that StimulusReflex provides for all reflexes that have a corresponding (JavaScript) Controller in app/javascript/controllers. It would be nice to clear the input after creating the message, so we add the following:

// in app/javascript/controllers/message_controller.js
   createSuccess(element) {
     element.value = ''
   }

Looking back at the create method of MessageReflex above you will note the call to morph :nothing. In that reflex we are overriding the default behavior and using CableReady directly.

As we tackle editing behavior, we can showcase the default morph behavior of a reflex. Let's start with our message partial. While the partial is very busy, it doesn't deviate too far from what we'd expect the partial to look like in a typical Rails application:

<%# app/views/messages/_message.html.erb %>
<div class="<%= message_styles(message, local_assigns) %>
            rounded-2xl px-4 py-2 mb-2 text-s w-fit"
     id="<%= dom_id(message) %>"
     data-reflex-root="#<%= dom_id(message) %>"
     data-controller="message">
  <div class="flex flex-row items-center">
    <div>
      <span class="inline-block font-bold text-sm"><%= message.user.handle %></span>
      <span class="inline-block ml-1 text-xs"><%= message.created_at.strftime("%l:%M%P") %></span>
      <% if for_messenger?(message, local_assigns) %>
        <% if @editing %>
          <span data-reflex="click->Message#cancel"
                data-id="<%= message.id %>"
                class="inline-block text-xs ml-2 hover:underline"
                id="<%= dom_id(message, "cancel") %>">
            Cancel
          </span>
        <% else %>
          <span data-reflex="click->Message#edit"
                data-id="<%= message.id %>"
                class="inline-block text-xs ml-2 hover:underline"
                id="<%= dom_id(message, "edit") %>">
            Edit
          </span>
        <% end %>
      <% end %>
    </div>
  </div>
  <% if @editing %>
    <%= text_area_tag :comment, message.comment,
      class: "block min-w-min w-72 md:w-96 m-2 h-32 p-2 text-black",
      id: dom_id(message, "update"),
      data: {
        reflex: "change->Message#update",
        action: "keyup->message#keyup",
        id: message.id,
        room_id: message.room_id } %>
  <% else %>
    <div class="text-black w-full">
      <%= message.comment %>
    </div>
  <% end %>
</div>```

We are building this as an exploration, otherwise we might not mix up quite as much logic into our partial. We can inform the partial whether or not the viewer is the messenger--the message sender--or not with the following helper:

```ruby
module MessageHelper
  def message_styles(message, locals)
    if for_messenger?(message, locals)
      "bg-sky text-blue-900 self-end align-right"
    else
      "bg-purple text-indigo-900"
    end
  end

  def is_messenger?(message)
    message.user == current_user
  end

  def for_messenger?(message, locals)
    for_messenger = locals[:for_messenger]
    for_messenger.nil? ? is_messenger?(message) : for_messenger
  end
end```

As a result, viewers will be able to differentiate between their own messages and those sent by others--regardless of whether or not they're receiving messages as a result of a page refresh or updates from the channel they're subscribed to.

We also now can see our remaining edit/update reflexes setup in the view partial which respectively call the following actions in our `MessageReflex`:

```ruby
# in app/reflexes/MessageReflex
  def edit
    @message = Message.find(element.dataset[:id])
    @editing = true
  end

  def update
    message = Message.find(element.dataset[:id])
    message.update(comment: element[:value])

    message_broadcast(message, "##{dom_id(message)}", :outerHtml)
    morph :nothing
  end

Looks strangely familiar; almost like a controller? When the edit button is clicked, the triggered reflex renders the partial and sends the update through the channel. On the client side, the default StimulusReflex controller (JS) uses morphdom to efficiently diff and apply the new/updated DOM elements. As a result, users see the kind of snappy edit-in-place field they've come to expect in modern web applications. Most of the heavy lifting here is done by StimulusReflex itself, without our intervention. This may be the closest we came to the kind of use case StimulusReflex is designed to simplify.

The update method also makes use of message_broadcast, this time using the CableReady :outerHtml directive. This matches the message using the dom_id and replaces the content for all subscribers to the room channel. We add some final polish by 'stimulating' the Message#update reflex when the user hits the 'Enter' key. We add this to the JS controller:

  // in app/javascript/controllers/message_controller.js
  keyup(event) {
    if(event.key === "Enter") { this.stimulate("Message#update", event.target) }
  }

Conclusion

In the first post of this series, we tried to provide some background on web application technologies, for context on why 'Reactive Rails' is having a moment. (If you missed it, you can read it here).

In this brief study, we've exercised some of the core and custom capabilities of CableReady and StimulusReflex, at least enough for the team at Flagrant to form some early opinions. We have not remotely plumbed the depths of features and capability these libraries can provide, but it's a thorough start.

In the next post in this series, we'll give Hotwire the same treatment. Finally, we'll compare and contrast the two, hopefully providing some useful information to other developers looking for a good fit.

All of the posts in this series are listed here:

Footnotes

[1]: StimulusReflex uses ActionCable by default. Integration with AnyCable is possible as well.