phlex-reactive

CI Gem Version Docs

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
      button(**on(:decrement)) { "โˆ’" }
      span { @count }
      button(**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
    authorize! @todo, :update?      # YOU authorize โ€” the token only proves identity
    @todo.toggle!(:done)
  end

  def rename(title:)
    authorize! @todo, :update?
    @todo.update!(title:)
  end

  def view_template
    li(id:, **reactive_attrs, class: ("done" if @todo.done?)) do
      button(**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
    button(**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.full_messages.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:/content escaping. A plain string is HTML-escaped by Turbo, so html: @account.name is 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 an html_safe string 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.authorization_errors = [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 :name are invokable. A public method without action is 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 in Phlex::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. Inherit ApplicationController to 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 + Turbophlex-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.*

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

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.