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(**reactive_root) 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)
// 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:
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.
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 reply.<verb> โ see "Controlling the action's reply")
โโโโโโโโโ Turbo applies 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(**reactive_root(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, morph: false, **opts) |
<turbo-stream action=replace target=id> of a freshly built component; morph: true adds method="morph" |
.update / .append(target:) / .prepend(target:) / .remove |
The other Turbo Stream actions |
.broadcast_replace_to(*streamables, model:, morph: false) |
Broadcast a replace over the stream transport (pgbus SSE / Action Cable); morph: true morphs in place |
.broadcast_append_to(*streamables, target:, model:) / _update_ / _prepend_ / _remove_ |
The broadcast variants |
#to_stream_replace / #to_stream_morph / #to_stream_update / #to_stream_remove |
Stream the already-built instance (used internally after an action / by reply); #to_stream_morph morphs in place |
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_root(**overrides) |
Spread onto the root element: emits the component id and reactive_attrs together, so the controller root always carries #id. Preferred over id: + reactive_attrs. **overrides (class:/data:) deep-merge. |
reactive_attrs |
Marks an element reactive + carries the signed token (no id). Spread alongside id: on the same element: div(id:, **reactive_attrs). Prefer reactive_root, which can't split them. |
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. |
reactive_collection :name, item:, container:, count:, empty:, size: |
Declare an add/remove-row list once; actions call reply.append/prepend/remove. See Reactive collections. |
reply.replace / .morph / .update / .remove / .redirect(url) / .with(*) |
Return from an action to control the reply (flash, remove, redirect, multi-stream). See Controlling the action's reply. |
reply.append(name, model) / .prepend(...) / .remove(name, model) |
Add/remove a row in a declared reactive_collection (row + count + empty-state in one reply). |
Param types: :string (default), :integer, :float, :boolean, :file.
Anything not in the schema is dropped before reaching your method.
File uploads (:file). Declare :file (or [:file] for multiple) to accept
an uploaded file in a reactive action โ attach a document/receipt/image to the
record without dropping out to a bespoke controller. When the reactive root holds
a populated <input type="file">, the client sends the action as multipart
FormData (instead of JSON) โ token + act as fields, scalar params as fields,
any nested/array params bracket-expanded into params[key][sub] /
params[key][index] fields (the same Rails-form shape, so a JSON body and a
multipart body coerce identically โ #39), and the file(s) appended; the endpoint
coerces :file to the ActionDispatch::Http::UploadedFile, passed through
untouched. A non-file value sent to a :file param is dropped (the keyword
default applies โ never a fabricated file). Token threading and the
re-render/morph are identical; only the request encoding changes when a file is
present.
reactive_record :document
action :upload, params: { file: :file, caption: :string } # single (has_one_attached)
action :upload_pages, params: { pages: [:file] } # multiple (has_many_attached)
def upload(file: nil, caption: nil)
@document.file.attach(file) if file
@document.update!(title: caption) if caption.present?
end
def view_template
form(**on(:upload, event: "submit")) do
input(type: "file", name: "file")
input(name: "caption")
(type: "submit") { "Upload" }
end
end
One multipart caveat:
FormDatacan't carry an empty array or hash, so on the multipart (file-present) path an empty[]/{}param is omitted and the action's keyword default applies โ it does not arrive as an explicit empty collection the way it does over JSON. If you rely on sendingtags: []to clear a collection, send that action without a file (the JSON path). A non-empty nested/array param rides along fine next to a file.
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. For the
root, prefer reactive_root, which already mixes id + token for you:
# โ
merges cleanly (data-action survives, your data-testid/class are added)
button(**mix(on(:increment), class: "btn", data: { testid: "inc" })) { "+" }
div(**reactive_root(class: "card", data: { testid: "root" })) { ... } # id + token + your attrs
# โ the extra data: overwrites on()'s data:, so the action never binds
button(**on(:increment), data: { testid: "inc" }) { "+" }
The reactive root must carry
#id(issue #48). The server targets your component's#idand the client self-matches its next signed token by the root element'sid.reactive_attrsdoes not emit the id โ so if you putid:on a child instead of the**reactive_attrselement, the root's id is empty, token threading falls back to the first token in the response, and the next action silently fails with HTTP 403. Usediv(**reactive_root)(it emits id
- token on one element) so the id can't land on the wrong node; if you spread
reactive_attrsdirectly, keepid:on the same element (div(id:, **reactive_attrs)). The controllerconsole.warns on connect when a reactive root has no id.
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(**reactive_root) 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.
reply โ controlling the action's reply
By default an action re-renders its component in place. To do more, return
reply.<verb> โ a subject-bound builder available in every component. 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.
reply reads cleanly: the component is the implicit subject (no self to
thread) and there's no constant to qualify (it's a method, so a namespaced
component needs no alias):
def rename(title:)
return reply.replace.flash(:error, @todo.errors..to_sentence) unless @todo.update(title:)
reply.replace
end
def approve = (@row.approve!; reply.remove) # drop the element
def publish = (@article.publish!; reply.redirect(article_url(@article))) # slug changed โ Turbo.visit
def add(item:) = reply.replace.stream(Totals.update(@order)) # multi-stream
# Per-field reactive editing (a "spreadsheet" grid): a debounced save fires
# while the user is still typing/tabbing. Morph in place so the focused <input>
# and its in-progress value survive the re-render (issue #28). Note the action is
# named `update`, yet `reply.morph` is unambiguous โ the verb is on `reply`:
def update(name:) = (@row.update!(name:); reply.morph)
# Re-render a COMPANION element (a heading mirroring the edited name) alongside self:
def rename(value:) = (@account.update!(name: value); reply.replace.also_update("page_heading", html: @account.name))
# Update ONLY part of the component (issue #30): re-stream just the total cell,
# NOT the whole row. reply.streams emits exactly your streams plus a tiny
# token-only refresh โ no full-self replace โ so a sibling <input> the user is
# mid-typing in is never torn down. The signed token still rolls forward.
def update(quantity:, price:) = (@item.update!(quantity:, price:); reply.streams(Totals.update(@item)))
| Builder | Reply |
|---|---|
reply.replace / reply.update |
re-render in place (default; replace is an outerHTML swap, update morphs inner HTML) |
reply.morph / reply.replace(morph: true) |
re-render in place via Idiomorph (method="morph") โ preserves the focused <input> + caret; for per-field reactive editing (issue #28) |
.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, morph: false) |
also re-render another Streamable component, targeting its own #id; morph: true morphs it in place |
.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") |
reply.remove |
remove the element (backed by Streamable#to_stream_remove) |
reply.redirect(url) |
client-side Turbo.visit (pass a *_url); rides a reactive:visit turbo-stream, not an HTTP 3xx |
reply.streams(*streams) |
partial update โ emit exactly these streams (no full-self replace) + a tiny token-only refresh, so live inputs survive; for per-field grid editing (issue #30) |
reply.with(*streams) / #stream(*more) |
multi-stream (self re-render still injected for the token) |
.flash/.stream/.also_* are additive on a self-replace, so the component's
signed token always refreshes. reply.streams is the exception that proves
the rule: it deliberately skips the full-self replace (so your hand-built streams
update only the targets you name) and refreshes the token via a tiny inert
reactive:token stream instead โ the token rolls forward without re-rendering
(and clobbering) the component's live inputs.
Under the hood.
reply.<verb>returns aPhlex::Reactive::Responseโ the immutable value object the endpoint reads. You can build one directly (Phlex::Reactive::Response.replace(self)) and it still works, butreplyis the preferred surface; treatResponseas an internal detail.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.
Reactive collections (add/remove rows + count + empty-state)
An add/remove-row list โ line items, attachments, tags, comments, a
notifications list โ is one of the most common reactive surfaces, and every one
re-implements the same orchestration by hand: append the row to the right
container, remove it on delete, keep a count badge in sync, and swap an
empty-state in/out as the list crosses 0โ1. reactive_collection declares
that contract once on the container so each action is a single call.
Declare the collection on the container component, then reply.append /
reply.prepend / reply.remove in the actions:
class NotificationsList < ApplicationComponent
include Phlex::Reactive::Streamable
include Phlex::Reactive::Component
reactive_collection :notifications,
item: NotificationRow, # the per-row Streamable component
container: "notifications", # the DOM id rows live in
count: "notifications-count", # optional companion id (the size badge)
empty: NotificationsEmpty, # optional empty-state component
size: -> { Todo.count } # resolves the live size (re-counted, never client state)
action :add, params: {title: :string}
action :dismiss, params: {id: :integer}
def add(title:)
todo = Todo.create!(title:)
reply.append(:notifications, todo) # append row + bump count + clear empty-state
end
def dismiss(id:)
Todo.find(id).destroy!
reply.remove(:notifications, id) # remove row + bump count + restore empty-state at 0
end
# view_template renders the count, the container <ul>, and the empty-state on
# first paint โ the same components the helper streams in/out on each delta.
end
| Builder | Reply (one Response) |
|---|---|
reply.append(name, model) |
append the row into the container + update the count + remove the empty-state when the list crosses 0โ1 |
reply.prepend(name, model) |
as append, but the row goes to the top |
reply.remove(name, model) |
remove the row by its dom_id + update the count + append the empty-state back when the list crosses โ0 |
size:is the source of truth โ it's re-counted server-side after the mutation, so the badge and the empty-state are correct-by-construction (no off-by-one, no client-held count).count:,empty:, andsize:are all optional: omit them and only the row stream is emitted.- Repeated add/remove just works โ each reply rolls the container's signed
token forward (via the inert
reactive:tokenrefresh), so the second click from the list root is accepted. Without this an add/remove list would be add-once-only (correct on the first click, silently rejected after); the helper bakes the refresh in so you never hit it. removetakes the record or itsdom_idstring โ a just-destroyed ActiveRecord still answersdom_idcorrectly, soreply.remove(:items, todo)works; pass the raw id only if your row#idmatchesActiveRecord::RecordIdentifier.- Reply governs the actor's HTTP response only. For a cross-tab live list
(other viewers see the row appear) keep broadcasting the row with
NotificationRow.broadcast_append_to(..., exclude: reactive_connection_id)โreactive_collectionis the per-actor add/remove + count + empty-state wrapper, not a replacement for the broadcast.
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 |
|---|---|
|
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
- Performance & benchmarking
- 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.