um

Turbo

Optimistic updates

Draw what a form submitted before the server answers, then let the server's element take its place.

Three pieces, used together: the form mints the record's id, the page draws the record under that id as it submits, and the server's element replaces the drawing because it has the same id. Nothing is matched up by hand, and nothing shows twice.

1. Mint the id in the form

uuid_field is a hidden field holding a fresh UUIDv7, minted in the browser and again whenever the form resets. Without the script, the server's own id is sent.

<%= form.uuid_field :id %>

2. Draw the submission

<unmagic-optimistic>, inside the form, clones its template on submit. It takes the element's id and text from the form's fields and places it in container.

<%= form_with model: Comment.new, builder: Unmagic::Components::FormBuilder do |form| %>
  <%= form.uuid_field :id %>
  <unmagic-optimistic container="#comments">
    <%= form.text_area :body, required: true %>
    <template>
      <article class="comment is-pending" data-optimistic-id="comment[id]">
        <p data-optimistic-text="comment[body]"></p>
      </article>
    </template>
  </unmagic-optimistic>
  <%= form.submit "Comment" %>
<% end %>

A required field stops an empty form from submitting, so there's never a drawing with no record coming for it. scroll="false" stops the new element scrolling into view.

3. Answer with the real element

Create the record with the submitted id and answer with an upsert, from the response or a broadcast. The element with that id is already on the page, so it's replaced in place:

def create
  @comment = Comment.create!(params.expect(comment: %i[id body]))
  render turbo_stream: turbo_stream.action(:upsert, "comments", @comment)
end

ai_chat_composer does all of this for chat. Needs import "unmagic/components/uuid_input", "unmagic/components/optimistic" and "unmagic/components/upsert".

See it working