LiveView Optimistic UI
Build LiveView interactions that feel instant while preserving server truth.
Core Model
- •Keep data state on the server, visual feedback on the client.
- •Apply immediate client feedback first, then push the event.
- •Let server diffs confirm, refine, or revert optimistic visuals.
- •Assume overlap and latency are normal, not edge cases.
Workflow
- •Classify the interaction:
- •Purely visual (open/close/toggle): JS-only commands, no round-trip.
- •Server mutation (save/delete/archive):
JS.pushplus optimistic visuals. - •Rich browser behavior (media, drag/drop, third-party libs):
phx-hookor colocated hooks. - •Large collections: streams or keyed comprehensions.
- •Choose loading feedback level:
- •Button-only feedback:
phx-disable-with. - •Button + related UI:
JS.push(..., loading: "#other-element"). - •Whole page transition:
JS.push(..., page_loading: true). - •Multiple scattered elements: compose
JS.add_classcalls in a pipe.
- •Button-only feedback:
- •Compose optimistic JS:
- •Pipe commands:
JS.push(...) |> JS.add_class(...) |> JS.transition(...). - •Use
display:inJS.showandJS.togglefor layout stability on inline elements. - •Use
JS.toggle_attribute/2with a 3-value tuple for instant ARIA updates. - •Use
to: {:closest, selector}orto: {:inner, selector}to avoid brittle selectors.
- •Pipe commands:
- •Keep it patch-safe:
- •Prefer LiveView JS commands over ad-hoc DOM mutation.
- •Use
JS.ignore_attributesfor browser-owned attributes likeopenon<details>/<dialog>. - •Ensure failures remove stale optimistic decorations deterministically.
- •Plan for failure:
- •On mutation rejection: revert optimistic visuals and show error feedback.
- •On concurrent clicks: disable or serialize per resource key.
- •On async work: guard stale responses with request IDs or version checks.
- •On socket disconnect: server patches resync DOM on reconnect. Re-derive any hook-managed state from the patched DOM in
mounted().
- •Make it accessible:
- •Use
aria-live="polite"regions to announce state changes to screen readers. - •Set
aria-busyon containers during mutations. - •Respect
prefers-reduced-motionwith a CSS guard.
- •Use
- •Validate with latency:
- •In dev tools, run
liveSocket.enableLatencySim(ms). - •Verify there is no rollback flicker, wrong-row updates, or duplicate submissions.
- •Write LiveViewTest assertions for server-side state changes. Note: LiveViewTest does not execute client-side JS commands (e.g.,
JS.add_class, loading classes). Test visual feedback with browser-level tests.
- •In dev tools, run
Event Flow
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
Baseline Patterns
1) Optimistic row delete
<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>
2) Instant toggle without round-trip
<button phx-click={JS.toggle(to: "#details-#{@id}", display: "inline")}>
More info
</button>
3) Instant ARIA state transitions
<button
id={"expander-#{@id}"}
phx-click={JS.toggle_attribute({"aria-expanded", "true", "false"})}
aria-expanded="false"
>
Toggle
</button>
4) Page-level loading event for long actions
<button phx-click={JS.push("rebuild", page_loading: true)}>
Rebuild
</button>
5) Composing multiple loading indicators
When a single action affects several parts of the page:
<button phx-click={
JS.push("checkout", loading: "#cart-summary")
|> JS.add_class("opacity-50", to: "#cart-items")
|> JS.add_class("animate-pulse", to: "#order-total")
}>
Place order
</button>
Stream Optimistic Patterns
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.
Optimistic stream insert with temp ID
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>
Optimistic stream delete with transition timing
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.
Async stream loading with stream_async (v1.1.5+)
For paginated or lazily loaded lists, stream_async/4 inserts items as they arrive without blocking the initial render.
def mount(_params, _session, socket) do
{:ok,
socket
|> stream(:items, [])
|> stream_async(:items, fn -> {:ok, MyApp.Items.list_all()} end)}
end
Error Recovery
Optimistic visuals must revert cleanly when the server rejects a mutation.
Server-driven revert (attributes rendered by the server)
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
Explicit revert via 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.
{:error, _reason} ->
{:noreply,
socket
|> push_event("revert-optimistic", %{id: id})
|> put_flash(:error, "Archive failed")}
Hooks.OptimisticContainer = {
mounted() {
this.handleEvent("revert-optimistic", ({ id }) => {
const el = document.getElementById(`item-${id}`)
if (el) {
el.classList.remove("opacity-50", "pointer-events-none", "line-through")
}
})
}
}
Undo window for destructive actions
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
Race Conditions
Request ID tracking for async results
Discard stale responses when a newer request supersedes an older one. start_async does not auto-cancel a previous async of the same name; use cancel_async/3 if you need explicit cancellation. For manual Task-based async, track a request ID and ignore stale results:
def handle_event("search", %{"q" => query}, socket) do
request_id = System.unique_integer([:positive])
parent = self()
socket =
socket
|> assign(:search_request_id, request_id)
|> assign(:searching?, true)
Task.start(fn ->
results = MyApp.Search.run(query)
send(parent, {:search_results, request_id, results})
end)
{:noreply, socket}
end
def handle_info({:search_results, request_id, results}, socket) do
if request_id == socket.assigns.search_request_id do
{:noreply, assign(socket, results: results, searching?: false)}
else
{:noreply, socket}
end
end
Optimistic locking for concurrent edits
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
Serializing concurrent clicks
Disable the trigger element during the round-trip to prevent double submission.
<button
phx-click={JS.push("process")}
phx-disable-with="Processing..."
>
Submit
</button>
For resource-keyed serialization (one in-flight action per row), use loading: to lock the row:
<button phx-click={
JS.push("archive", loading: "#row-#{item.id}")
|> JS.add_class("pointer-events-none", to: "#row-#{item.id}")
}>
Archive
</button>
Form Validation
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.
Optimistic search with loading feedback
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>
Accessibility
Screen reader announcements
Use an aria-live region to announce optimistic state changes. Keep it in the layout so it persists across patches.
<div role="status" aria-live="polite" class="sr-only" id="live-status"> <%= @status_message %> </div>
Update it from the server after mutations:
{:noreply, assign(socket, :status_message, "Item saved")}
Busy state on containers
Mark regions as busy during async operations so assistive technology can announce completion.
<section id="items-list" aria-busy={@saving?}>
...
</section>
Respect reduced motion
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;
}
}
Testing Optimistic Flows
LiveViewTest basics
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
Testing failure recovery
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
Testing async flows
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
Latency simulation in development
Not a substitute for tests, but catches visual regressions tests cannot.
// In browser console liveSocket.enableLatencySim(1000) // Interact with the UI and watch for flicker, stale state, double submissions liveSocket.disableLatencySim()
Colocated Hooks (v1.1+)
Prefer colocated hooks over global hook registrations. They keep hook logic close to the LiveView that uses them and avoid global namespace pollution.
// In your LiveView's colocated JS file (e.g., item_list_live.hooks.js)
export const OptimisticList = {
mounted() {
this.handleEvent("revert-optimistic", ({ id }) => {
const el = document.getElementById(`item-${id}`)
if (el) {
el.classList.remove("opacity-50", "pointer-events-none")
}
})
}
}
See Phoenix.LiveView.ColocatedHook for hooks and Phoenix.LiveView.ColocatedJS for general colocated JS. Requires Phoenix 1.8+.
Anti-Patterns
Feedback timing:
- •Waiting for a server response before any visual feedback.
- •Transitions that finish after the server responds, causing a flash of reverted state. Match
Process.send_afterdelay to transition duration.
DOM management:
- •Manually mutating DOM where
JS.*commands already provide patch-aware behavior. - •Using
phx-update="ignore"on a container and expecting server patches to revert optimistic classes inside it.ignoreblocks all server patches to that subtree. - •Using
phx-hookfor thingsJS.*commands handle natively. Hooks are an escalation, not a default.
Streams and lists:
- •Re-rendering whole lists for single-item changes when streams/keys are available.
- •Stream items with unstable IDs (timestamps, list indexes). Use database IDs or deterministic unique keys.
Forms:
- •Modifying form input values with JS commands during
phx-change. The wrong values may serialize. Usevalue:inJS.pushinstead.
State management:
- •Relying on independent HTTP response order for correctness.
- •Forgetting that socket disconnect and reconnect resets hook state. Re-derive optimistic state from the server-patched DOM in
mounted().
Accessibility:
- •Optimistic transitions without a
prefers-reduced-motionCSS guard. - •No
aria-liveregion 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
- •When composing JS command chains, refer to
references/js-commands-cookbook.mdfor command syntax, option details, and composition patterns. - •When checking version-specific behavior or planning a LiveView upgrade, refer to
references/changelog-highlights-2024-2026.mdfor breaking changes, bug fixes, and new features that affect optimistic UI.