Welcome to TurboReflex 👋

Lines of Code GEM Version GEM Downloads Ruby Style NPM Version NPM Downloads NPM Bundle Size JavaScript Style Tests Twitter Follow

TurboReflex enhances the reactive programming model for Turbo Frames.

Table 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 ✨ 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.

  1. Trigger an event
  2. Change state
  3. (Re)render to reflect the new state
  4. 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

Setup

  1. 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.

  1. Import TurboReflex in your JavaScript app

    # app/javascript/application.js
    import '@hotwired/turbo-rails'
    +import 'turbo_reflex'
    
  2. 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.

  1. 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 %>
    
  2. 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
    
  3. (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.

  1. Look for data-turbo-frame on the reflex element

    <input type="checkbox"
      data-turbo-reflex="ExampleReflex#work"
      data-turbo-frame="some-frame-id">
    
  2. 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 Stream TagBuilder
  • 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.

TurboReflex Demos

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

  1. Run yarn upgrade and bundle update to pick up the latest
  2. Bump version number at lib/turbo_reflex/version.rb. Pre-release versions use .preN
  3. Run bin/standardize
  4. Run rake build and yarn build
  5. Commit and push changes to GitHub
  6. Run rake release
  7. Run yarn publish --no-git-tag-version
  8. Yarn will prompt you for the new version. Pre-release versions use -preN
  9. Commit and push any changes to GitHub
  10. Create a new release on GitHub (here) and generate the changelog for the stable release for it