👋
Welcome to TurboReflex
reactive programming model for Turbo Frames.
TurboReflex enhances theTable of Contents
Why TurboReflex?
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
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!
Sponsors
Proudly sponsored by
Dependencies
-
rails
>=6.1
-
turbo-rails
>=1.1
-
@hotwired/turbo-rails
>=7.1
-
ruby
>=2.7
Setup
-
Add the TurboReflex dependencies
# Gemfile gem "turbo-rails", ">= 1.1", "< 2" +gem "turbo_reflex", "~> VERSION"
# package.json "dependencies": { "@hotwired/turbo-rails": ">=7.2", + "turbo_reflex": "^VERSION"
Be sure to install the same version of the Ruby and JavaScript libraries.
-
Import TurboReflex in your JavaScript app
# app/javascript/application.js import '@hotwired/turbo-rails' +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>
Usage
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
Reflex Triggers
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]'])
Lifecycle Events
TurboReflex supports the following lifecycle events.
-
turbo-reflex:start
- fires before the reflex is sent to the server -
turbo-reflex:finish
- fires after the server has processed the reflex and responded -
turbo-reflex:error
- fires if an unexpected error occurs
Targeting Frames
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>
Working with Forms
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, data: { turbo_reflex: "ExampleReflex#work" } do |form| %>
...
<% end %>
<% end %>
<%= turbo_frame_tag dom_id(@post) do %>
<%= form_for @post, remote: true, data: { turbo_reflex: "ExampleReflex#work" } do |form| %>
...
<% end %>
<% end %>
<%= form_with model: @post,
data: { turbo_frame: dom_id(@post), turbo_reflex: "ExampleReflex#work" } do |form| %>
...
<% end %>
Server Side Reflexes
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/reflexes
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 reflex -
controller
- the Rails controller processing the HTTP request -
turbo_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
Appending Turbo Streams
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.
Setting Instance Variables
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
Prevent Controller Action
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.
Broadcasting Turbo Streams
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.
Putting it All Together
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.
Running Locally
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
Running in Docker
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.
License
The gem is available as open source under the terms of the MIT License.
Todos
- [ ] 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
Releasing
- 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