Turbo Streams
Table of Contents
:ID: a9825c81-8f3b-42f7-acb8-f5fb4ee359b9
Turbo streams are part of the Turbo library, which itself is part of Hotwire. The idea with turbo streams is to have the server render some HTML, but the frontend handle that HTML response in some declared way (eg, the action). The actions, which are preformed with reference to a target DOM element (referenced by the element’s ID), can be:
- append
- prepend
- replace
- update
- remove
- before
- after
A turbo stream can either be a response from the server, or pushed to the frontend client over web sockets. Either way, the outcome is an update to a part of the DOM.
The mechanics are well explained in the docs, but it wasn’t completely clear to me on how this is integrated with Rails via the turbo-rails gem.
See also Hotwire rails
# A turbo stream request
How does the front end send an HTTP request to the sever indicating that it
wants a turbo stream response? This is done by setting the Accept
header to
text/vnd.turbo-stream.html
curl --request GET \ --url http://localhost:3000/messages/new \ --header 'Accept: text/vnd.turbo-stream.html'
In order to have the Rails frontend perform HTTP requests with the turbo
stream format say for a link (GET request), set the tubo_stream
data property.
link_to 'New Message', new_message_path, data: { turbo_stream: 'true'}
For POST requests, this is done automatically.
# The turbo stream response
The controller should be configured to handle a turbo stream format.
def new @message = Messages.new respond_to do |format| format.turbo_stream end end
This will implicitly render the view template
app/views/messages/new.turbo_stream.haml
within which the turbo_stream
tag
helper can be used to generated the expected turbo frame HTML. See
Turbo::Streams::TagBuilder for a dive into the implementation.
= turbo_stream.prepend 'messages' do = render MessageFormComponent.new(message: @message)
Any of the above actions can be used, called as a method on the TagBuilder
object. The argument, messages
, is the DOM element ID that should be
targeted, in this case the container to which the message form should be
prepended.
# turbo-stream tag
The server will render the following with help from the turbo_stream
tag
helper:
<turbo-stream action="prepend" target="messages"> <template> <form action="/messages" accept-charset="UTF-8" method="post"> <!-- etc, etc, etc --> </form> </template> </turbo-stream>
The key things are:
- The
turbo-stream
tag which declares both the action (see list above) and the target (the DOM id on which the action is applied) - A
template
tag.
# Rendering inline with view components
View components can be rendered from the controller for turbo stream
requests instead of defining the new.turbo_stream.haml
template.
def new @message = Message.new respond_to do |format| format.turbo_stream do render( turbo_stream: turbo_stream.prepend('messages', MessageFormComponent.new(message: @message).render_in(view_context)) ) end end end
See also
# Deletes
Deletes can be handled also by setting data attributes on a link, optionally with a confirmation prompt.
= link_to 'Delete', @message, data: { turbo_method: 'delete', turbo_confirm: 'Are you sure?' }
What does the sever respond with? We can keep it simple with an inline render:
def destroy message = Message.find(params[:id]) message.destroy respond_to do |format| format.turbo_stream do render turbo_stream: turbo_stream.remove(message) end format.html { redirect_to messages_path } end end
This action will respond with the following markup, form which turbo will
remove the DOM element with id message_4
by returning an empty, templateless,
turbo-frame
element.
<turbo-stream action="remove" target="message_4"></turbo-stream>
# What about redirects?
We might just want to redirect instead, even conditionally. If we’re reusing view components or partials, we might have the ability to delete from an index page and from a show page. It doesn’t make sense to return a turbo stream that removes elements on the show page since the resource is being deleted.
def destroy message = Messages.find(params[:id]) message.destroy respond_to do |format| format.turbo_stream do if request.referrer == messages_url render turbo_stream: turbo_stream.remove(message) else redirect_to messages_path, status: :see_other, notice: 'Message was deleted' end end format.html { redirect_to messages_path } end end
NOTE the status see_other
(303) which is used in response to a PUT or POST
where the redirect is meant to be a GET. See also https://github.com/hotwired/turbo/issues/84
# turbo_stream_from (turbo streams over WebSocket connection)
This method is used in the view templates to generate a
turbo-cable-stream-source
HTML tag with a couple data attributes describing
the channel and stream.
= turbo_stream_from current_user, 'posts'
The above generates: (See also https://turbo.hotwired.dev/handbook/streams#integration-with-server-side-frameworks)
<turbo-cable-stream-source channel="Turbo::StreamsChannel" signed-stream-name="verylongsignedstring" ></turbo-cable-stream-source>
The Turbo::StreamsChannel
ActionCable channel is dedicated to turbo streams.
The stream name is signed based on the “streamables” (eg, current_user and
’posts’) for a bit of security. The streamables can include multiple objects
to narrow scope of the stream. Include the a user object to setup a stream
for a particular user’s posts
Now to broadcast a message, you can do this from the code:
Turbo::StreamsChannel.broadcast_prepend_to([user, 'posts'], target: 'posts', content: 'HELLO')
There’s a helper method for each turbo stream action that wraps the generic
broadcast_action_to
method. See also:
- https://github.com/hotwired/turbo-rails/blob/main/app/channels/turbo/streams/broadcasts.rb
- https://www.driftingruby.com/episodes/broadcasting-progress-from-background-jobs
NOTE: instead of content
one can use partial
and point to the partial path.
# with view_component components
There are several approaches to this, all kind of hacky. See also https://github.com/ViewComponent/view_component/issues/1106
Turbo::StreamsChannel.broadcast_action_to( [user, 'posts'], target: 'post_20', content: ApplicationController.render(component, layout: false), action: :replace )
Might be helpful to wrap this in a function
module ViewComponentBroadcaster module_function def call(streamables, component:, **options) Turbo::StreamsChannel.broadcast_action_to( streamables, target: component.id, content: ApplicationController.render(component, layout: false), **options ) end end ViewComponentBroadcaster.call([user, 'posts', component: PostComponent.new(post:), action: :replace)
# Programatically submitting a Turbo form with JS
Use requestSubmit
instead of submit
which does not emit a submit event that
Turbo is listening for.
https://stackoverflow.com/a/69537709
# TurboStream request with fetch
const body = JSON.stringify({ ... }); const headers = { 'Content-Type': 'application/json', Accept: 'text/vnd.turbo-stream.html', 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content } const response = await fetch('/foo', { method: 'POST', body, headers }) const responseBody = await response.text() // Option 1: use Turbo.renderStreamMessage(responseBody) // Option 2: If option 1 isn't an option, manually handle the response: const tempTemplate = document.createElement('template') tempTemplate.innerHTML = responseBody const content = tempTemplate.content.querySelector('template').content // Now do whatever with the response content