DEV Community

julianrubisch
julianrubisch

Posted on • Originally published at blog.minthesize.com on

Two Patterns for StimulusReflex Form Submissions

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 } %>
Enter fullscreen mode Exit fullscreen mode
<!-- 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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode
# app/channels/calendar_events_channel.rb
class CalendarEventsChannel < ApplicationCable::Channel
  def subscribed
    stream_from "CalendarEvents"
  end
end
Enter fullscreen mode Exit fullscreen mode
// 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);
  }
});
Enter fullscreen mode Exit fullscreen mode

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:

Inserting an event

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

class CalendarEventsController < ApplicationControllerinclude 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
Enter fullscreen mode Exit fullscreen mode

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.

Form Reset

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
Enter fullscreen mode Exit fullscreen mode
# 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
Enter fullscreen mode Exit fullscreen mode
# 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
Enter fullscreen mode Exit fullscreen mode

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.

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

bin/rails g stimulus_reflex SettingsReflex
Enter fullscreen mode Exit fullscreen mode
# 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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

Here’s the end result:

Selecting a Holiday Region

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.

Top comments (0)