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:
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
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.
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.
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:
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)