phlex-reactive
Reactive Phlex components for Rails โ Livewire-style actions and live cross-tab updates, without writing Stimulus controllers or hand-picking Turbo Stream targets.
๐ Full documentation
class Counter < ApplicationComponent
include Phlex::Reactive::Streamable
include Phlex::Reactive::Component
reactive_state :count
action :increment
action :decrement
def initialize(count: 0) = @count = count
def id = "counter"
def increment = @count += 1
def decrement = @count -= 1
def view_template
div(id:, **reactive_attrs) do
(**on(:decrement)) { "โ" }
span { @count }
(**on(:increment)) { "+" }
end
end
end
That's the whole counter. No Stimulus controller. No .turbo_stream.erb. No
route. No hand-picked target. Click + and the count updates in place.
Why
Stimulus + Turbo are powerful but tedious. A single interactive widget means a
Stimulus controller, a data-* soup, a .turbo_stream.erb view, a controller
action, and a hand-picked dom_id target โ repeated for every feature. The
mental model is "wire everything by hand."
phlex-reactive borrows the mental model that makes Livewire and Phoenix LiveView pleasant โ a component has state and actions; change state and the UI follows โ and implements it the Rails way:
- Actions are Ruby methods. Declare
action :increment; the client calls it. - Re-render is auto-targeted. A component owns a stable
id; the response is a<turbo-stream>that replaces it. You never pick a target. - The same unit re-renders for clicks AND broadcasts. A click and a background broadcast both produce "replace the component by its id," so live cross-tab updates are the same mechanism as local interactivity.
- State lives in your database, not the browser. The DOM carries only a signed identity (a record's GlobalID), not a snapshot of state โ so there's no mass-assignment surface and no re-signing protocol.
- One tiny client runtime. A single generic Stimulus controller, registered once, handles every reactive component. You don't write per-feature JS.
Pair it with pgbus and your live updates become transactional (no broadcast for a rolled-back change) and reconnect-safe (missed messages replay) over Postgres SSE โ no Action Cable, no Redis.
Installation
# Gemfile
gem "phlex-reactive"
bundle install
Then run the installer โ it registers the client controller and writes a config initializer:
bin/rails generate phlex:reactive:install
That's all for importmap apps: the engine mounts the action endpoint at
/reactive/actions and auto-pins (and preloads) the client runtime, and the
installer adds the eager registration below to your Stimulus entrypoint.
What the installer wires (or do it by hand)
```js // 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) so a click immediately after load is never missed.Scaffold a component
# state-backed (record-less)
bin/rails generate phlex:reactive:component Counter increment decrement
# record-backed (signed GlobalID identity)
bin/rails generate phlex:reactive:component Todos::Item toggle rename --record todo
Generates the component (and an RSpec spec if your app uses RSpec).
esbuild / webpack / bun
Import and register it from your controllers entrypoint: ```js import { application } from "./application" import ReactiveController from "phlex/reactive/reactive_controller" application.register("reactive", ReactiveController) ``` The JS ships at `app/javascript/phlex/reactive/reactive_controller.js` in the gem; point your bundler at the gem path or copy it in. See [docs/installation.md](docs/installation.md).Requirements: Rails 7.1+, Phlex 2 (phlex-rails), Turbo 8+ (for morphing),
and a Phlex ApplicationComponent base class. pgbus is optional but recommended
for broadcasting.
Integration troubleshooting (silent "nothing happens")
Two host-app setups make the first reactive component silently do nothing โ components render, but no action ever fires, with no error pointing at the cause. The gem now logs a warning for each, but here are the fixes:
A catch-all route shadows POST /reactive/actions. The engine appends its
route after everything in your config/routes.rb, so a bottom-of-file
catch-all wins and every reactive POST 404s:
# config/routes.rb โ a catch-all like this shadows the engine's appended route
match "*path", to: "errors#not_found", via: :all
Exempt the reactive path from the catch-all (or set
Phlex::Reactive.action_path to an unshadowed path):
match "*path", to: "errors#not_found", via: :all,
constraints: ->(req) { !req.path.start_with?("/reactive/") }
At boot the gem warns ([phlex-reactive] POST /reactive/actions does not resolve
to phlex/reactive/actions โฆ) when the route is shadowed.
The reactive controller isn't registered (lazyLoadControllersFrom apps).
lazyLoadControllersFrom("controllers", application) only registers controllers
under app/javascript/controllers/. The gem's controller lives outside that dir,
so data-controller="reactive" does nothing until you register it explicitly:
// app/javascript/controllers/index.js (or your Stimulus entrypoint)
import ReactiveController from "phlex/reactive/reactive_controller"
application.register("reactive", ReactiveController)
If reactive elements are on the page but the controller never connected, the
runtime logs a console warning ([phlex-reactive] found N element(s) with
data-controller="reactive" but the reactive controller never connected โฆ).
The mental model in one picture
โโโ click / input โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ โผ
[ button(**on(:increment)) ] POST /reactive/actions { token, act, params }
โฒ โ
โ verify signed token (no state trusted)
โ rebuild component (record from DB)
โ run the whitelisted action
โ re-render โ <turbo-stream replace id> (default; an action
โ may return a Response โ see "Controlling the action's reply")
โโโโโโโโโ Turbo morphs it in โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
...and for OTHER tabs/users:
model change โ Component.broadcast_replace_to(stream) โ pgbus SSE โ same morph
Client actions and server broadcasts converge on one re-render unit: the
component, targeted by its id.
Quickstart: a live, cross-tab counter
# app/components/counter.rb โ see the top of this README for the full class
render Counter.new(count: 0)
Open the page in two tabs, click + โ done. To make it update across tabs when
the underlying record changes, use a record-backed component (below).
Two kinds of reactive component
1. Record-backed (the common case)
State lives in an ActiveRecord row. The signed identity is the record's GlobalID; the server re-finds it on each action. Always prefer this.
class Todos::Item < ApplicationComponent
include Phlex::Reactive::Streamable
include Phlex::Reactive::Component
reactive_record :todo
action :toggle
action :rename, params: { title: :string }
def initialize(todo:) = @todo = todo
def id = dom_id(@todo) # stable per-record DOM id == Turbo target
def toggle
@todo, :update? # YOU authorize โ the token only proves identity
@todo.toggle!(:done)
end
def rename(title:)
@todo, :update?
@todo.update!(title:)
end
def view_template
li(id:, **reactive_attrs, class: ("done" if @todo.done?)) do
(**on(:toggle)) { @todo.done? ? "โ" : "โ" }
span { @todo.title }
end
end
end
2. State-backed (signed instance vars)
Sign small, JSON-serializable instance vars into the token. Use it alone for
a record-less widget (a counter, a wizard step), or alongside reactive_record
to carry transient UI state โ which field, what mode โ next to the row. Both the
record's GlobalID and the state are signed into one token and rebuilt on each
action. Keep state small and JSON-serializable.
reactive_state :count, :step # signed; rebuilt on each action
The inline edit example combines both: a
reactive_record :record plus reactive_state :attribute, :editing.
Concrete examples
| Example | What it shows |
|---|---|
| Counter | State-backed, the smallest reactive component |
| Cross-tab chat | Record-backed action + pgbus broadcast โ live sync across tabs/browsers |
| Live todo list | Per-row components, add/toggle/rename/delete, broadcast on change |
| Inline edit | Show โ edit mode toggle, replacing a Stimulus controller + 3 routes |
| Notifications / badges | Pure broadcast (no client action) โ a job pushes a re-render |
The cross-tab chat in ~60 lines of Ruby (and zero JS) is the showcase โ see docs/examples/chat.md.
API reference
Phlex::Reactive::Streamable
| Method | Use |
|---|---|
#id (you implement) |
Stable DOM id == Turbo Stream target. Must match the root element's id. |
.replace(model = nil, **opts) |
<turbo-stream action=replace target=id> of a freshly built component |
.update / .append(target:) / .prepend(target:) / .remove |
The other Turbo Stream actions |
.broadcast_replace_to(*streamables, model:) |
Broadcast a replace over the stream transport (pgbus SSE / Action Cable) |
.broadcast_append_to(*streamables, target:, model:) / _update_ / _prepend_ / _remove_ |
The broadcast variants |
#to_stream_replace / #to_stream_update / #to_stream_remove |
Stream the already-built instance (used internally after an action / by Response) |
Use in controllers: render turbo_stream: Counter.replace(counter).
Phlex::Reactive::Component
| Macro / helper | Use |
|---|---|
reactive_record :name |
Record-backed identity (GlobalID). State = the DB. |
reactive_state :a, :b |
Signed instance-var identity. Standalone, or combined with reactive_record to sign transient UI state alongside the row. |
action :name, params: { x: :integer } |
Declare a client-invokable action + its param schema. Default-deny. |
reactive_attrs |
Spread onto the root element: marks it reactive + carries the signed token. |
on(:action, event: "click", **params) |
Spread onto a trigger element. Adds type=button for clicks. |
on(:action, event: "input", debounce: 300) |
Coalesce rapid events into one round trip after a quiet period (live-as-you-type). |
reactive_input(:param, **attrs) / reactive_select(:param, **attrs) |
Render a control already bound to an action param (no magic name:). |
reactive_field(:param, **attrs) |
The attribute hash behind the above โ spread onto any control. |
nested_update!(:assoc, attrs) |
Map a nested param onto <assoc>_attributes with id preservation; update the record. |
Param types: :string (default), :integer, :float, :boolean. Anything not
in the schema is dropped before reaching your method.
Array & nested params. Wrap a type in an array for an array param, or a hash schema in an array for Rails-style nested attributes โ so one reactive action can mirror a normal nested-attributes update instead of forcing a per-row component:
action :save, params: {
date: :string,
bank_account_ids: [:integer], # array of scalar
invoice_items_attributes: [ # array of hash
{ id: :integer, quantity: :float, price: :float, _destroy: :boolean }
]
}
def save(date:, bank_account_ids:, invoice_items_attributes:)
@invoice.update!(date:, bank_account_ids:, invoice_items_attributes:)
end
Nested coercion recurses per field, drops undeclared nested keys, and accepts an
array as either a JSON array or a Rails index hash ({ "0" => โฆ, "1" => โฆ }).
Model-scoped form fields just work. A standard Rails Form(model: @invoice)
names its inputs invoice[date], invoice[status], โฆ and the client posts those
names verbatim. A nested schema matches them with zero field renaming โ the
endpoint expands bracket notation before coercion, so invoice[date] nests under
invoice and invoice_items_attributes[0][qty] becomes the index-hash form
above:
action :save, params: {invoice: {date: :string, status: :string}}
# client posts { "invoice[date]": "โฆ", "invoice[status]": "โฆ" } โ save(invoice: { date:, status: })
Nested reactive components compose. A reactive component rendered inside
another is its own root โ field collection stops at nested
data-controller="reactive" roots, so an outer action collects only its own
named inputs, never a nested component's. An invoice editor's save sees its
flat fields; each line-item row's quantity/price belong to that row's own
action. No name-disjointness workarounds required.
Debounced triggers (live-as-you-type). Pass debounce: (milliseconds) to
coalesce rapid events โ typically keystrokes on an "input" trigger โ into a
single action round trip fired after the quiet period, instead of one POST per
keystroke. A blur flushes a pending dispatch so the last edit is never dropped.
Omit debounce: for the immediate-dispatch default.
# Recompute a total live as the user types, without hammering the endpoint.
input(**mix(on(:update, event: "input", debounce: 300), name: "quantity", value: @item.quantity))
Combining on(...) / reactive_attrs with your own attributes. Both return
a hash that includes a data: key. Spreading them and passing another data:
(or class:, id:) would clobber it โ use Phlex's mix to deep-merge:
# โ
merges cleanly (data-action survives, your data-testid/class are added)
button(**mix(on(:increment), class: "btn", data: { testid: "inc" })) { "+" }
div(**mix(reactive_attrs, id:, class: "card")) { ... }
# โ the extra data: overwrites on()'s data:, so the action never binds
button(**on(:increment), data: { testid: "inc" }) { "+" }
Binding inputs to action params (drop the magic name:). A field's value
travels with an action only if its name equals the param. Hand-writing
name: "value" on every input is easy to forget โ the action then silently gets
nothing. reactive_input/reactive_select emit the binding for you (the trigger
stays on the button, so focusing the field doesn't dispatch and collapse edit
mode):
action :save, params: { value: :string, status: :string }
def view_template
span(id:, **reactive_attrs) do
reactive_input(:value, value: @record.name) # <input name="value" โฆ>
reactive_select(:status) do # <select name="status">โฆ</select>
%w[open closed].each { |s| option(value: s, selected: s == @record.status) { s } }
end
(**mix(on(:save), data: { testid: "save" })) { "Save" }
end
end
reactive_field(:value, **attrs) returns just the attribute hash if you'd rather
spread it onto a control yourself. An explicit name: still wins (escape hatch).
Editing an associated record (accepts_nested_attributes_for). nested_update!
maps a declared nested param straight onto <assoc>_attributes and carries the
existing record's id, so update_only: matches it in place instead of building a
second has_one (the boilerplate that's easy to get subtly wrong):
# Account has_one :address; accepts_nested_attributes_for :address, update_only: true
action :save, params: { address: { street: :string, city: :string } }
def save(address:)
nested_update!(:address, address) # update!(address_attributes: address.merge(id: @account.address&.id))
end
nested_attributes(:address, address) returns the id-merged hash without
updating, if you need to combine it with other attributes.
Phlex::Reactive::Response โ controlling the action's reply
By default an action re-renders its component in place. Return a
Phlex::Reactive::Response to do more (it governs only the actor's HTTP reply โ
cross-tab updates still use broadcast_*_to(..., exclude: reactive_connection_id)).
Returning anything else keeps the default, so existing actions are unaffected.
The snippets below alias the constant for brevity (Response.replace(self) won't
resolve to Phlex::Reactive::Response inside a namespaced component โ fully
qualify it, or add the alias shown):
Response = Phlex::Reactive::Response # or qualify each call below
def rename(title:)
return Response.replace(self).flash(:error, @todo.errors..to_sentence) unless @todo.update(title:)
Response.replace(self)
end
def approve = (@row.approve!; Response.remove(self)) # drop the element
def publish = (@article.publish!; Response.redirect(article_url(@article))) # slug changed โ Turbo.visit
def add(item:) = Response.replace(self).stream(Totals.update(@order)) # multi-stream
# Re-render a COMPANION element (a heading mirroring the edited name) alongside self:
def rename(value:) = (@account.update!(name: value); Response.replace(self).also_update("page_heading", html: @account.name))
| Builder | Reply |
|---|---|
Response.replace(self) / .update(self) |
re-render in place (explicit default) |
.also_update(target, html:) |
also re-render a companion element by DOM id; html is a plain string (escaped) or a Phlex component |
.also_replace(component) |
also re-render another Streamable component, targeting its own #id |
.flash(level, content, target: โฆ) |
append a flash; content is a plain string (escaped) or a Phlex component (off-request โ no Rails flash); target defaults to Phlex::Reactive.flash_target ("flash") |
Response.remove(self) |
remove the element (backed by Streamable#to_stream_remove) |
Response.redirect(url) |
client-side Turbo.visit (pass a *_url); rides a reactive:visit turbo-stream, not an HTTP 3xx |
Response.with(*streams) / #stream(*more) |
multi-stream |
.flash/.stream/.also_* are additive on a self-replace, so the component's
signed token always refreshes.
html:/contentescaping. A plain string is HTML-escaped by Turbo, sohtml: @account.nameis safe even for user-supplied values. To emit intentional markup, pass a Phlex component (html: Heading.new(name: @record.name)) โ rendered and auto-escaped through the renderer โ or anhtml_safestring for raw HTML you control.
Configuration (config/initializers/phlex_reactive.rb)
Phlex::Reactive.configure do |c| end if false # (plain accessors below)
# Inherit auth/CSRF/Current from your app on the action endpoint:
Phlex::Reactive.base_controller_name = "ApplicationController"
# Render your authorization library's error as 403:
Phlex::Reactive. = [Pundit::NotAuthorizedError]
# or: [ActionPolicy::Unauthorized]
# Use your ApplicationController to render components (app helpers / Current):
Phlex::Reactive.renderer = ApplicationController
# Sign tokens with a dedicated key instead of secret_key_base:
Phlex::Reactive.verifier = ActiveSupport::MessageVerifier.new(ENV["REACTIVE_KEY"])
# Change the endpoint path (default "/reactive/actions"):
Phlex::Reactive.action_path = "/_r/actions"
If you set a custom action_path, expose it to the client:
<meta name="phlex-reactive-action-path" content="<%= Phlex::Reactive.action_path %>">
Security
phlex-reactive is built so the easy path is the safe path โ but the boundary is real, so read this once.
- State is never trusted from the client. The DOM holds a
MessageVerifier- signed identity โ{component, gid}(record-backed),{component, state}(state-backed), or{component, gid, state}when a component declares both โ not raw state. A tampered class, record, or state value fails signature verification โ 400. - Actions are default-deny. Only methods declared with
action :nameare invokable. A public method withoutactionis unreachable. - You must authorize. The signature proves the token is yours, not that
this user may act on this record. Call your authorizer inside the action
(
authorize! @todo, :update?) and register its error inPhlex::Reactive.authorization_errors. - Params are schema-coerced. Only declared params reach your method, each cast to its declared type. No raw mass assignment.
- CSRF + auth are the host app's. The endpoint inherits from your configured
base_controller_name. InheritApplicationControllerto get CSRF and auth โ but if you have public reactive components, ensure the action path isn't force-redirected to a login page for logged-out users.
See docs/security.md for the threat model and a checklist.
How it beats Stimulus + Turbo (same feature, less code)
A counter, today vs. with phlex-reactive:
| Stimulus + Turbo | phlex-reactive |
|---|---|
|
```js
// counter_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = { url: String }
increment() { this.#post("increment") }
decrement() { this.#post("decrement") }
#post(op) {
fetch(`$thisthis.urlValue/$op`, {
method: "POST",
headers: { "X-CSRF-Token": token() },
})
}
}
```
```erb
<%# _counter.html.erb %>
<%= @counter.value %>
```
```ruby
# routes + controller
resources :counters do
member { post :increment; post :decrement }
end
def increment
@counter.increment!(:value)
render turbo_stream: turbo_stream.replace(
dom_id(@counter), partial: "counter",
locals: { counter: @counter })
end
```
| ```ruby class Counter < ApplicationComponent include Phlex::Reactive::Streamable include Phlex::Reactive::Component reactive_record :counter action :increment action :decrement def initialize(counter:) = @counter = counter def id = dom_id(@counter) def increment = @counter.increment!(:value) def decrement = @counter.decrement!(:value) def view_template div(id:, **reactive_attrs) do button(**on(:decrement)) { "โ" } span { @counter.value } button(**on(:increment)) { "+" } end end end ``` *One file. No JS. No routes. No partial. No hand-picked target.* |
Live updates with pgbus (recommended)
pgbus replaces Action Cable's transport
with Postgres SSE and fixes its reliability gaps. With it installed,
broadcast_*_to and turbo_stream_from route over pgbus automatically:
class Message < ApplicationRecord
broadcasts_to ->(m) { [m.room, :messages] }, durable: true
end
- Transactional: a broadcast inside a transaction that rolls back never fires โ and the DB change is undone. No "ghost" UI updates.
- Reconnect-safe: a tab that dropped replays missed messages on reconnect
(
Last-Event-ID+ PGMQ archive). - No race on subscribe: messages broadcast between render and subscribe are replayed, not lost.
- No Redis, no Action Cable.
See docs/broadcasting.md and docs/transport-pgbus.md.
Documentation
- Installation & bundler setups
- Mental model & architecture
- Security & threat model
- Broadcasting & live updates
- Transport: pgbus vs Action Cable
- Testing reactive components
- Examples: counter ยท chat ยท todo list ยท inline edit ยท notifications
Credits & prior art
The mental model is stolen, gratefully, from Laravel Livewire (public method = action) and Phoenix LiveView (a component is a re-render unit). The transport and reliability come from pgbus. The rendering is all Phlex.
License
MIT.