Live query render with Rails 6 and Stimulus JS

I thought I'd give Stimulus another try with a side project I'm working on. This time, I only wanted a "splash" of JavaScript magic here and there while I keep our Lord and Saviour in mind, DHH, when designing.

Live query render with Rails 6 and Stimulus JS

I thought I'd give Stimulus another try with a side project I'm working on. This time, I only wanted a "splash" of JavaScript magic here and there while I keep our Lord and Saviour in mind, DHH, when designing.

DHH talks about his love for server-side rendering and how to break down your controller logic into what I call, "micro-controllers". This approach makes much sense, to me.

I'm coming from a React frontend development where I separate the client from the server (api). Everything is done through Restful fetching which returns json. When doing a search/query, you fetch the data then update your state with the returned data and that's how you'd implement a live query. A live query is when you have an input field, the user makes a query and the list updates instantly or, a dropdown is populated with the results. Things work differently with jQuery or Stimulus. In our case, we'll be using Stimulus.

Perquisites

  • You have Rails 5+ installed
  • You have Stimulus installed

We won't be using any js.erb files here since we're using Stimulus. If Basecamp doesn't uses it, I thought I'd follow suit.

Let's say we have a URL /customers, and a controller called customers_controller.rb:

# before_action :authenticate_user! # For Devise
[..]

def index
  @customers = Customer.all.limit(100)
end

[..]

And our views views/customers/index.html.erb:

<main>
  <!-- Filter section -->
  <section>
    <input type="text" name="query" value="" placeholder="Search" />
  </section>

  <!-- Results section -->
  <section data-target="customers.display">
   <%= render partial: 'shared/customer_row', locals: {customers: @customers}  %>
  </section>
</main>

Partials

Inside views/shared/_customer_row.html.erb:

<ul>
  <% customers.each do | customer | %>
    <li><%= customer.first_name + ' ' + customer.surname %></li> 
  <% end %>
</ul>

With this minimal setup, we should see a text input field and a list of customers.

JS Magic with Stimulus

As the user types in our text field (input), we need to submit that data to the server (controller). To do that, we need few things:

  • A stimulus controller customers_controller.js
  • a form
// Stimulus controller
import { Controller } from "stimulus"
import Rails from "@rails/ujs"

export default class extends Controller {
  static targets = [ "form", "query", "display"]

  connect() {
    // Depending on your setup
    // you may need to call
    // Rails.start()
    console.log('Hello from customers controller - js')
  }

  search(event) {
    // You could also use
    // const query = this.queryTarget.value
    // Your call.
    const query = event.target.value.toLowerCase()
    console.log(query)
  }

  result(event) {}

  error(event) {
    console.log(event)
  }
}

I won't go into how Stimulus works but do have a read on their reference.

Let's update the html:

<main data-controller="customers">
  <!-- Filter section -->
  <section>
    <form
      data-action="ajax:success->customers#result"
      data-action="ajax:error->customers#error"
      data-target="customer.form"
      data-remote="true"
      method="post"
      action=""
    >
      <input
        data-action="keyup->customers#search"
        data-target="customers.query"
        type="text" 
        name="query" 
        value=""
        placeholder="Search"
      />
    </form>
  </section>

  <!-- Results section -->
  [..]
</main>

Refreshing the page then check you browser console, you'd see the message "Hello from customers controller - js". If not, stop and debug you have Stimulus installed correctly and the controller name is present on your html element: data-controller="customers". When entering a value in the input, you should see what you've typed being logged in your browser console.

Micro Controllers

This post talks about how DHH organizes his Rails Controllers. We'll use same principles here.

Inside our rails app controllers/customers/filter_controller.rb

class Customers::FilterController < ApplicationController
  before_action :set_customers
  include ActionView::Helpers::TextHelper

  # This controller will never renders any layout.
  layout false

  def filter
    initiate_query
  end

  private
    def set_customers
      # We're duplicating here with customers_controller.rb's index action 😬
      @customers = Customer.all.limit(100)
    end

    def initiate_query
      query = strip_tags(params[:query]).downcase

      if query.present? && query.length > 2
        @customers = Customers::Filter.filter(query)
      end
    end
end

Routing

Inside routes.rb

[..]

post '/customers/filter', to: 'customers/filter#filter', as: 'customers_filter'

[..]

We've separated our filter logic from our CRUD customers controller. Now our controller is much simpler to read and manage. We've done the same for our model Customers::Filter. Let's create that:

Inside model/customers/filter.rb:

class Customers::Filter < ApplicationRecord
  def self.filter query
    Customer.find_by_sql("
      SELECT * FROM customers cus
      WHERE LOWER(cus.first_name) LIKE '%#{query}%'
      OR LOWER(cus.surname) LIKE '%#{query}%'
      OR CONCAT(LOWER(cus.first_name), ' ', LOWER(cus.surname)) LIKE '%#{query}%'
    ")
  end
end

Wow? No. This is just a simple query for a customer by their first name and surname. You may have more logic here, but for brevity, we keep it short and simple.

Though our Customers::FilterController will not use a layout, we still need to render the data, right? For that, we need a matching action view name for filter. Inside views/customers/filter/filter.html.erb:

<%= render partial: 'shared/customer_row', locals: {customers: @customers}  %>

This is what our returned data will looks like - it's server-side rendered HTML. Now we need to update our form's action customers_filter then fetch some data as we type:

[..]
<!-- Filter section -->
<section>
  <form
    data-action="ajax:success->customers#result"
    data-action="ajax:error->customers#error"
    data-target="customer.form"
    data-remote="true"
    method="post"
    action="<%= customers_filter_path %>"
  >
    <input
      data-action="keyup->customers#search"
      data-target="customers.query"
      type="text" 
      name="query" 
      value=""
      placeholder="Search"
    />
  </form>
</section>
[..]

Remember we got customers_filter from routes.rb. We now need to update our js:

[..]

search(event) {
  Rails.fire(this.formTarget, 'submit')
}

result(event) {
  const data = event.detail[0].body.innerHTML
  if (data.length > 0) {
    return this.displayTarget.innerHTML = data
  }

  // You could also show a div with something else?
  this.displayTarget.innerHTML = '<p>No matching results found</p>'
}

[..]

In our search(), we don't need the query as it's passed to the server via a param. If you have any business logics that need the query text, in JS, then you can do whatever there. Now when you make a query, the HTML results update automatically.

Update

You should noticed I'm duplicating @customers = Customer.all.limit(100). Let's put this into a concern.

Inside controllers/concerns/all_customers_concern.rb

module AllCustomersConcern
  extend ActiveSupport::Concern

  included do
    helper_method :all_customers
  end

  def all_customers
    Customer.all.limit(100)
  end
end

Next, update all controllers:

class CustomersController < ApplicationController
  include AllCustomersConcern

  def index
    @customers = all_customers
  end

  [..]
end

class Customers::FilterController < ApplicationController
  [..]
  include AllCustomersConcern
  [..]
  private
    def set_customers
      @customers = all_customers
    end
end

Conclusion

Rails with Stimulus make it very easy to build any complex filtering system by breaking down logics into micro controllers. Normally I'd put everything in one controller but I guess DHH's approach becomes very useful.

Typos/bugs/improvements? Feel fee to comment and I'll update. I hope this is useful as it does for me. Peace!

Thanks

A huge shout out to Jeff Carnes for helping me out. I've never done this before and I'm well pleased.