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.
Top comments (10)
This is great, thanks Daveyon. Really interesting to see how DHH approaches controllers too.
I added in a
spinner
target and method to my stimulus controller as well, just so there's some instant feedback for the user.Also, the first entry of the
event.detail
array removed all of my table tags :s. So, I ended up using the third entry instead.What are your thoughts on the requests being triggered at every
keyup
in terms of server load? I had a quick look around and there's solutions to delay the request for 500ms to see if another event is fired, but then that delays the result to the user.Shopify seem to fire a request off on every keyup, so that's what I'm doing for now.
Hi oliwoodsuk.
I believe Shopify uses GraphQL for their fetching? I have not taken into consideration server load etc. You many want to cache the results or fire the query when the user enters at least 2 characters. What you've done looks cool.
Yeah, pretty sure your right actually. Ah yep, good idea. I think I'll do just that.
Hi ! Thanks a lot with all this. I'm just confused with what seems like namespaced models. I couldn't find your code on your Github account. Is it somewhere else ?
Also I can't get this to work with a POST request (but it work with a get), I get a strange Turbolinks function in my data rather than a document. Why did you choose post instead of get ?
Thank you :)
Hi @maxencep40 . What are you referring to exactly? An example?
Thank you for your answer. I created a SO post about the issue I'm facing here. Did you keep your repo of this post ?
Ok. I use
post
because I'm sending data to the backend. You useget
when you "want" something only (nothing to send in the body request). The key part is the method name:filter
. Ensure the namefilter
matches the name of your view path/filename. This is a rails convention.Namespacing.
In this post, I'm talking about customers so you'll have a path like this:
/controllers/customers/*
In that folder, you'll have all the controllers (classes) related to "customer":
/controllers/customers/customers_controller.rb
(CRUD actions)/controllers/customers/filter_controller.rb
(All other actions that are not CRUD)I talk about keeping your controller folders organised instead of having all the files in
controllers/
. Models would be the same:/models/customers/<model-name>.rb
Alright thanks for the details. I didn't use filter but search and I made sure the views path matched but I'm still getting the error in the SO post.
Would be useful if you someday get your hand on your repo so I can compare more precisely ;)
Could be many things. Your form is also missing the
data-remote="true"
That is needed and you're submitting with a button. Different from what I have. When I can I'll have a look.No it's just that since rails 6.1 you need to put
local: false
to submit via Ajax on theform_with