Examples

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
end

Clicking 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.

One row, authored once#

nested_field_name builds the Rails nested-attributes wire name. With no index it renders the template placeholder; with index: it renders a real row — so the SAME method serves the template prototype, an edit form's persisted rows, and (after the parent is saved) the server-rendered collection:

# The default is the template form (NEW_ROW placeholder names);
# pass index: to server-render an edit form's persisted rows.
def row_fields(index: nil)
  kwargs = index.nil? ? {} : { index: }
  div(**reactive_nested_row) do
    input(name: nested_field_name(:line_items, :quantity, **kwargs), type: 'number')
    input(name: nested_field_name(:line_items, :price, **kwargs), type: 'number')
    button(**reactive_nested_remove) { '×' }
  end
end

Under reactive_scope :order the emitted names are order[line_items_attributes][NEW_ROW][quantity] (template) and order[line_items_attributes][3][quantity] (a server-rendered row) — the scope wraps the line_items_attributes base, never the whole bracketed path.

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.

JSON mode — one hidden field instead of nested attributes#

Not every app persists a "new parent + child rows" form through accepts_nested_attributes_for. A common alternative serializes the rows into a single hidden field and JSON.parses it in the controller. If that is your existing persistence path, opt the list into as: :json and keep the controller exactly as it is — the primitive adapts to your wire, not the other way around:

# rows land here AND sync into the hidden field below:
div(**reactive_nested_list(:line_items, as: :json)) { }
# the client keeps this in sync (seed "[]" so an empty submit posts one):
input(type: 'hidden', **reactive_field(:line_items), value: '[]')

template(**reactive_nested_template(:line_items)) { row_fields }
button(**reactive_nested_add(:line_items)) { 'Add item' }

The row markup is unchanged — the same <template>, the same nested_field_name, the same add/remove triggers. In JSON mode the generic controller mirrors the surviving rows into that one hidden field as a JSON array on every add / remove / keystroke (the set-value + dispatch contract, so dirty tracking and compute still see it), inferring each JSON key from the trailing bracket segment of a row input's name — order[line_items_attributes][3][quantity]"quantity". A removed row simply leaves the array (JSON has no _destroy marker — an absent row is the removal).

The reconcile is your own hand-rolled parse, no nested attributes:

rows = JSON.parse(params.require(:order).permit(:line_items)[:line_items].presence || '[]')
order = Order.create!(total: params[:order][:total])
rows.each { order.line_items.create!(quantity: it['quantity'], price: it['price']) }

The per-row …_attributes[i][field] names still render (that is what the key is inferred from), so a submit posts them alongside the JSON field. A controller that permits only the JSON param ignores the extras — the hidden field is the single source of truth. Want a byte-clean POST? Drop the row-input names; then the keys must be declared, which as: :json (infer-from-names) deliberately doesn't require.

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
end

The 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.