Example: draft rows for a new parent
The "new order + line items" form: the user builds up child rows BEFORE the parent exists. An unsaved parent has no GlobalID to sign, so this pre-save window runs entirely client-side — zero round trips — and the real form submit creates parent + rows in ONE request.
Why this can’t be a reactive collection (yet)#
A reactive collection assumes the parent the rows attach to already exists: the row actions re-derive it from a signed record identity, which needs a GlobalID — an id. A parent that is still a draft (new_record?) can't be signed, so the classic "new X" form (build line items, tasks, attachments; save everything at once) traditionally falls back to bespoke JavaScript: clone a <tr>, renumber indexes, serialize into the form on submit.
reactive_nested_* is that pattern as a primitive, the reactive_tags posture: the rows are form state, add/remove run entirely client-side (no token, no POST — it works in a token-less ClientBindings component), and the surrounding real form submit posts standard Rails parent[assoc_attributes][<index>][field] names so accepts_nested_attributes_for reconciles in one create.
The wiring#
Three markers and two triggers, all keyed by the association name (so several collections can share one root):
class DraftOrderForm < ApplicationComponent
include Phlex::Reactive::ClientBindings
reactive_scope :order # names post as order[line_items_attributes][…]
def view_template
div(**reactive_root(id: 'draft_order_form')) do
form(action: orders_path, method: 'post', data: { turbo: 'false' }) do
input(**reactive_field(:total, type: 'number', value: '0'))
div(**reactive_nested_list(:line_items)) { } # rows land here
template(**reactive_nested_template(:line_items)) { row_fields }
button(**reactive_nested_add(:line_items)) { 'Add item' }
button(type: 'submit') { 'Create order' }
end
end
end
endClicking add clones the <template> row and swaps every NEW_ROW placeholder in the clone's name/id/for attributes for a fresh unique index — clock-seeded and strictly monotonic, so server-rendered 0..n indexes and a same-millisecond double click can never collide — then focuses the new row's first field. Remove deletes a draft row from the DOM; on a row carrying a hidden [_destroy] input (an edit form's persisted row) it marks it "1" and hides the row instead, so Rails destroys it on save.
The reconcile — plain Rails#
Nothing bespoke on the server. The submit is one POST carrying the parent's fields and every surviving row's indexed group:
class Order < ApplicationRecord
has_many :line_items, dependent: :destroy
accepts_nested_attributes_for :line_items, allow_destroy: true
end
# The controller create:
Order.create!(params.require(:order).permit(:total,
line_items_attributes: %i[id quantity price _destroy]))A removed draft row simply isn't in the POST; a _destroy-marked persisted row rides along as _destroy=1 and Rails deletes it.
Fill-then-add — add controls outside the row#
The default reactive_nested_add is inline-edit: it clones the template and focuses the new row's first field, so you type INTO the row. But a very common form shape puts the add controls OUTSIDE the row — a preset <select>, a typeahead, plain inputs — and clicking Add snapshots those values into a new row, then clears the controls for the next entry.
Pass from: (a map of row field → source-control selector) and optionally clear::
input(id: 'item-name', type: 'text') # the add controls, OUTSIDE the row
input(id: 'item-qty', type: 'number')
button(**reactive_nested_add(:line_items,
from: { name: '#item-name', quantity: '#item-qty' },
clear: true)) { 'Add item' }On click the client clones the template, fills each cloned-row field from its source control's current value — matching the field by the same trailing bracket-segment key inference JSON mode uses — keeps focus on the sources (so you keep entering the next item; it does not steal focus into the row), and with clear: true resets the sources (each via the set-value + dispatch contract, so dirty tracking and compute observe the reset).
It composes with both wire modes: the seeded values ride the renumbered _attributes names on submit (:attributes), and the end-of-add JSON sync serializes them (as: :json) — no extra wiring either way.
from: values are raw CSS selectors resolved WITHIN this reactive root (issue #15 ownership) — the escape-hatch posture of reactive_filter/reactive_tags, since the sources are your own markup, not reactive_field bindings. A selector that resolves nothing, or a row-field key with no matching cloned field, is silently skipped (the row still adds) — a half-mapped binding never throws on click.
Bonus: real server actions on a draft parent#
Independent of the rows, a draft parent can now round-trip real server actions too. An unsaved record signs a gid-less {c, state} token (the identity layer already omitted the GlobalID), and the action endpoint rebuilds the component through the record kwarg's initialize default, with the declared reactive_state riding the token:
class OrderComponent < ApplicationComponent
include Phlex::Reactive::Component
reactive_record :order
reactive_state :total, :allowance, :cash, :leasing
action :rebalance, params: { allowance: :integer, cash: :integer,
leasing: :integer, total: :integer }
# The default is what a DRAFT token rebuilds through — a component
# whose initialize REQUIRES the kwarg raises a guided error instead.
def initialize(order: Order.new, total: nil, allowance: nil, cash: nil, leasing: nil)
# …
end
endThe action runs against the rebuilt draft (guard your writes with @order.persisted?), re-renders, and the fresh token — still gid-less — rides the reply.
Boundaries#
- The DOM is the single source of truth for unsent draft rows. A server re-render of the root REPLACES them — keep replace-shaped actions out of a root holding unsent rows.
- Nesting a collection inside another's template is unsupported — the placeholder swap would hit both (the same limitation as every template-clone implementation).
- Once the parent is saved, the persisted flow takes over: the same row markup renders with real indexes, or the list graduates to a reactive collection (
reactive_collection+reply.append/reply.remove).
Emit data-turbo as the STRING "false" on the form if you want the native (non-Turbo) submit — Phlex omits a Ruby false attribute entirely, and Turbo then intercepts the POST and requires a redirect response.