NOTE: for an updated version of this post, see my course StimulusReflex patterns, the forms lesson is included in the free samples!

Forms are the standard CRUD pattern and can be handled by standard Rails remote forms perfectly. Still there might be times when you want to use your newly acquired StimulusReflex superpowers for a slicker user experience. My suggestion would be to stick to your last, but below are two patterns you can utilize to improve not only your user interactions, but also your application structure.

1. Vanilla CableReady

On the StimulusReflex discord we frequently get asked how to best handle form submissions in reflexes. My answer tends to be: “Don’t”. Instead I’m first going to show you a more robust technique that relies on standard Rails remote forms and the power of CableReady.

I’m borrowing a bit of the excellent Calendar Demo on StimulusReflex’s expo site for the purposes of this walkthrough. (If you want to follow along, this RailsByte will install StimulusReflex and TailwindCSS. You can also use the demo repo, complete with version tags)

For starters, our tiny calendar app needs just one model, CalendarEvent, which we generate with a scaffold command:

rails new calendula --template "https://www.railsbytes.com/script/z0gsd8" --skip-spring --skip-action-text --skip-active-storage --skip-action-mailer --skip-action-mailbox --skip-sprockets --skip-webpack-install
bin/rails g scaffold CalendarEvent occurs_at:datetime description:text color:string

In the index template, we only render two partials: One containing the form to add a new event, and the calendar grid itself. We inject @dates, which is just a collection of Date objects we want to display, and @calendar_events, all CalendarEvent s in this date range.

<!-- app/views/calendar_events/index.html.erb -->
<div id="calendar-form">
  <%= render "form", calendar_event: @calendar_event %>
</div>

<div class="w-screen p-8">
  <div class="grid grid-cols-7 w-full" id="calendar-grid">
    <%= render partial: "calendar_events/calendar_grid",
               locals: {dates: @dates, calendar_events: @calendar_events} %>
  </div> 
</div>

The _calendar_grid.html.erb partial renders a collection of _date.html.erb partials:

<!-- app/views/calendar_events/_calendar_grid.html.erb -->
<% Date::DAYNAMES.each do |name| %>
  <div class="bg-gray-500 text-white p-3"><%= name %></div>
<% end %>
<%= render partial: "date", collection: dates,
           locals: { calendar_events: calendar_events } %>
<!-- app/views/calendar_events/_date.html.erb
<div class="flex flex-col justify-between border h-32 p-3">
  <span class="text-small font-bold"><%= date %></span>

  <% (calendar_events[date] || []).each do |calendar_event| %>
    <span class="bg-<%= calendar_event.color %>-700 rounded text-white px-4 py-2">
      <%= calendar_event.description %>
      </span>
  <% end %>
</div>

The _form.html.erb partial is just the standard one generated by the scaffold, nothing fancy here. In the calendar_events_controller.rb, however, we need to change a couple of things. (I’ll omit the part where all the instance variables are set up. You can find it in the demo repo if you’re interested)

First, we remove the standard redirect upon successful insert. Second, we need to include the CableReady::Broadcaster module to inject this functionality. Then, we re-fetch the @calendar_events containing the newly created event and morph over the #calendar-grid using a CableReady broadcast:

# app/controllers/calendar_events_controller.rb
class CalendarEventsController < ApplicationController
  include CableReady::Broadcaster

  # ...

  def create
    @calendar_event = CalendarEvent.new(calendar_event_params)

    if @calendar_event.save
      @calendar_events = CalendarEvent.where(occurs_at: @date_range)
                           .order(:occurs_at).group_by(&:occurs_at_date)
      cable_ready["CalendarEvents"].morph({
        selector: "#calendar-grid",
        html: CalendarEventsController.render(partial: 'calendar_events/calendar_grid',
                                              locals: {dates: @dates, calendar_events: @calendar_events}),
        children_only: true})
      cable_ready.broadcast
    else
      render :index
    end
  end

  # ...

end

Of course we need to do the necessary CableReady setup, i.e. create a channel and configure the corresponding JavaScript channel.

bin/rails g channel CalendarEvents 
# app/channels/calendar_events_channel.rb
class CalendarEventsChannel < ApplicationCable::Channel
  def subscribed
    stream_from "CalendarEvents"
  end
end
// app/javascript/channels/calendar_events_channel.js
import CableReady from "cable_ready";
import consumer from "./consumer";

consumer.subscriptions.create("CalendarEventsChannel", {
  received(data) {
    if (data.cableReady) CableReady.perform(data.operations);
  }
});

Looking at the result we can observe that the insert works as expected, only the form does not reset. That’s expected though, since we’re not dealing with a normal request/response cycle, so we have to do this ourselves:

../assets/images/posts/2020/2020-10-16-calendula-01.gif

Let’s add a second CableReady operation for replacing the #calendar-form partial:

class CalendarEventsController < ApplicationController
  include CableReady::Broadcaster

  # ...

  def create
    @calendar_event = CalendarEvent.new(calendar_event_params)

    if @calendar_event.save
      @calendar_events = CalendarEvent.where(occurs_at: @date_range).order(:occurs_at).group_by(&:occurs_at_date)
      cable_ready["CalendarEvents"].morph({
        selector: "#calendar-grid",
        html: CalendarEventsController.render(partial: 'calendar_events/calendar_grid', locals: {dates: @dates, calendar_events: @calendar_events}),
        children_only: true})
      # -----> we also have to reset the form <-----
      cable_ready["CalendarEvents"].inner_html({
        selector: "#calendar-form",
        html: CalendarEventsController.render(partial: "form", locals: { calendar_event: CalendarEvent.new })})

      cable_ready.broadcast
    else
      render :index
    end
  end

  # ...

end

Note that I’m deliberately using the inner_html operation here, since morph would not actually clear the inputs, since their current state is not reflected in the DOM.

../assets/images/posts/2020/2020-10-16-calendula-02.gif

The added benefit of this approach over a pure StimulusReflex based one is that we can update our model(s) on other parts of the site, too. If we isolate that logic in an ActiveJob (or a service object, for those so inclined), we can make it more testable and reuse it from other parts of the application.

bin/rails g job StreamCalendarEvents
# app/controllers/calendar_events_controller.rb
def create
  @calendar_event = CalendarEvent.new(calendar_event_params)

  if @calendar_event.save
    @calendar_events = CalendarEvent.where(occurs_at: @date_range)
                         .order(:occurs_at).group_by(&:occurs_at_date)
    StreamCalendarEventsJob.perform_now(dates: @dates,
                                        calendar_events: @calendar_events,
                                        date_range: @date_range)
  else
    render :index
  end
end
# app/jobs/stream_workloads_job.rb
class StreamCalendarEventsJob < ApplicationJob
  include CableReady::Broadcaster

  queue_as :default

  def perform(dates:, calendar_events:, date_range:)
    cable_ready["CalendarEvents"].morph({
      selector: "#calendar-grid",
      html: CalendarEventsController.render(partial: 'calendar_events/calendar_grid', locals: {dates: dates, calendar_events: calendar_events, date_range: date_range}),
      children_only: true})
    # we also have to reset the form
    cable_ready["CalendarEvents"].inner_html({
      selector: "#calendar-form",
      html: CalendarEventsController.render(partial: "form", locals: { calendar_event: CalendarEvent.new })})

    cable_ready.broadcast
  end
end

We’ll revisit this ActiveJob in part 2.

2. Use a Concern to DRY your StimulusReflex Form Submissions

Pure reflex-based form submissions can be a source of Shotgun Surgery smells if you pay no attention, since you’ll end up with a lot of duplicated logic. True, standard Rails controllers are resource-based too, but creating a resource.rb, resources_controller.rb, resources_reflex.rb structure will lead you down the path of unmaintainability quickly. Let’s revisit the example from above and put the form handling logic in a reflex.

Rewriting Form Submission with a Reflex

bin/rails g stimulus_reflex CalendarEvents
# app/reflexes/calendar_events_reflex.rb
class CalendarEventsReflex < ApplicationReflex
  def submit
    @calendar_event = CalendarEvent.new(calendar_event_params)

    @calendar_event.save
  end

  private

  def calendar_event_params
    params.require(:calendar_event).permit(...)
  end
end

This looks no different from a standard Rails controller and little could be said to justify putting this form handling logic here. We can do better though, by hoisting the form submission logic into a Concern. Note that to keep it reusable, we have to pass in the resource_class via data-params, which we have to encrypt it to prevent tampering. (Side note: simplified access to signed/unsigned global IDs will soon land in StimulusReflex)

# app/reflexes/concerns/submittable.rb
module Submittable
  extend ActiveSupport::Concern

  included do
    before_reflex do
      resource_class = Rails.application.message_verifier("calendar_events")
                            .verify(element.dataset.resource)
                            .safe_constantize
      @resource = resource_class.new(submit_params)
    end
  end

  def submit
    @resource.save

    morph :nothing
  end
end

This means that our reflex can now concentrate on how to handle a successful form submission (I’m deliberately only covering the “happy path” here, without validation errors). In after_reflex, we use the StreamCalendarEventsJob in the same fashion as in the controller in part 1.

# app/reflexes/calendar_events_reflex.rb
class CalendarEventsReflex < ApplicationReflex
  include Submittable

  before_reflex :fetch_dates

  after_reflex do
    @calendar_events = CalendarEvent.where(occurs_at: @date_range)
                         .order(:occurs_at).group_by(&:occurs_at_date)
    StreamCalendarEventsJob.perform_now(dates: @dates,
                                        calendar_events: @calendar_events,
                                        date_range: @date_range)
  end

  private

  def submit_params
    params.require(:calendar_event).permit(:occurs_at, :description, :color)
  end

  def fetch_dates
    # ... omitted
  end
end

To make this reusable on the client side, too, let’s create a reflex_form_controller.js. It will take a reflexName via the Stimulus Data API and wraps a formTarget.

import ApplicationController from "./application_controller";

export default class extends ApplicationController {
  static targets = ["form"];
  connect() {
    super.connect();
  }

  submit(e) {
    e.preventDefault();
    this.stimulate(`${this.data.get('reflexName')}#submit`, this.formTarget);
  }
}
<!-- app/views/calendar_events/_form.html.erb -->
<div data-controller="reflex-form" data-reflex-form-reflex-name="CalendarEventsReflex">
  <%= form_with(model: calendar_event, class: "m-8", data: {action: "reflex-form#submit", target: "reflex-form.form", resource: Rails.application.message_verifier("calendar_events").generate("CalendarEvent")}) do |form| %>
    <!-- form omitted -->
  <% end %>
</div>

Triggering the submit action will stimulate the reflex. It can also be used to trigger further events, e.g. for DOM manipulation (closing menus etc.)

Re-Using the Pattern

So what’s the point in this exercise if I didn’t demonstrate how this pattern can be reused? For this, let’s assume we want to include national holidays in our calendar (I’m using the holidays and country_select gems here). First, we need a model to store this data. We’ll use a standard ActiveRecord and use it in a “singleton” fashion (meaning, we pretend there’s only ever one instance of it).

bin/rails g model Settings holiday_region:string

We’d like to add a simple dropdown to choose a country from, so we just add

<div class="grid grid-cols-2 w-full">
  <div id="calendar-form" data-controller="reflex-form" data-reflex-form-reflex-name="CalendarEventsReflex">
    <%= render "form", calendar_event: @calendar_event %>
  </div>
  <div id="settings-form" data-controller="reflex-form" data-reflex-form-reflex-name="SettingsReflex">
    <%= form_with(model: Setting.first, method: :put, url: "#", class: "m-8", data: {signed_id: Setting.first.to_sgid.to_s, target: "reflex-form.form"}) do |form| %>
      <div class="max-w-lg rounded-md shadow-sm sm:max-w-xs">
        <%= form.label :holiday_region %>
        <%= form.country_select :holiday_region,
         {only: Holidays.available_regions.map(&:to_s).map(&:upcase)},
         class: "form-select block w-full transition duration-150 ease-in-out sm:text-sm sm:leading-5",
         data: {action: "change->reflex-form#submit", reflex_dataset: "combined"} %>
      </div>
    <% end %>
  </div>
</div>

to our calendar_events/index.html.erb. Note that we use the same stimulus controller we created above! We are passing in a signed global ID (SGID) of our Settings instance, too. We need to extend our Submittable concern to check whether a signed ID is present in the element’s dataset. If so, we retrieve the record and assign the changed attributes. If not, our creation logic stays in place:

# app/reflexes/concerns/submittable.rb
module Submittable

  # ...

  included do
    before_reflex do
      if element.dataset.signed_id.present?
        @resource = GlobalID::Locator.locate_signed(element.dataset.signed_id)
        @resource.assign_attributes(submit_params)
      else
        resource_class = Rails.application.message_verifier("calendar_events")
                           .verify(element.dataset.resource)
                           .safe_constantize
        @resource = resource_class.new(submit_params)
      end
    end
  end

  # ...
end

Last but not least we have to create the SettingsReflex that includes the Submittable concern.

bin/rails g stimulus_reflex SettingsReflex
# app/reflexes/settings_reflex.rb
class SettingsReflex < ApplicationReflex
  include Submittable

  before_reflex :fetch_dates 

  after_reflex do
    @calendar_events = CalendarEvent.where(occurs_at: @date_range)
                         .order(:occurs_at).group_by(&:occurs_at_date)
    StreamCalendarEventsJob.perform_now(dates: @dates,
                                        calendar_events: @calendar_events,
                                        date_range: @date_range)
  end

  private

  def submit_params
    params.require(:setting).permit(:holiday_region)
  end

  def fetch_dates
    # ...
  end
end

And here lies the beauty of this pattern: The actual reflex logic is reduced to handling the DOM patching, and even that could be extracted to a concern since it’s the exact same as above. We only need to adapt our _date.html.erb partial to display the holiday name:

<div class="flex flex-col justify-between border h-32 p-3 <%= "bg-gray-200" unless date_range.include? date %>">
  <span class="text-small font-bold"><%= date %></span>

  <% holidays = Holidays.on(date, Setting.first.holiday_region.downcase.to_sym) %>
  <% unless holidays.empty? %>
    <span class="bg-blue-600 rounded text-white px-4 py-2">
      <%= holidays.map { |h| h[:name] }.join(" ") %>
    </span>
  <% end %>

  <% (calendar_events[date] || []).each do |calendar_event| %>
    <span class="bg-<%= calendar_event.color %>-700 rounded text-white px-4 py-2">
      <%= calendar_event.description %>
    </span>
  <% end %>
</div>

Here’s the end result:

assets/images/posts/2020/2020-10-16-calendula-03.gif

Conclusion

I’ve introduced two patterns for form submissions using CableReady and StimulusReflex that you should consider when implementing it in your application. Using websockets as a transport necessitates the re-thinking of some established patterns, but done right can lead to a more cohesive codebase.