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/actions → Phlex::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)
Eager, or lazy with phlex/reactive/early
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. To load it lazily instead, add import "phlex/reactive/early" to your entry point: that module (under 1.5 KB gzipped, pinned and preloaded by the engine) queues an on(...) or on_client(...) trigger that fires before the controller connects, and the controller replays it on connect. That includes a window: hotkey pressed anywhere on the page: it is never prevented (a shortcut: chord is), and it is replayed only if the controller connects within 1.5 s of the keypress (or the early-event TTL, if that is shorter). Two limits: a captured link or form trigger has its native behavior stopped while it waits, so it does nothing if the controller never loads; and other controller actions (nested rows, tags, list navigation, compute) and outside: triggers are not captured.

The same module lets a root cost nothing until it is used: reactive_dormant (or reactive_root(dormant: true)) renders the root without data-controller="reactive", so a lazily loaded controller is not fetched until one of the root's triggers fires. See Dormant roots on the performance page.

esbuild / rollup / webpack (jsbundling)#

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

The gem ships a prebuilt minified module (reactive_controller.min.js, the whole client in one file, about 25 KB gzipped) with a linked sourcemap. It imports three small modules by their bare names: phlex/reactive/confirm, phlex/reactive/confirm_predicate and phlex/reactive/compute, the seams an app can override. Point your bundler at the gem with one prefix alias for phlex/reactive:

esbuild.config.mjs
import * as esbuild from "esbuild"
import { execSync } from "node:child_process"
const gemJs = `${execSync("bundle show phlex-reactive").toString().trim()}/app/javascript`

await esbuild.build({
  // …
  plugins: [{
    name: "phlex-reactive",
    setup(build) {
      build.onResolve({ filter: /^phlex\/reactive\// }, ({ path }) => ({ path: `${gemJs}/${path}.min.js` }))
    },
  }],
})

The same alias covers the opt-in split client (phlex/reactive/core, a smaller controller that imports its feature modules on demand); add splitting: true, format: "esm" so each feature becomes its own chunk. See What loads when on the performance page before you opt in.

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.

Running under Falcon#

Falcon serves each request as a fiber on one thread. Rails keys its per-request state on config.active_support.isolation_level, which defaults to :thread, so under Falcon every thread-keyed value is shared by the requests that thread serves: ActiveSupport::CurrentAttributes, IsolatedExecutionState, thread variables (Thread#thread_variable_get), and the lock on the connection transactional tests pin (two overlapping requests wedge the server). Set it to :fiber for an app served by Falcon:

config/application.rb
config.active_support.isolation_level = :fiber

phlex-reactive keeps its own request state in fiber-local storage, so the gem works either way; this setting protects the app around it. Puma serves a thread per request and needs nothing. bin/rails phlex_reactive:doctor fails when Falcon serves the app under :thread, and is advisory when Falcon is bundled beside another server and is not the only server loaded: it sees every server in the bundle, but a rake task loads only the ones the Gemfile auto-requires, so it cannot tell which one runs.

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 = ->(level, content) { MyFlash.new(level:, content:) } # renders string flashes
# 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.