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.


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>
   โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ 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 (record-less widgets)

No database row โ€” e.g. a counter or a wizard step. The listed instance vars are signed into the token. Keep state small and JSON-serializable.

reactive_state :count, :step       # signed; rebuilt on each action

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 Stream the already-built instance (used internally after an action)

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 State-backed identity (signed instance vars). Record-less only.
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.

Param types: :string (default), :integer, :float, :boolean. Anything not in the schema is dropped before reaching your method.

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" }) { "+" }

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} or {component, state}), 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.