Guide

Installation

Add the gem, run the installer, register one Stimulus controller eagerly — the Rails engine wires up the endpoint and assets for you.

Install#

Gemfile
gem "phlex-reactive"
bundle install
bin/rails generate phlex:reactive:install

The Rails engine automatically:

  • mounts POST /reactive/actionsPhlex::Reactive::ActionsController#create
  • adds the gem's app/javascript to the asset paths
  • auto-pins (and preload: trues) the client controller for importmap apps

The installer (phlex:reactive:install) does the host-app wiring:

  • registers the reactive Stimulus controller eagerly in your entrypoint
  • writes config/initializers/phlex_reactive.rb with the common options

If you'd rather wire it by hand, register the controller once (eagerly — below).

Generators#

# Setup (idempotent)
bin/rails generate phlex:reactive:install

# Scaffold a state-backed component (record-less)
bin/rails generate phlex:reactive:component Counter increment decrement

# Scaffold a record-backed component (signed GlobalID identity)
bin/rails generate phlex:reactive:component Todos::Item toggle rename --record todo

# Custom signed state vars
bin/rails generate phlex:reactive:component Wizard next_step --state step open

The component generator also writes an RSpec spec when your app has a spec/ directory.

importmap-rails (default Rails 7+)#

app/javascript/controllers/index.js
import { application } from "controllers/application"
import ReactiveController from "phlex/reactive/reactive_controller"
application.register("reactive", ReactiveController)
Register eagerly, not lazily
If you lazyLoadControllersFrom, the controller is fetched on first appearance — and a user who clicks immediately after load can fire before it connects, so nothing happens. Eager registration (above) guarantees it's bound before any interaction. The engine already pins and preloads the prebuilt minified build (~22 KB, not the ~106 KB commented source), so this adds no latency.

esbuild / rollup / webpack (jsbundling)#

app/javascript/controllers/index.js
import { application } from "./application"
import ReactiveController from "phlex/reactive/reactive_controller"
application.register("reactive", ReactiveController)

If your bundler can't resolve the gem path, copy the file in. The gem ships a prebuilt minified module (reactive_controller.min.js, ~22 KB vs ~106 KB source) with a linked sourcemap — vendor that:

js="$(bundle show phlex-reactive)/app/javascript/phlex/reactive"
cp "$js/reactive_controller.min.js"     app/javascript/controllers/reactive_controller.js
cp "$js/reactive_controller.min.js.map"  app/javascript/controllers/reactive_controller.min.js.map

…and import ReactiveController from "./reactive_controller".

Importing reactive_controller.js also registers the reactive:visit Turbo StreamAction that powers reply.redirect. The import ReactiveController above covers this; if you vendor the file, make sure it's actually imported (not tree-shaken away) or redirects won't fire.

bun (bun-rails)#

Same as esbuild. Point the import at the gem path or vendor the file.

Requirements#

  • Rails ≥ 7.1
  • Phlex 2 via phlex-rails, with an ApplicationComponent < Phlex::HTML base class that includes the Phlex Rails helpers (dom_id, t, routes, etc.)
  • Turbo ≥ 8 (for morphing) — turbo-rails, with window.Turbo available
  • A <meta name="csrf-token"> in your layout (standard Rails)
  • pgbus (optional, recommended) — the reliable broadcast transport. See Broadcasting and Transport: pgbus to wire it up.

Configuration#

Create config/initializers/phlex_reactive.rb as needed:

config/initializers/phlex_reactive.rb
Phlex::Reactive.base_controller_name = "ApplicationController"   # CSRF + auth + Current
Phlex::Reactive.renderer             = ApplicationController     # app helpers during render
Phlex::Reactive.authorization_errors = [Pundit::NotAuthorizedError]
# Phlex::Reactive.action_path = "/_r/actions"                   # custom endpoint
# Phlex::Reactive.verifier    = ActiveSupport::MessageVerifier.new(ENV["REACTIVE_KEY"])
# Phlex::Reactive.flash_target = "flash"                         # DOM id reply…flash appends into
# Phlex::Reactive.flash_component = MyFlash                      # renders string flashes: new(level:, content:)
# Phlex::Reactive.error_flash  = ->(kind) { "Something went wrong (#{kind})." } # flash on endpoint failures

error_flash turns the failures the endpoint catches — bad token, default-deny, authorization, missing record (the 400/403/404 rescue paths) — into a turbo-stream flash the user sees, at the same status it already returns (statuses never change). The kind argument is one of :tampered, :unknown_class, :not_reactive_class, :forbidden, :not_found. It composes with flash_target/flash_component above.

Observability & diagnostics

Three opt-in knobs help you see what the endpoint and client are doing. None of them change HTTP statuses or leak token/param values.

  • Phlex::Reactive.verbose_errors — diagnostic endpoint error bodies + dropped-param logging (statuses never change). Defaults to Rails.env.local? — on in development and test, off in production.
  • Phlex::Reactive.log_events — one compact line per reactive event (action/render/broadcast) at DEBUG[reactive] Counter#increment ok (3.1ms). Default off. The events fire for your APM regardless (ActiveSupport::Notifications, *.phlex_reactive); this flag only controls the gem’s own log lines.
  • Phlex::Reactive.debug — client debug mode (devtools-lite). When on, every reactive root carries data-reactive-debug="true" and the console groups every dispatch — action, param names (never values), status, stream targets, round-trip ms. Off by default; gate it on Rails.env.development?.

If you change action_path, expose it to the client:

<meta name="phlex-reactive-action-path" content="<%= Phlex::Reactive.action_path %>">

Client page-meta knobs

Two more <meta> tags tune the client runtime. There is no server-side setting for either — they live in your layout head.

<%# Client request timeout (default 30s). A hung request aborts client-side after %>
<%# this window (reactive:error kind "timeout"), so the per-component queue never wedges. %>
<meta name="phlex-reactive-timeout" content="15000"> <%# 15s, in ms %>

<%# Latency simulator — DEVELOPMENT ONLY. Exposes window.PhlexReactive.enableLatencySim(ms) / %>
<%# disableLatencySim() so you can actually SEE pending/optimistic affordances (~5ms locally). %>
<%= tag.meta(name: "phlex-reactive-env", content: "development") if Rails.env.development? %>

A timed-out POST may have succeeded server-side — phlex-reactive never auto-replays, so make retryable actions idempotent. With the latency simulator meta present, toggle it from the browser console: PhlexReactive.enableLatencySim(400) delays every action 400ms (persists to sessionStorage, clears when the tab closes). Without the meta there is no global handle and zero production surface.

Custom param types

Register your own coercion for action ..., params: in the initializer. The block gets the raw client value and returns the coerced value, or Phlex::Reactive::ParamSchema::DROP to reject it (the keyword default then applies — the drop-don’t-fabricate contract).

config/initializers/phlex_reactive.rb
Phlex::Reactive.param_type(:money) do |value|
  /\A\d+(\.\d{1,2})?\z/.match?(value.to_s) ? BigDecimal(value) : Phlex::Reactive::ParamSchema::DROP
end

# then, in any component:
#   action :charge, params: { amount: :money }
#   def charge(amount:) = @invoice.charge!(amount) # a BigDecimal, or unset
Register during boot only
The registry is frozen after initialization (the engine’s after_initialize calls freeze_param_types!), so a runtime param_type call raises. Declare every custom type in the initializer.

Verify it works#

Run the doctor — it validates the whole install (route, Stimulus registration, CSRF, the identity verifier, and every component) and prints ✓/✗/? with a fix for each failure:

bin/rails phlex_reactive:doctor

Then drop a counter on any page, click +, and watch it increment with no full-page reload. If it reloads or does nothing, check the testing guide troubleshooting section.

The doctor is read-only and safe to run anywhere. A points at the exact fix; a ? is advisory (e.g. it can’t confirm csrf_meta_tags in a Phlex-only layout). No full-page reload on the click is the runtime signal everything is wired.