TurboReflex enhances the reactive programming model for Turbo Frames.
Turbo Frames are a terrific technology that can help you build modern reactive web applications. They are similar to iframes in that they focus on features like discrete isolated content, browser history, and scoped navigation... with the caveat that they share their parent's DOM tree.
TurboReflex extends Turbo Frames and adds support for client triggered reflexes (think RPC). Reflexes let you sprinkle β¨ in functionality and skip the ceremony of typical REST boilerplate (routes, controllers, actions, etc...). Reflexes are great for features that ride atop RESTful resources. Things like making selections, toggling switches, adding filters, etc... Basically any feature where you've been tempted to create a non-RESTful action in a controller.
Reflexes improve the developer experience (DX) of creating modern reactive applications. They share the same mental model as React and other client side frameworks.
- Trigger an event
- Change state
- (Re)render to reflect the new state
- repeat...
The primary distinction being that state is wholly managed by the server.
TurboReflex is a lightweight Turbo Frame extension... which means that reactivity runs over HTTP. Web sockets are NOT used for the reactive critical path! π
Proudly sponsored by
- rails
>=6.1
- turbo-rails
>=1.1
- @hotwired/turbo-rails
>=7.1
-
Add the TurboReflex dependencies
# Gemfile +gem "turbo_reflex", "~> 0.0.3"
# package.json "dependencies": { "@hotwired/turbo-rails": ">=7.1", + "turbo_reflex": "^0.0.3"
Be sure to install the same version of the Ruby and JavaScript libraries.
-
Import TurboReflex in your JavaScript app
# app/javascript/application.js +import 'turbo_reflex'
-
Add TurboReflex behavior to the Rails app
# app/views/layouts/application.html.erb <html> <head> + <%= turbo_reflex.meta_tag %> </head> <body> </body> </html>
This example illustrates how to use TurboReflex to manage upvotes on a Post.
-
Trigger an event - register an element to listen for events that trigger reflexes
<!-- app/views/posts/show.html.erb --> <%= turbo_frame_tag dom_id(@post) do %> <a href="#" data-turbo-reflex="PostReflex#upvote">Upvote</a> Upvote Count: <%= @post.votes %> <% end %>
-
Change state - create a server side reflex that modifies state
# app/reflexes/posts_reflex.rb class PostReflex < TurboReflex::Base def upvote Post.find(controller.params[:id]).increment! :votes end end
-
(Re)render to reflect the new state - normal Rails / Turbo Frame behavior runs and (re)renders the frame
TurboReady uses event delegation to capture events that can trigger reflexes.
Here is the list of default events and respective elements that TurboReflex monitors.
change
-<input>
,<select>
,<textarea>
submit
-<form>
click
-*
all other elements
It's possible to override these defaults like so.
import TurboReflex from 'turbo_reflex'
// restrict `click` monitoring to <a> and <button> elements
TurboReflex.registerEvent('click', ['a[data-turbo-reflex]', 'button[data-turbo-reflex]'])
You can also register custom events and elements.
Here's an example that sets up monitoring for the sl-change
event on the sl-switch
element from the Shoelace web component library.
TurboReflex.registerEvent('sl-change', ['sl-switch[data-turbo-reflex]'])
TurboReflex supports the following lifecycle events.
turbo-reflex:start
- fires before the reflex is sent to the serverturbo-reflex:finish
- fires after the server has processed the reflex and respondedturbo-reflex:error
- fires if an unexpected error occurs
TurboReflex targets the closest
<turbo-frame>
element by default,
but you can also explicitly target other frames just like you normally would with Turbo Frames.
-
Look for
data-turbo-frame
on the reflex element<input type="checkbox" data-turbo-reflex="ExampleReflex#work" data-turbo-frame="some-frame-id">
-
Find the closest
<turbo-frame>
to the reflex element<turbo-frame id="example-frame"> <input type="checkbox" data-turbo-reflex="ExampleReflex#work"> </turbo-frame>
TurboReflex works great with Rails forms.
Just specify the data-turbo-reflex
attribute on the form.
# app/views/posts/post.html.erb
<%= turbo_frame_tag dom_id(@post) do %>
<%= form_with model: @post, html: { turbo_reflex: "ExampleReflex#work" } do |form| %>
...
<% end %>
<% end %>
<%= turbo_frame_tag dom_id(@post) do %>
<%= form_for @post, remote: true, html: { turbo_reflex: "ExampleReflex#work" } do |form| %>
...
<% end %>
<% end %>
<%= form_with model: @post,
html: { turbo_frame: dom_id(@post), turbo_reflex: "ExampleReflex#work" } do |form| %>
...
<% end %>
The client side DOM attribute data-turbo-reflex
is indicates what reflex (Ruby class and method) to invoke.
The attribute value is specified with RDoc notation. i.e. ClassName#method_name
Here's an example.
<a data-turbo-reflex="DemoReflex#example">
Server side reflexes can live anywhere in your app; however, we recommend you keep them in the app
directory.
|- app
| |...
| |- models
+| |- reflexes
| |- views
Reflexes are simple Ruby classes that inherit from TurboReflex::Base
.
They expose the following instance methods and properties.
element
- a struct that represents the DOM element that triggered the reflexcontroller
- the Rails controller processing the HTTP requestturbo_stream
- a Turbo StreamTagBuilder
turbo_streams
- a list of Turbo Streams to append to the response
# app/reflexes/demo_reflex.rb
class DemoReflex < TurboReflex::Base
# The reflex method is invoked by an ActionController before filter.
# Standard Rails behavior takes over after the reflex method completes.
def example
# - execute business logic
# - update state
# - append additional Turbo Streams
end
end
It's possible to append additional Turbo Streams to the response in a reflex. Appended streams are added to the response body after the Rails controller action has completed and rendered the view template.
# app/reflexes/demo_reflex.rb
class DemoReflex < TurboReflex::Base
def example
# logic...
turbo_streams << turbo_stream.append("dom_id", "CONTENT")
turbo_streams << turbo_stream.prepend("dom_id", "CONTENT")
turbo_streams << turbo_stream.replace("dom_id", "CONTENT")
turbo_streams << turbo_stream.update("dom_id", "CONTENT")
turbo_streams << turbo_stream.remove("dom_id")
turbo_streams << turbo_stream.before("dom_id", "CONTENT")
turbo_streams << turbo_stream.after("dom_id", "CONTENT")
turbo_streams << turbo_stream.invoke("console.log", args: ["Whoa! π€―"])
end
end
This proves especially powerful when paired with TurboReady.
π NOTE:
turbo_stream.invoke
is a TurboReady feature.
It can be useful to set instance variables on the Rails controller from the reflex.
Here's an example that shows how to do this.
<!-- app/views/posts/index.html.erb -->
<%= turbo_frame_tag dom_id(@posts) do %>
<%= check_box_tag :all, :all, @all, data: { turbo_reflex: "PostsReflex#toggle_all" } %>
View All
<% @posts.each do |post| %>
...
<% end %>
<% end %>
# app/reflexes/posts_reflex.rb
class PostsReflex < TurboReflex::Reflex
def toggle_all
posts = element.checked ? Post.all : Post.unread
controller.instance_variable_set(:@all, element.checked)
controller.instance_variable_set(:@posts, posts)
end
end
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
def index
@posts ||= Post.unread
end
end
Sometimes you may want to prevent normal response handling.
For example, consider the need for a related but separate form that updates a subset of user attributes. We'd like to avoid creating a non RESTful route, but aren't thrilled at the prospect of adding REST boilerplate for a new route, controller, action, etc...
In that scenario we can reuse an existing route and prevent normal response handling with a reflex.
Here's how to do it.
<!-- app/views/users/show.html.erb -->
<%= turbo_frame_tag "user-alt" do %>
<%= form_with model: @user, data: { turbo_reflex: "UserReflex#example" } do |form| %>
...
<% end %>
<% end %>
The form above will send a PATCH
request to users#update
,
but we'll prevent normal request handling in the reflex so we don't run users#update
.
# app/reflexes/user_reflex.html.erb
class UserReflex < TurboReflex::Base
def example
# business logic, save record, etc...
controller.render html: "<turbo-frame id='user-alt'>We prevented the normal response!</turbo-frame>".html_safe
end
end
Remember that reflexes are invoked by a controller before filter. That means rendering from inside a reflex halts the standard request cycle.
You can also broadcast Turbo Streams to subscribed users from a reflex.
# app/reflexes/demo_reflex.rb
class DemoReflex < TurboReflex::Base
def example
# logic...
Turbo::StreamsChannel
.broadcast_invoke_later_to "some-subscription", "console.log", args: ["Whoa! π€―"]
end
end
Learn more about Turbo Stream broadcasting by reading through the hotwired/turbo-rails source code.
π NOTE:
broadcast_invoke_later_to
is a TurboReady feature.
The best way to learn this stuff is from working examples. Be sure to clone the library and run the test application. Then dig into the internals.
git clone https://github.com/hopsoft/turbo_reflex.git
cd turbo_reflex
bundle
cd test/dummy
bin/rails s
# View the app in a browser at http://localhost:3000
Docker users can get up and running even faster.
git clone https://github.com/hopsoft/turbo_reflex.git
cd turbo_reflex
docker compose up -d
# View the app in a browser at http://localhost:3000
You can review the implementation in test/dummy/app
.
Feel free to add some demos and submit a pull request while you're in there.
The gem is available as open source under the terms of the MIT License.
- Consider falling back to the turbo-reflex-frame when a frame can't be identified
- Consider how to best support
link_to
with methods other than GET - Update system tests for new demos
- Add tests for lifecycle events
- Add tests for select elements
- Add tests for checkbox elements
- Add tests for all variants of frame targeting
- Run
yarn upgrade
andbundle update
to pick up the latest - Bump version number at
lib/turbo_reflex/version.rb
. Pre-release versions use.preN
- Run
bin/standardize
- Run
rake build
andyarn build
- Commit and push changes to GitHub
- Run
rake release
- Run
yarn publish --no-git-tag-version
- Yarn will prompt you for the new version. Pre-release versions use
-preN
- Commit and push any changes to GitHub
- Create a new release on GitHub (here) and generate the changelog for the stable release for it