From amplify
Implements optimistic UI patterns in Elixir Phoenix LiveView: instant client feedback before server confirmation, loading states, rollback on failure, race guards, stream animations, undo, accessibility.
npx claudepluginhub wunki/amplify --plugin ask-questions-if-underspecifiedThis skill uses the workspace's default tool permissions.
Build LiveView interactions that feel instant while preserving server truth.
Builds and debugs Phoenix LiveView forms with changeset validation, per-field errors via used_input?, nested inputs_for, file uploads, phx-target components, and reconnect recovery.
Enforces Phoenix LiveView best practices: @impl true callbacks, assign initialization in mount/handle_params, connected? checks, proper tuples, and two-phase rendering awareness. Invoke before LiveView modules or .heex templates.
Provides Phoenix LiveView best practices: no DB queries in mount (called twice), load data in handle_params, security scopes, scoped PubSub topics, GenServer polling, async assigns, and gotchas.
Share bugs, ideas, or general feedback.
Build LiveView interactions that feel instant while preserving server truth.
JS.push plus optimistic visuals.phx-hook or colocated hooks.phx-disable-with.JS.push(..., loading: "#other-element").JS.push(..., page_loading: true).JS.add_class calls in a pipe.JS.push(...) |> JS.add_class(...) |> JS.transition(...).display: in JS.show and JS.toggle for layout stability on inline elements.JS.toggle_attribute/2 with a 3-value tuple for instant ARIA updates.to: {:closest, selector} or to: {:inner, selector} to avoid brittle selectors.JS.ignore_attributes for browser-owned attributes like open on <details>/<dialog>.push_event revert (for JS.add_class changes that survive patches).cancel_async/3 to cancel a superseded start_async by name before starting a new one.mounted().aria-live="polite" regions to announce state changes to screen readers.aria-busy on containers during mutations.prefers-reduced-motion with a CSS guard.liveSocket.enableLatencySim(ms).JS.add_class, loading classes). Test visual feedback with browser-level tests.User intent (click/submit)
-> JS commands execute immediately (visual feedback)
-> event is pushed over the LiveView channel
-> server handles mutation
-> diff and acknowledgement arrive
-> client keeps, refines, or reverts optimistic visuals
<button
phx-click={
JS.push("delete", loading: "#row-#{item.id}")
|> JS.add_class("opacity-50 pointer-events-none", to: "#row-#{item.id}")
}
phx-disable-with="Removing..."
>
Remove
</button>
<button phx-click={JS.toggle(to: "#details-#{@id}", display: "inline")}>
More info
</button>
<button
id={"expander-#{@id}"}
phx-click={JS.toggle_attribute({"aria-expanded", "true", "false"})}
aria-expanded="false"
>
Toggle
</button>
<button phx-click={JS.push("rebuild", page_loading: true)}>
Rebuild
</button>
Streams are the default for any list of non-trivial size. Optimistic stream updates require coordination between client-side visuals and server-side stream operations.
Insert a temporary item immediately, swap it for the real one on confirmation, or remove it on failure.
def handle_event("add_item", params, socket) do
temp_id = "temp-#{System.unique_integer([:positive])}"
temp_item = %{id: temp_id, title: params["title"], pending?: true}
socket =
socket
|> stream_insert(:items, temp_item, at: 0)
|> start_async({:create_item, temp_id}, fn ->
{temp_id, MyApp.Items.create(params)}
end)
{:noreply, socket}
end
def handle_async({:create_item, _}, {:ok, {temp_id, {:ok, item}}}, socket) do
socket =
socket
|> stream_delete(:items, %{id: temp_id})
|> stream_insert(:items, item, at: 0)
{:noreply, socket}
end
def handle_async({:create_item, _}, {:ok, {temp_id, {:error, _changeset}}}, socket) do
socket =
socket
|> stream_delete(:items, %{id: temp_id})
|> put_flash(:error, "Could not create item")
{:noreply, socket}
end
Using a tuple name {:create_item, temp_id} allows concurrent inserts: each gets its own async, and the temp ID flows through the return value so the right placeholder is replaced.
Style the temp item as pending in the template:
<div
:for={{dom_id, item} <- @streams.items}
id={dom_id}
class={if item[:pending?], do: "opacity-50 animate-pulse"}
>
<%= item.title %>
</div>
When using a CSS transition, delay stream_delete so the animation completes before the element is removed from the DOM.
<button phx-click={
JS.push("delete_item", value: %{id: item.id})
|> JS.transition(
{"transition-opacity duration-300", "opacity-100", "opacity-0"},
to: "#items-#{item.id}"
)
}>
Delete
</button>
def handle_event("delete_item", %{"id" => id}, socket) do
case MyApp.Items.delete(id) do
{:ok, item} ->
Process.send_after(self(), {:remove_from_stream, item}, 300)
{:noreply, socket}
{:error, _reason} ->
{:noreply, put_flash(socket, :error, "Delete failed")}
end
end
def handle_info({:remove_from_stream, item}, socket) do
{:noreply, stream_delete(socket, :items, item)}
end
The 300ms delay matches the duration-300 transition class. If the server responds faster than the animation, the item disappears mid-fade without this delay.
stream_async (v1.1.5+)For paginated or lazily loaded lists, stream_async/4 inserts items as they arrive without blocking the initial render. Skip this pattern if the project uses LiveView older than v1.1.5 — use start_async with manual stream_insert calls instead.
def mount(_params, _session, socket) do
{:ok,
socket
|> stream(:items, [])
|> stream_async(:items, fn -> {:ok, MyApp.Items.list_all()} end)}
end
Optimistic visuals must revert cleanly when the server rejects a mutation.
Server patches restore attributes and content that the server controls. If the item stays in assigns or streams unchanged, the next patch naturally restores server-rendered DOM state (text, data attributes, conditionally rendered classes).
However, classes added client-side via JS.add_class are not reverted by server patches. They persist until explicitly removed. For server-driven revert to work, the optimistic visual must come from server-rendered state (e.g., a conditional class in HEEx), not from a client-side JS command.
def handle_event("archive", %{"id" => id}, socket) do
case MyApp.Items.archive(id) do
{:ok, item} ->
{:noreply, stream_delete(socket, :items, item)}
{:error, _reason} ->
# Server patch restores server-rendered attributes, but NOT JS.add_class changes
{:noreply, put_flash(socket, :error, "Could not archive item")}
end
end
push_event (JS.add_class and similar)Classes added via JS.add_class survive server patches. Use push_event to tell a hook to clean them up on failure. See references/js-commands-cookbook.md for the hook-side implementation.
{:error, _reason} ->
{:noreply,
socket
|> push_event("revert-optimistic", %{id: id})
|> put_flash(:error, "Archive failed")}
For deletes and archives, offer a brief undo window instead of executing immediately.
def handle_event("delete", %{"id" => id}, socket) do
ref = make_ref()
Process.send_after(self(), {:confirm_delete, id, ref}, 5_000)
{:noreply,
socket
|> assign(:pending_delete, {id, ref})
|> put_flash(:info, "Item will be deleted. Undo?")}
end
def handle_event("undo_delete", _params, socket) do
{:noreply, assign(socket, :pending_delete, nil)}
end
def handle_info({:confirm_delete, id, ref}, socket) do
case socket.assigns.pending_delete do
{^id, ^ref} ->
MyApp.Items.delete!(id)
{:noreply,
socket
|> stream_delete(:items, %{id: id})
|> assign(:pending_delete, nil)}
_ ->
# Undo was clicked, or a different delete superseded this one
{:noreply, socket}
end
end
Discard stale responses when a newer request supersedes an older one. start_async does not auto-cancel a previous async of the same name. Cancel the named async before starting a new one:
socket = socket |> cancel_async(:search, :superseded) |> start_async(:search, fn -> ... end)
For manual Task-based async (pre-LiveView 1.0 or third-party tasks), track a monotonic request ID in assigns and ignore results where request_id != socket.assigns.search_request_id.
Prevent silent overwrites when multiple users edit the same resource.
def handle_event("update", params, socket) do
item = socket.assigns.item
case MyApp.Items.update(item, params, expected_version: item.lock_version) do
{:ok, updated} ->
{:noreply, assign(socket, :item, updated)}
{:error, :stale} ->
fresh = MyApp.Items.get!(item.id)
{:noreply,
socket
|> assign(:item, fresh)
|> put_flash(:error, "Updated by someone else. Your changes were not saved.")}
end
end
Use phx-disable-with to block double-submit on a button. For per-row serialization, use loading: "#row-#{item.id}" to lock the row element during the round-trip (see Baseline Pattern 1).
For the full form lifecycle (changesets, to_form, error feedback model, debouncing, recovery, nested forms, uploads), see the liveview-forms skill. This section covers only the optimistic feedback patterns.
phx-change-loading is applied automatically to the input and its parent form during the round-trip. To show loading feedback on a results container outside the form, use loading: in JS.push:
<form phx-change={JS.push("search", loading: "#results")} phx-submit="search">
<input
type="search"
name="q"
value={@query}
phx-debounce="300"
placeholder="Search..."
/>
</form>
<div id="results" class="phx-submit-loading:opacity-50">
...
</div>
Use an aria-live region in the layout (so it persists across patches). Update @status_message from the server after mutations.
<div role="status" aria-live="polite" class="sr-only" id="live-status">
<%= @status_message %>
</div>
Mark containers as busy during async operations: <section aria-busy={@saving?}>. Update @saving? before and after the mutation.
One CSS rule prevents all transition-based optimistic animations from causing issues for motion-sensitive users. The optimistic state classes still apply, only the visual transition is suppressed.
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
LiveViewTest processes events synchronously through the server, so you can assert the post-mutation state directly.
test "delete removes the item", %{conn: conn} do
item = insert(:item)
{:ok, view, _html} = live(conn, ~p"/items")
assert has_element?(view, "#items-#{item.id}")
view |> element("#delete-#{item.id}") |> render_click()
refute has_element?(view, "#items-#{item.id}")
end
test "shows error and keeps item when delete fails", %{conn: conn} do
item = insert(:item, locked: true)
{:ok, view, _html} = live(conn, ~p"/items")
view |> element("#delete-#{item.id}") |> render_click()
assert has_element?(view, "#items-#{item.id}")
assert render(view) =~ "Could not delete"
end
For start_async and stream_async patterns, use render_async/1 to await all pending async tasks before asserting:
test "optimistic insert shows pending item then resolves", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/items")
view
|> form("#new-item-form", item: %{title: "New thing"})
|> render_submit()
# Wait for start_async to complete, then render
html = render_async(view)
assert html =~ "New thing"
end
Not a substitute for tests, but catches visual regressions tests cannot. In the browser console: liveSocket.enableLatencySim(1000). Check for flicker, stale state, and double submissions. Disable with liveSocket.disableLatencySim().
Prefer colocated hooks over global hook registrations. They keep hook logic close to the LiveView that uses them and avoid global namespace pollution. See Phoenix.LiveView.ColocatedHook and Phoenix.LiveView.ColocatedJS. Hook-side implementation for push_event revert patterns is in references/js-commands-cookbook.md.
Feedback timing:
Process.send_after delay to transition duration.DOM management:
JS.* commands already provide patch-aware behavior.phx-update="ignore" on a container and expecting server patches to revert optimistic classes inside it. ignore blocks all server patches to that subtree.phx-hook for things JS.* commands handle natively. Hooks are an escalation, not a default.Streams and lists:
Forms:
phx-change. The wrong values may serialize. Use value: in JS.push instead.State management:
mounted().Accessibility:
prefers-reduced-motion CSS guard.aria-live region for announcing mutation outcomes to screen readers.See José Valim's analysis of concurrent submissions: without causal ordering, concurrent request/revalidation models can surface stale user-visible state. Prefer LiveView's persistent channel model and server-side ordering discipline for mutation flows.
references/js-commands-cookbook.md when composing JS command chains, choosing selector strategies, or looking up loading feedback options. Skip if the question is purely about server-side Elixir patterns.references/changelog-highlights-2024-2026.md when checking version-specific behavior, planning a LiveView upgrade, or debugging a regression that appeared after a version bump. Skip if the LiveView version is not in question.