Live query render with Rails 6 and Stimulus JS
Daveyon Mayne 😻
Posted on August 20, 2020
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
- You do not have jQuery installed - 😁 🥳 - Ok, you can but not needed
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.
Posted on August 20, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.