Triggering Turbo Frame with JS

While playing around with Turbo (so cool) I was trying to build a simple way to filter a list while typing. The way I accomplished this was:

  1. inside of a Turbo Frame I put the search field in a form that points to a Rails controller action. The Rails controller action searches the db and filters based on the search param.
  2. the controller detects that the request format is turbo_stream and renders a turbo_stream template with all the filtered results
  3. a separate results div is replaced with the turbo_stream template that was returned in the response

In order to make this work while typing I:

  1. hide the submit button
  2. using stimulus, on input into the search field, send click to the hidden submit button: this.submitTarget.click()

It works like a charm, is small, very reusable, and really quick. While I would prefer not needing to add (and then hide) a submit button, it is not a really big deal because I can easily hide it when the Stimulus controller connects to the form (even though this adds a quick flicker). Still I was wondering if there was another (or better) way to trigger Turbo in JS without having to send click to the submit button element. I tried sending submit to the form with JS, but it didn’t get intercepted by Turbo or get the proper Accept header added to it.

Thanks!!

6 Likes

Hey! I don’t know about programmatically invoking Turbo using JS code, but I think that doing so kind of gets into what I think is considered the “internals” of Turbo. I think its best to interact with Turbo by pinging the server like you’re doing. I may be wrong about this, but just based on what’s out there right now, that’s the impression I’m getting.

That said, I don’t think you need to hide and fire a submit action on a button. I believe you should be able to just listen to the input event on the text field and then fire an AJAX request to the URL that renders the Turbo Stream. You’d probably need a way to limit the rate at which the response fires (debounce or throttle, I forget which one works best here), but I imagine that should work okay.

Matt Swanson did something similar with dropdowns and checkboxes using Stimulus and the old Turbolinks. You can see his article here: https://boringrails.com/articles/better-stimulus-controllers/

Obviously you wouldn’t make a call to Turbolinks like he did, but it’s a similar approach. You’d want to issue a classic AJAX request instead.

1 Like

I’ve been playing with similar functionality (autocomplete search form) and honestly I can’t see what Turbo frames or streams bring to the table instead of doing a simple AJAX query inside a Stimulus controller that returns an HTML fragment.

At the end of the day, you totally could do this. I guess that’s the whole point of Turbo, abstracting the process of sending HTML over the wire.

I’m definitely having a good time removing lots of AJAX/JS boilerplate for simple actions e.g. “click this button to subscribe, then change the button text to unsubscribe” using frames and/or streams, but Stimulus works just fine for when you need something more intricate.

1 Like

Thanks for the input @jacobdaddario. I think you are right in that its best to interact with Turbo without digging in too deep.

The reason I wanted to see if I could avoid classic AJAX setup is because the functionality that submits a form, reads the response, and auto populates it is built in. I was just curious how to utilize Turbo to save the time creating the requests and injecting their responses back into HTML. To be honest, apart from search boxes Im not sure its what I am trying to figure out is significant. Turbo Frames and Streams offer enough to accomplish so much as is. Its more of a curious itch.

I have been playing around with creating an XHR response from within Stimulus on keydown that returns a TurboStream response but Turbo isn’t picking it up and injecting it, Im guessing its because the request wasn’t originated by Turbo.

1 Like

Keydown event would work well! Are you sure you’re hitting the right route on the server and that it’s returning a response with mime type text/html; turbo-stream?

I think for streams all you need is a request hitting the server and a div that the turbo stream can target.

This is something that perhaps will become clearer with more usage and documentation (and examples): when to use a frame and when to use a stream. For example, form validation : use a stream; isolated single AJAX updates, use a frame; continual updates (e.g. from a websocket or, in this case, autocomplete/typeahead) use a stream. And, of course, when neither fits the bill and you are better dropping down to Stimulus/JS.

1 Like

What I’m seeing is when I trigger a GET from an AJAX query (i.e. calling fetch() or axios.get() in your code) inside a Stimulus controller, which returns a valid turbo-stream package (correct tags and content type) it doesn’t work. However if you call Turbo.visit() it works fine. The problem is that calling visit(), even if it returns a stream, also changes the URL in the location bar, which you don’t want to do for an autocomplete (e.g. if your autocomplete URL is /search?q=something this gets pushed to your history). Frames don’t appear to work either: they can only be triggered by a manual form submit or link navigation. The best solution I think would be to have a Turbo.stream() function that expects a turbo-stream result, but does not change your history unlike Turbo.visit().

3 Likes

Could you post a sample of your stream fragment? I’m curious to see what the fragment looks like, because to my knowledge the classic AJAX request should work. Unless I’m totally missing something.

With my family right now, but once the holiday winds down I can take a look at this myself and try to spin up my own example.

EDIT: I was totally missing something. Check the below responses discussing how turbo-stream intercepts form submissions.

Yeah, the response type is correct. I added two forms on a page, both within Frames, that post to the same route. One sends the AJAX request on key up, and the other that submits using a submit button. That way I always have a confirmed working example on the page as I try different things out in the Stimulus controller. I have also updated the headers in the XHR to match the ones Turbo Frames adds to the framed form request.

As @danjac-2020 said, calling visit works fine, but it adds the URL to the location bar. Ive been looking through the inner workings of Turbo to get an idea for what normally happens. I think it intercepts normal link clicks and form submissions, creates its own requests and listens for their response (but I can totally be wrong, my fiancé doesn’t want me on the computer today so progress is slow :joy:). Much to explore!

Hey - so I am working on a project where I wanted to trigger a turbo-frame refresh programmatically as well. I did some serious source code diving and hopefully I can help!

With regards to the programmatic form submission:

Turbo intercepts form submission events, but weirdly, the JS formElement.submit() method does not trigger the submit event. Confusing for sure - but the MDN docs lay it out:

The event is not sent to the form when calling the form.submit() method directly.

You can see that it’s looking for this event in the FormInterceptor class, (in the turbo source).

Programmatically triggering a click on a button in the form is a good workaround to ensure the submit event is fired, however, you can simply use dispatchEvent to achieve the same thing:

this.formTarget.dispatchEvent(new CustomEvent('submit', { bubbles: true }))

I can confirm that this does work and does not require any hidden buttons :slight_smile: (might want to confirm browser compatibility though - my project only targets the latest browsers).


With regards to the autocomplete, wouldn’t it work to use a little bit of stimulus & a turbo-frame like this?

<div data-controller="autocomplete">
  <form method="get" action="search" data-turbo-frame="search-results" data-autocomplete-target="form">
    <input name="q" data-action="keyup->autocomplete#search">
  </form>
  <turbo-frame id="search-results">
    <div class="result">...</div>
    <div class="result">...</div>
  </turbo-frame>
</div>
// autocomplete_controller.js
export default extends Controller {
  static targets= ['form']

  search() {
    this.formTarget.dispatchEvent(new CustomEvent('submit', {bubbles: true}))
  }
}

I think you could also add in debouncing to reduce server strain. While it’s marvelous how simple this is from a client-side implementation perspective - I think autocompletes are fraught with edge-cases, what if one of the search queries arrives out of order and such… Not that those problems aren’t solvable, but they’re certainly ignored here.


I’m also trying to wrap my head around when it makes the most sense to use a turbo frame and turbo streams. You could clearly do this with a turbo stream as well. Would be curious to see if there’s clear advantages to that approach over this one!

9 Likes

This is a good solution, but I’m wondering what the advantage is here vs doing a straightforward fetch() to return an HTML fragment, given you have to use a Stimulus controller anyway?

I’m still exploring the uses of frames/streams, but I think they work best when you can perform the action with a simple form/link (e.g. wrap a toggle button in a form that returns a frame with updated content, or using streams to handle form validation) or when using WebSockets to return partial updates. If you need anything more complicated then use Stimulus. Which is totally fine - I think we just need more documentation and examples around the typical use cases.

1 Like

There it is. That little tidbit threw off my entire approach to solving this. Thanks for clearing that up! I should’ve double checked the doc myself but it seemed so obvious that I didn’t consider it.

1 Like

Hey! So it looks like @mgodwin beat me to a punch on a couple of things! First things first, I uploaded a test project with this functionality to my GitHub account. You can find it here. Even if this isn’t useful now, I wanted to drop a link to it for anyone who may stumble across this Discourse thread in the future.

That said, @mgodwin already nailed the tricky part. It turns out that the turbo-stream mechanism listens for form submission events, and for some reason the submit() function does not emit a form submission event. That means that it’ll bring back a normal HTML response. That said, it looks like there’s another method, requestSubmit() which does issue a submit event. Weird stuff from JavaScript land. Consequently, you can write your controller and html in the following fashion:

<!-- app/views/books/index.html.erb -->
<%= form_tag('/search', method: :get, data: { controller: "search" }) do %>
  <%= text_field_tag :search, params[:search], data: { action: "input->search#findResults" } %>
<% end %>
<div id="books"></div>
// app/assets/javascripts/controllers/search_controller.js

import { Controller } from "stimulus"

export default class extends Controller {
  findResults(event) {
    var searchValue = event.target.value;

    this.element.requestSubmit();
  }
}
# app/controllers/search_controller.rb
class SearchController < ApplicationController
  def search
    @books = Book.search_title(params[:search])

    respond_to do |format|
      format.turbo_stream
      format.html         { redirect_to root_url(search: params[:search]) }
    end
  end
end
<!-- app/views/search/search.turbo_stream.erb -->
<turbo-stream action="update" target="books">
  <template>
    <ul>
      <% @books.each do |book| %>
        <%= content_tag :li, book.title %>
      <% end %>
    </ul>
  </template>
</turbo-stream>

Like @mgodwin mentioned, you could add a debounce function like is mentioned in this thread. That said, I discovered a couple of interesting things while working on this toy app that I wanted to document here.

First, controllers being moved into the assets directory makes it harder to add packages like lodash to help with utility functions like debounce. Yarn package management seems to still be tied to Webpack, which is kind of frustrating. I wonder if there’s a particular reason that the controllers have been moved back into the asset folder in the stimulus-rails integration? If the asset pipeline had access to JavaScript package management, this would probably be a welcomed change by many.

Next, I originally tried to erroneously trigger a turbo-stream response using fetch() to send a get request to the server. As we’ve already learned, Turbo listens purely for the submit event, meaning it requires a form to be submitted. That said, when I was using this approach it surprisingly was still returning turbo-stream fragments to the server, it just wasn’t injecting them. I imagine the behavior of Turbo just listening for submit events is intentional in order to retain the spirit of semantic HTML and progressive enhancement. That said, I’m curious as to what causes a fetch request to return a turbo-stream response.

Finally to reply to @danjac-2020 , I think the big thing with turbo-streams is to provide a standard approach to sending HTML fragments over the wire to be injected into the DOM. I’ve done some toying around with HTML fragments over the wire just using fetch requests and Stimulus prior to the release of Turbo, and I ran into two big problems.

  1. Inserting HTML outside of a Stimulus controller gets kind of hacky. You can’t assign something outside of a controller as a target, which means you need to use a typical getElementById() call. This totally works, but it feels out of place in a Stimulus controller which has the nicely organized target system.

  2. Adding multiple fragments of HTML to the page in response to one request is really hard. Or at least in my experiments it was a struggle. Let’s say that you have the user submitting a form, and on a validation error you want to inject inline errors and a toast message into the DOM. With a normal fetch request, the server’s response comes back as a single HTML file that you have to break up into parts and inject into the page. With turbo-streams, you can inject multiple fragments into different parts of the page in a single request, with no added overhead on your part. All you have to do is ping the server with a form submission and have a turbo-stream response with multiple fragments.

Turbo seems to be the logical conclusion of the original Turbolinks. The original idea is still there as turbo-drive, but now there is also context-specific navigation using turbo-frames and a standard way to inject HTML into an existing DOM using turbo-drive. Additionally, it avoids dependence on websockets unlike a solution like StimulusReflex. I really enjoyed working with it today, and if there’s any way that we could contribute to the docs, I’d love to help flesh them out.

3 Likes

Re form validation: that’s one place turbo-streams works well.

Re debounce: I use stimulus-use (has lots of other nice bits of functionality e.g. for communicating between controllers) which has debounce and throttle functions. Of course it’s another package to install, but worth it - https://github.com/stimulus-use/stimulus-use.

Interesting re requestSubmit() and @mgodwin’s suggestion: I have a test project with autocomplete here https://github.com/danjac/movieapp/ and might try this out, thanks both.

2 Likes

Wow, did not know about requestSubmit()! I’d say that’s potentially superior to the custom event bubbling that I mentioned in my post.

RE - package management in the stimulus-rails repo, DHH calls out using browser based ESM as very intentional:

Stimulus for Rails makes it easy to use this modest framework with the asset pipeline and ES6/ESM in the browser. It uses the 7kb es-module-shim to provide importmap support for all ES6-compatible browsers. This means you can develop and deploy without using any bundling or transpiling at all! Far less complexity, no waiting for compiling.

I for one am really excited about this approach - one of the biggest upsides being that you don’t have to use webpack at all :confetti_ball: . Also called out in the docs is that you can import other modules (if you need) using skypack.dev. It won’t work for every project, but you have an escape hatch back to webpack/er if you need. For many projects, I suspect that this will be more than sufficient though.

RE: what causes fetch to return turbo_stream responses - I’m fairly certain that there is an accept header added to the fetch request which allows rails to do content-type negotiation and target those requests specifically in the controller. Of course, that will only send down the markup, you then need to inject the markup client-side. It looks like you can use Turbo.connectStreamSource if you wanted to go totally manual mode (which is used under the hood, I believe), but I haven’t really found a reason to use it directly yet. Just using form submission & WS seems more idiomatic and sufficient.

1 Like

I use Turbo with Django, so had to do a bit more investigation in how the server side works without much insight into Rails. There is indeed an Accept header passed back, and you need to respond with the correct content type (text/html; turbo-stream;) but neither are sufficient to get fetch() to play nice - one problem I have is sending updates generated from HTML media element events, so using a form submission is not really an option. Of course I use fetch(); but then I’m handling low level HTML changes in Stimulus. It works, but it could be a lot more elegant if I could use streams.

Honestly, it feels hacky to manually trigger forms or hidden buttons to get this functionality and I can imagine it becoming a source of bugs if abused in a large code base. I think a better option would be to have a Turbo.stream() function that works like visit().

2 Likes

Regarding the hidden button approach, I initially did it as a test to better understand what was working and what wasn’t so it was more of a starting point, very hacky. I agree that using it in production would be a bad idea. For what I was trying to figure out (submitting a form with JS while still taking advantage of either Frames or Streams), requestSubmit() was exactly what I was looking for.

I am still digging through the source cause I find it very interesting. I’ll share anything that may be relevant. Please do the same, I’d be interesting in hearing what you figure out.

1 Like

I’m stuck on this same problem. And for some reason I can’t find the method requestSubmit. It doesn’t even exist in the hotwired/turbo repo. Where can I find it?