DEV Community

Daveyon Mayne 😻
Daveyon Mayne 😻

Posted on

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
  • 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

[..]
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

Partials

Inside views/shared/_customer_row.html.erb:

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

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)
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Routing

Inside routes.rb

[..]

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

[..]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}  %>
Enter fullscreen mode Exit fullscreen mode

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>
[..]
Enter fullscreen mode Exit fullscreen mode

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>'
}

[..]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
oliwoodsuk profile image
oliwoodsuk • Edited

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.

Collapse
 
mirmayne profile image
Daveyon Mayne 😻

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.

Collapse
 
oliwoodsuk profile image
oliwoodsuk

Yeah, pretty sure your right actually. Ah yep, good idea. I think I'll do just that.

Collapse
 
maxencep40 profile image
MaxencePautre • Edited

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

Collapse
 
mirmayne profile image
Daveyon Mayne 😻

Hi @maxencep40 . What are you referring to exactly? An example?

Collapse
 
maxencep40 profile image
MaxencePautre

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 ?

Thread Thread
 
mirmayne profile image
Daveyon Mayne 😻 • Edited

Ok. I use post because I'm sending data to the backend. You use get when you "want" something only (nothing to send in the body request). The key part is the method name: filter. Ensure the name filter 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
Thread Thread
 
maxencep40 profile image
MaxencePautre

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 ;)

Thread Thread
 
mirmayne profile image
Daveyon Mayne 😻 • Edited

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.

Thread Thread
 
maxencep40 profile image
MaxencePautre • Edited

No it's just that since rails 6.1 you need to put local: false to submit via Ajax on the form_with