DEV Community

Unpublished Post. This URL is public but secret, so share at your own discretion.
Cover image for Beast Mode StimulusReflex with AllFutures

Beast Mode StimulusReflex with AllFutures

I'm going to show you how to use StimulusReflex and a new gem called AllFutures to build faceted UIs in Rails with startling ease.

We're going to step through the creation of an interface for filtering, sorting and paginating tabular data. I call it... 👉 Beast Mode StimulusReflex 👈.

Beast Mode is literally a Single Page Application; it has one page that displays Customer records in a table.

chrome_YByqjduKC7

I recommend that you open the app in a separate tab and clone the code so that you can follow along.

GitHub logo leastbad / beast_mode

100% server-side rendered faceted search UI demo. Featuring StimulusReflex and All Futures.

The application is also "Dockerized", if that's your thing.

The Customer model

Beast Mode displays Customer records in a table. Each Customer has five attributes:

  • name
  • email
  • company
  • age
  • status

The Faker gem is used in seed.rb to populate the customers table with some sample data.

Faceted search is just a fancy way of saying that you start with every possibility, and then add constraints until you're only left with what you're looking for. If you've ever checked a box that implies "Only show results with Amazon Prime available", you've used a faceted search.

Our users will be able to interrogate Customer data with a variety of filters:

  • a text search that can either be really strict or forgiving of spelling mistakes; it will search all attributes, but give priority to names
  • display customers that match a specific status such as "Active"
  • show customers who work at law firms, based on the presence of the word "and" in their company
  • display customers in a particular age range

Filters are additive, and it doesn't matter which order you apply them. Filters can be implemented as Rails scopes in the Customer model:

class Customer < ApplicationRecord
  include PgSearch::Model
  pg_search_scope :stemmed, ->(query, threshold) {
    {
      query: query,
      against: {name: "A", email: "B", company: "B", age: "B", status: "C"},
      using: {
        tsearch: {prefix: true},
        trigram: {threshold: threshold, only: [:name]}
      }
    }
  }
  scope :search_for, ->(query, threshold) { stemmed(query, threshold) if query.present? }
  scope :with_status, ->(status) { where(status: status) if status.present? }
  scope :only_lawyers, ->(lawyers) { where("company like ?", "% and %") if lawyers }
  scope :between, ->(low, high) { where(age: low..high) }
end
Enter fullscreen mode Exit fullscreen mode

We make use of the excellent pg_search gem to create a scope called stemmed that will search for text matches (giving preference to name) with a variable threshold for error; 0.1 is quite forgiving while 0.3 is roughly "high school English teacher" strict. Due to a quirk in the way pg_search scopes work, searching for "" returns no results instead of all results, forcing us to create an inelegant search_for scope which only calls stemmed if there is a non-empty value to search for.

The three remaining scopes are standard Rails where clause modifiers. We always check to verify that a value has been set or else we don't add that scope to the query; the between scope is always called, eliminating the need for an if statement.

Introducing: AllFutures

We'll use StimulusReflex to make short work of the reactive functionality, while AllFutures can remember which filters we've activated across multiple user actions. We'll call this the "search instance" for the rest of this post.

We need continuity for our search instance, at least for as long as it's on our screen. If we hit refresh or navigate away and come back, the old instance is gone and we should see a brand new search instance, with the UI restored to its default state.

GitHub logo leastbad / all_futures

A Redis-backed virtual ActiveModel that makes building faceted search UIs a snap.

AllFutures offers Rails developers a way to gather attributes on an ephemeral, Redis-backed model across multiple requests. This solves a huge logistics problem. Rails' ActionDispatch is based on a request/response cycle, so there's no built-in provision for a model that might be valid soon... but isn't yet.

Even if ActiveRecord did know how to persist invalid records, storing the parameters of search instances in a searches table is an awkward solution at best, even though many developers have done exactly this over the years.

The worst part is that 1000 developers will solve the search instance problem in 1000 slightly different ways.

This has led to countless hacks: modifying the query string, abusing the session object, and scheduled tasks to clear out searches in the middle of the night. Remember: it's a law of nature that all temporary record clearing tasks eventually stop working shortly after the developer who wrote them leaves the company.

Everest

AllFutures classes are similar to ActiveModel::Model, but with attributes support. However, instead of persisting to your SQL datastore if they are valid, AllFutures instances are stored regardless of their validity.

Redis storage is fast, cheap and ephemeral; depending on your cache expiration policy, the keys you create will eventually be retired to make room for new ones.

Behind the scenes, AllFutures uses the awesome Kredis and ActiveEntity gems.

CustomerFilter

AllFutures classes look just like ActiveRecord models, except that you define your attributes in the class. They can also contain the standard ActiveRecord Model features like validations and errors, instance methods, associations, attribute arrays, defaults and dirty checking. You can learn more by checking the ActiveEntity documentation.

You can put AllFutures model classes in your app/models folder, or create dedicated folders if your application will have multiple classes for different use cases.

For the Beast Mode application, we create attributes to hold all of the information associated with a search instance. These attributes correspond to the state of the UI elements, as well as pagination, the sort order and direction.

Representing a search instance as a collection of attributes might require a shift in thinking. We're used to being focused on the results of a search, not the data which defines the search itself.

You can see how the default values assigned to the attributes directly map to the default state of controls in the faceted search UI:

# app/models/customer_filter.rb
class CustomerFilter < AllFutures::Base
  # Facets
  attribute :search, :string
  attribute :threshold, :float, default: 0.1
  attribute :status, :string
  attribute :lawyers, :boolean, default: false
  attribute :low, :integer, default: 21
  attribute :high, :integer, default: 65

  # Pagination
  attribute :items, :integer, default: 10
  attribute :page, :integer, default: 1

  # Sorting
  attribute :order, :string, default: "name"
  attribute :direction, :string, default: "asc"

  def scope
    Customer
      .with_status(status)
      .only_lawyers(lawyers)
      .between(low, high)
      .order(order => direction)
      .search_for(search, threshold)
  end
end
Enter fullscreen mode Exit fullscreen mode

There is also an instance method called scope, which returns an ActiveRecord::Relation. This is a lazy-loaded query that won't run until it's evaluated.

The goal is to keep the business logic associated with each search instance inside of the AllFutures class, similar to how you structure your ActiveRecord models.

Connect the dots

In order to put our CustomerFilter to work, we're going to need a CustomersController and matching views. Finally, we'll connect the dots with a CustomersReflex.

class CustomersController < ApplicationController
  def index
    @filter = CustomerFilter.create
    @pagy, @customers = pagy(@filter.scope)
  end
end
Enter fullscreen mode Exit fullscreen mode

Before we can hand the keys over to StimulusReflex, we need three instance variables to render and serve the search UI page in its default state.

We need to create @filter, an instance of CustomerFilter. It represents this specific search session for however long this page is open. We can then call the scope method on @filter and pass the ActiveRecord Relation it returns right into Pagy.

Pagy rewards us with @pagy and @customers - giving us the default results and a reference to the Pagy object - which lets us navigate through them.

I don't think it makes sense to forensically analyze the views; Beast Mode makes use of Bootstrap 5, FontAwesome and Stimulus as well as StimulusReflex. Here is a simplified snippet from the ERB template used to construct the Search text box and the Status dropdown:

<input type="text" data-reflex="input->Customers#search" data-filter="<%= filter.id %>" />

<select data-reflex="change->Customers#status" data-filter="<%= filter.id %>">
  <option selected value="">All</option>
  <option value="Active">Active</option>
  <option value="Inactive">Inactive</option>
  <option value="Pending">Pending</option>
  <option value="Suspended">Suspended</option>
</select>
Enter fullscreen mode Exit fullscreen mode

The key detail here is the use of the data-filter="<%= filter.id %>" on every element relevant to the search parameters. The filter's id allows us to obtain a reference to the correct CustomerFilter instance inside our Reflex actions. When you initiate a Reflex, all attributes on the element are automatically passed to the server and made available via the element accessor.

CustomersReflex is the rug that ties the room together. Here's a meaningful sample extracted from the main event:

class CustomersReflex < ApplicationReflex
  def paginate
    facet do |filter|
      filter.page = element.dataset.page
    end
  end

  def search
    facet do |filter|
      filter.search = element.value
    end
  end

  def status
    facet do |filter|
      filter.status = element.value
    end
  end

  def sort
    facet do |filter|
      filter.order = element.dataset.order
      filter.direction = element.dataset.direction
    end
  end

  private

  def facet
    filter = CustomerFilter.find(element.dataset.filter)
    yield filter
    filter.save
    pagy, customers = pagy(filter.scope, page: filter.page, items: filter.items)
    morph customers
    morph "#paginator", render(partial: "customers/paginator", locals: {pagy: pagy, filter: filter})
  end
end
Enter fullscreen mode Exit fullscreen mode

Every user interaction triggers a Reflex action which uses the filter id to retrieve the correct CustomerFilter, updates the appropriate attributes depending on which action was taken, and then passes the updated CustomerFilter scope into Pagy.

Now we take the filter, pagy and customers variables and use them to morph two HTML fragments: the current results, and the pagination controls. Both reflect the current state of the search instance, thanks to the CustomerFilter.

We're pretty excited about the new morph customers syntax available in StimulusReflex v3.5. morph now accepts an ActiveRecord Relation - essentially, a set of model instances returned from a query. If the morph target is properly named (#customers) and Customer knows where its partial lives (app/views/customers/customer.html.erb), morph can do all of the heavy lifting, including wrapping your rendered partial collection. 🧙

This Reflex-powered code path resembles a video game run loop more than a traditional Rails ActionDispatch request/response cycle.

This new way of responding to user interactions is an example of #reactiverails unlocking obscene productivity and performance gains.

Since Selector Morphs do not go through the Rails router or ActionController, a Reflex can be quite fast compared to other techniques. It's common to see Reflex actions finish in 7-15ms.

That's a wrap!

It's wild how much smart, reactive functionality you can build with a few lines of code using Rails, StimulusReflex and AllFutures... and this didn't even use any of the new features of CableReady v5.

Outside of a few highly-specialized edge cases, it's now almost impossible to imagine starting a new project with an SPA like React.

My favourite feature of StimulusReflex is that there's a large, incredibly friendly group of experts on Discord who will stop what they are doing to help newcomers get up to speed, 24/7. Drop by and say hello.


Did they meet at the gym?

Top comments (0)