Module: Phlex::Reactive::Component
- Extended by:
- ActiveSupport::Concern
- Defined in:
- lib/phlex/reactive/component.rb
Overview
Component turns a self-contained Phlex component into a Livewire-style reactive unit: declare actions in Ruby, and the generic ‘reactive` Stimulus controller wires clicks/inputs to an HTTP round trip that re-renders the component and morphs it back into the DOM. No per-feature Stimulus controllers, no hand-picked Turbo targets.
Include alongside Phlex::Reactive::Streamable (which provides #id and the re-render machinery).
Security model (the decisive design choice) ===
We do NOT ship component STATE to the browser (no snapshot). The DOM carries a signed IDENTITY:
* Record-backed (the common case): reactive_record :todo signs the
record's GlobalID. The server re-finds it via GlobalID — the client
can neither forge the component class nor swap the record. State =
the database.
* State-backed (record-less, e.g. a counter): reactive_state :count
signs the listed instance variables. Use when there is genuinely no
record to re-find.
* Both (the inline_edit pattern): reactive_record :record plus
reactive_state :attribute, :editing signs the record's GlobalID AND
the transient mode in one token, so "which field / what mode" survives
every action round trip and stays tamper-proof.
Actions are DEFAULT-DENY: only methods declared with ‘action :name` may be invoked. The signature proves the token is ours, NOT that this user may act — your action must still authorize the record. Action params pass through a declared schema; nothing else reaches the method.
Usage (record-backed):
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)
def toggle = ((@todo, :update?); @todo.toggle!(:done))
def rename(title:) = ((@todo, :update?); @todo.update!(title:))
def view_template
li(id:, **reactive_attrs) do
(**on(:toggle)) { @todo.done? ? "✓" : "○" }
span { @todo.title }
end
end
end
Defined Under Namespace
Classes: Action
Instance Method Summary collapse
-
#nested_attributes(association, attrs) ⇒ Object
Map a declared nested param onto Rails’ <assoc>_attributes, carrying the existing associated record’s id so accepts_nested_attributes_for matches it IN PLACE instead of building a second one (issue #24).
-
#nested_update!(association, attrs, **extra) ⇒ Object
Map a nested param onto <assoc>_attributes (with id preservation) AND apply it to the component’s record in one call (issue #24).
-
#on(action_name, event: "click", debounce: nil, **params) ⇒ Object
Attributes for an element that triggers an action.
-
#reactive_attrs ⇒ Object
Root-element attributes: marks the element reactive and carries the signed identity token.
-
#reactive_connection_id ⇒ Object
The acting client’s SSE connection id during the current action (nil outside an action, or when the client isn’t subscribed to a stream).
-
#reactive_field(param, **attrs) ⇒ Object
Bind a form control’s ‘name` to an action param so its value travels with the action — instead of hand-writing the magic `name: “value”` on every input and silently getting no params when you forget it (issue #23).
-
#reactive_input(param, **attrs) ⇒ Object
Render an <input> already bound to an action param (issue #23).
-
#reactive_select(param, **attrs, &block) ⇒ Object
Render a <select> bound to an action param (issue #23).
Instance Method Details
#nested_attributes(association, attrs) ⇒ Object
Map a declared nested param onto Rails’ <assoc>_attributes, carrying the existing associated record’s id so accepts_nested_attributes_for matches it IN PLACE instead of building a second one (issue #24). Returns the update hash; pass it to update!:
def save(address:) = nested_update!(:address, address)
The id is only added when the association already exists, so the first save (no associated record yet) creates one cleanly. The given attrs are not mutated.
245 246 247 248 249 250 251 |
# File 'lib/phlex/reactive/component.rb', line 245 def nested_attributes(association, attrs) merged = attrs.dup existing = reactive_record_for_nested.public_send(association) merged[:id] = existing.id if existing {"#{association}_attributes": merged} end |
#nested_update!(association, attrs, **extra) ⇒ Object
Map a nested param onto <assoc>_attributes (with id preservation) AND apply it to the component’s record in one call (issue #24). Extra keyword attributes update alongside the association.
def save(address:, name:) = nested_update!(:address, address, name:)
257 258 259 |
# File 'lib/phlex/reactive/component.rb', line 257 def nested_update!(association, attrs, **extra) reactive_record_for_nested.update!(**nested_attributes(association, attrs), **extra) end |
#on(action_name, event: "click", debounce: nil, **params) ⇒ Object
Attributes for an element that triggers an action.
button(**on(:toggle)) { "○" }
form(**on(:save, event: "submit")) { ... }
input(**on(:update, event: "input", debounce: 300)) # live-as-you-type
Extra keyword args become explicit params merged over collected form fields. For click triggers we force type=“button” so a bare button inside a <form> can’t submit it and cause a full-page navigation.
‘debounce:` (milliseconds) coalesces rapid events (e.g. keystrokes on an “input” trigger) into ONE round trip fired after the quiet period — so live-update-as-you-type doesn’t POST per keystroke. A blur flushes a pending dispatch so the last edit is never dropped. Omit it for the immediate-dispatch default.
196 197 198 199 200 201 202 203 204 205 206 207 |
# File 'lib/phlex/reactive/component.rb', line 196 def on(action_name, event: "click", debounce: nil, **params) attrs = { data: { action: "#{event}->reactive#dispatch", reactive_action_param: action_name.to_s, reactive_params_param: params.to_json } } attrs[:data][:reactive_debounce_param] = debounce if debounce attrs[:type] = "button" if event == "click" attrs end |
#reactive_attrs ⇒ Object
Root-element attributes: marks the element reactive and carries the signed identity token. Spread onto the root:
div(id:, **reactive_attrs) { ... }
173 174 175 176 177 178 179 180 |
# File 'lib/phlex/reactive/component.rb', line 173 def reactive_attrs { data: { controller: "reactive", reactive_token_value: reactive_token } } end |
#reactive_connection_id ⇒ Object
The acting client’s SSE connection id during the current action (nil outside an action, or when the client isn’t subscribed to a stream). Pass it as ‘exclude:` when broadcasting from an action so the actor doesn’t receive the echo of its own change — it already gets the action’s HTTP response:
def (body:)
msg = ChatMessage.create!(room: @room, body:)
ChatMessage::Item.broadcast_append_to("chat", @room,
target: "messages", model: msg, exclude: reactive_connection_id)
end
166 167 168 |
# File 'lib/phlex/reactive/component.rb', line 166 def reactive_connection_id Phlex::Reactive.current_connection_id end |
#reactive_field(param, **attrs) ⇒ Object
Bind a form control’s ‘name` to an action param so its value travels with the action — instead of hand-writing the magic `name: “value”` on every input and silently getting no params when you forget it (issue #23). Returns a Phlex attributes hash to spread onto any control:
input(**reactive_field(:value, value: @record.name))
select(**reactive_field(:status)) { ... }
Extra attrs merge over the binding; an explicit name: still wins (escape hatch). The trigger (on(:save)) stays on the button, not the field — so focusing the input doesn’t dispatch and collapse edit mode.
218 219 220 |
# File 'lib/phlex/reactive/component.rb', line 218 def reactive_field(param, **attrs) {name: param.to_s, **attrs} end |
#reactive_input(param, **attrs) ⇒ Object
Render an <input> already bound to an action param (issue #23). Sugar for input(**reactive_field(param, **attrs)); the value/type/etc. pass through.
reactive_input(:value, value: @record.name, type: "text")
225 226 227 |
# File 'lib/phlex/reactive/component.rb', line 225 def reactive_input(param, **attrs) input(**reactive_field(param, **attrs)) end |
#reactive_select(param, **attrs, &block) ⇒ Object
Render a <select> bound to an action param (issue #23). The options block is the element’s content, so the awkward FormBuilder positional split (where name: lands after the options/html-options args) goes away:
reactive_select(:status) { @statuses.each { |s| option(value: s, selected: s == @record.status) { s } } }
233 234 235 |
# File 'lib/phlex/reactive/component.rb', line 233 def reactive_select(param, **attrs, &block) select(**reactive_field(param, **attrs), &block) end |