Stimulus Discourse

Live search without the entire page reload, just the partial

I’m wondering how Basecamp pulled this off without the use of JavaScript*. I’m doing a live search but instead of showing a dropdown with results, I’m updating the page itself.

*Javan says:

Say what now? Ok. I’m using Turbolinks to make a GET request which passes a query string to the controller, updates the variable which renders the value:

# Controller
def index
  @customers = Customers.all
  @query = ''

  if params[:q].present?
    @customers = Customer.all.limit(4)
    @query = params[:q]
  end
end
// Simulus controller
[..]

search(event) {
  const query = event.target.value.toLowerCase()
  Turbolinks.visit("/customers?q=" + query)
}

[..]
<!-- Filter section -->
<input 
  type="text"
  data-target="foo.searchinput"
  data-action="keyup->foo#search"
  placeholder="Search"
  value="<%= @query %>"
  autocomplete="off"
/>

<!-- The customers list -->
<%= render partial: 'list', locals: {customers: @customers}  %>

The logic works but here’s what I don’t get:

Turbolinks is fast because it doesn’t reload the page when you follow a link.

What? The entire dom is being replaced. Ok, maybe this happens after I hit the controller. My issue is that the input focus is lost and if I do use js to add autofocus, the cursor goes back to the start of the value. Could someone show me how to get a live search going with the result updating the page while the input is still being focused? Possible? I’m coming from a js-only client (React).

Hey @SylarRuby!

You would just return a fully rendered HTML response with no layout and inject it into the page in a situation like this, rather than completely render a new page for Turbolinks to process. See this recent post for some more talk about remotely fetching some data and dropping it into the page. The gist of it is that in many scenarios it’s possible to have a single controller that can be reused across various use cases involving remotely fetching data. That controller could be as simple as:

remote_partial_controller.js

import { Controller } from 'stimulus'

export default class extends Controller {
  static targets = ['display']

  render(evt) {
    this.displayTarget.innerHTML = evt.detail[0].body.innerHTML
  }
}

For your situation, you would just have your input submit a remote form (the form will be remote by default in Rails) and configure an action that tells RemotePartialController to render the response.

Now, you may have additional logic necessary for the autocomplete search (for instance don’t perform a search until there are at least two characters). In your example, you’re using a FooController with a #search method for this. That controller and the RenderPartialController can happily live side by side here, but if it’s easier or makes more sense, you can easily just take the RenderPartialController#render method and move that into your FooController.

Also, in your FooController, you’ll want to submit the form via Rails.fire(form, 'submit') rather than call Turbolinks.visit. Then have an action that triggers render when a response is returned from the form submission.

I hope that makes sense, I’m happy to go into more depth if necessary. I unfortunately don’t have time to write up a full working example, but if that would be helpful I can whip one up later.

Regarding the quote:

it’s replacing the DOM inline (really just the body) - it’s not actually clearing the current page from memory and loading a new one, as would typically happen when you navigate from page to page in a non-SPA situation. This means it doesn’t have to re-parse any JS or CSS payloads. SPAs have this advantage, too, but SPAs are also tied to an API and you’re rendering a JSON payload on the backend and then rendering HTML on the frontend. With Turbolinks, you skip the JSON, render the HTML on the backend, and assuming you keep your backend fast you’ll realize some of the advantages of a SPA but typically with a lot less code and complexity by just writing code like a non-SPA website.

Hope this helps!

1 Like

Wow. Thanks for the write up. I’ll dissect the information you gave me and see what I can come up with. If I’m stuck, or resolve it, I’ll come back.

Thanks, again.

Ok done that but the innHTML I see is the entire page. How to get the @customers only? My controller action is:

def filtered
  @customers = Customer.first
  respond_to do |format|
    format.js
  end
end

But evt.detail[0].body.innerHTML is the entire page.

Glad you’re making progress, @SylarRuby!

The rest of the page is coming from the layout, so you’ll want to set that to none. You can do this at a controller level, if none of your actions have layouts, or per action. It looks like you might have this in a CustomersController and likely need a layout for #index, #show, etc., so I’ll demonstrate at the action level. Note that because you’re not providing different response types (e.g. HTML, XML, JSON… you’re only returning HTML), there’s no real need to use respond_to, so I’ll simplify that with just a render call.

def filtered
  @customers = Customer.first
  render layout: false
end

That oughta do the trick!

Now, I will recommend that you actually break search out into its own controller rather than overload CustomerController with more methods. It leads to more controllers, but each with less and more focused responsibilities. Here’s a nice write up about it, and it’s how Basecamp approaches things. Once I tried that approach, I never went back.

So with that approach, you might end up with something like:

CustomerSearchController.rb

class CustomerSearchController < ApplicationController
  layout false

  def index
    @customers = Customer.first
  end
end

Obviously, your actual search logic will be more involved, but it just keeps things ridiculously simple. Mix that with some simple Stimulus on the frontend and you have a nice auto-complete search with very little code.

Let us know when you get it working! :call_me_hand:t3:

1 Like

Thanks. I’ll “clean up” later but it’s not working at all. Here’s what I have (the input):

<form
  data-action="ajax:success->customertabs#result"
  data-target="customertabs.form" action="<%= foo_path %>" method="post" data-remote="true">
  <input 
    type="text"
    data-target="customertabs.searchinput"
    data-action="keyup->customertabs#search"
    placeholder="Search"
    class="fn_input"
    autocomplete="off"
    name="search"
    data-remote="true"
  />
  <input type="hidden" name="sort" value="{asn:1,db:123}" />
</form>

That is exactly what I have. Routes:

post '/foo', to: 'customers#foo', as: 'foo'

Controller#action:

def foo
  puts "You got me"
  @customers = Customer.all.limit(1)
  render layout: false
end

I do see the “You got me” logged so I’m hitting the action.

Stimulus:

[..]
result(data) {
  console.log(data)
}

search(event) {
  const query = event.target.value.toLowerCase()
  console.log(query)
  Rails.fire(this.formTarget, 'submit')
}
[..]

When I console logged data I see two results: one with no “document” and one with “document”. The one with “document” returns the full html so Im guessing Im missing something? See image:

Updated!

I had data-remote="true" on the input. I removed it. Now it’s just result without any “document” (no content) as in image, but the first console log.

I got it!!! OMG!!! I needed to put

<%= render partial: 'list', locals: {customers: @customers}  %>

In a file with the same name as the action’s name! Sweet! Works! OMG!! Thank you!!!

Heyyyyy right on, glad to hear it! :tada:

1 Like