一键导入
liveview-expert
Phoenix LiveView patterns for stateful server-rendered UI including streams, async assigns, components, forms, uploads, and JS hooks
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
菜单
Phoenix LiveView patterns for stateful server-rendered UI including streams, async assigns, components, forms, uploads, and JS hooks
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
基于 SOC 职业分类
Python CLI patterns — typer apps, uv with PEP 723 inline deps, pytest, pathlib, atomic file writes, file locking, JSONL streaming, and production-grade stdlib idioms for command-line tools
Commanded CQRS/event-sourcing patterns for aggregates, commands, events, process managers, projections, and event handlers
Oban background job patterns — workers, queues, scheduling, unique jobs, retries, telemetry, engines, and Oban Pro features (Workflow, Batch)
Elixir testing patterns — ExUnit organization, ExMachina factories, Mox boundary mocking, Bypass HTTP stubbing, parameterize, sandbox patterns, and async-safe concurrency
Ecto database patterns — schemas, changesets, query composition, Multi transactions, migrations, ParameterizedType, UUIDv7, and performance tuning
Absinthe GraphQL patterns — schema design, resolvers, middleware, Dataloader batching, subscriptions, complexity analysis, and file uploads
| name | liveview-expert |
| description | Phoenix LiveView patterns for stateful server-rendered UI including streams, async assigns, components, forms, uploads, and JS hooks |
| targets | {"elixir":"1.18+","phoenix":"1.8+","phoenix_live_view":"1.0+","otp":"27+"} |
.heex template, Phoenix.Component, or Phoenix.LiveComponentmount/3, handle_event/3, handle_info/3, or handle_params/3stream/3, assign_async/3, or migrating away from temporary_assignslive_session, JS hooks, file uploads, or LiveView-side navigationphoenix-expert). For LiveView-specific test patterns (Phoenix.LiveViewTest, render_click, has_element?, file_input), see elixir-testing-expert.connected?(socket).Initial HTTP request:
Browser → GET /path → mount/3 (connected?: false) → render → static HTML
WebSocket upgrade:
Browser ⇄ WS → mount/3 (connected?: true) → render → initial diff
↓
handle_event / handle_info → render → DOM diff
↓
handle_params (URL change) → render → DOM diff
What kind of UI unit do you need?
├── Stateless markup reused across templates? → Phoenix.Component (function component)
├── Stateful, scoped to a parent LiveView, isolated event handling? → Phoenix.LiveComponent
│ └── Multiple instances need independent state? → LiveComponent with `:id`
├── Top-level page with its own URL and lifecycle? → Phoenix.LiveView (full LiveView)
└── Pure HTML helper with no assigns? → Plain function returning HEEx, no `Phoenix.Component`
How should this list-shaped data live in the socket?
├── Small, bounded, fully replaced on update? → assign(:items, list)
├── Large list, append/prepend/delete operations? → stream(:items, list)
│ └── Need to reset (e.g. filter change)? → stream(:items, list, reset: true)
├── Loaded from DB on mount, expensive query? → assign_async(:items, fn -> ... end)
├── Streamed from external source over time? → stream + handle_info
└── Form data? → to_form(changeset) — NOT assign(:changeset, ...)
Where should this piece of state live?
├── Survives reconnect, shareable via link? → URL params (handle_params + push_patch)
├── User identity, persists across LiveViews? → Session (mount via live_session)
├── Source of truth, queryable elsewhere? → Database (load in mount)
├── UI-only, ephemeral, lost on reconnect is fine? → Socket assigns
└── Cross-LiveView pub/sub? → Phoenix.PubSub (subscribe in mount, handle_info — see `phoenix-expert` for PubSub setup and topic conventions)
What kind of transition?
├── Same LiveView, URL change only (filters, pagination)? → push_patch(to: ~p"/...")
│ └── Triggers handle_params/3 — no remount
├── Different LiveView in same live_session? → push_navigate(to: ~p"/...")
│ └── Remount, but keeps WS connection
├── Full page load (different live_session, leaves LV)? → redirect(to: ~p"/...")
└── In a HEEx template? → <.link patch={...}> / <.link navigate={...}>
defmodule MyAppWeb.CoreComponents do
use Phoenix.Component
attr :label, :string, required: true
attr :type, :string, default: "button"
attr :rest, :global, include: ~w(disabled form name)
slot :inner_block, required: true
def button(assigns) do
~H"""
<button type={@type} class="btn" {@rest}>
{render_slot(@inner_block) || @label}
</button>
"""
end
end
Rule: Declare every assign with attr — runtime warnings catch typos and missing fields.
defmodule MyAppWeb.UserLive.Index do
use MyAppWeb, :live_view
alias MyApp.Accounts
@impl true
def mount(_params, _session, socket) do
if connected?(socket), do: Accounts.subscribe()
{:ok, stream(socket, :users, Accounts.list_users())}
end
@impl true
def handle_params(params, _uri, socket) do
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
user = Accounts.get_user!(id)
{:ok, _} = Accounts.delete_user(user)
{:noreply, stream_delete(socket, :users, user)}
end
@impl true
def handle_info({:user_created, user}, socket) do
{:noreply, stream_insert(socket, :users, user, at: 0)}
end
defp apply_action(socket, :index, _), do: assign(socket, :page_title, "Users")
defp apply_action(socket, :new, _), do: assign(socket, :page_title, "New User")
end
# Mount
{:ok, stream(socket, :posts, Posts.list())}
# Insert at top
stream_insert(socket, :posts, post, at: 0)
# Update in place (matches by dom_id)
stream_insert(socket, :posts, updated_post)
# Delete
stream_delete(socket, :posts, post)
# Replace entire stream (e.g., filter changed)
stream(socket, :posts, Posts.list(filter), reset: true)
<div id="posts" phx-update="stream">
<div :for={{dom_id, post} <- @streams.posts} id={dom_id}>
{post.title}
</div>
</div>
Rule: Streams require id="..." on the container and phx-update="stream". Each child needs id={dom_id}.
def mount(%{"id" => id}, _session, socket) do
socket =
socket
|> assign(:id, id)
|> assign_async(:user, fn ->
case Accounts.get_user(id) do
nil -> {:error, :not_found}
user -> {:ok, %{user: user}}
end
end)
{:ok, socket}
end
Rule: The function must return {:ok, %{key: value}} on success or {:error, reason} on failure. The <.async_result> :failed slot receives the error reason and renders accordingly.
<.async_result :let={user} assign={@user}>
<:loading>Loading user...</:loading>
<:failed :let={_reason}>Failed to load.</:failed>
{user.name}
</.async_result>
def mount(_params, _session, socket) do
changeset = Accounts.change_user(%User{})
{:ok, assign(socket, :form, to_form(changeset))}
end
def handle_event("validate", %{"user" => params}, socket) do
form =
%User{}
|> Accounts.change_user(params)
|> Map.put(:action, :validate)
|> to_form()
{:noreply, assign(socket, :form, form)}
end
def handle_event("save", %{"user" => params}, socket) do
case Accounts.create_user(params) do
{:ok, user} ->
{:noreply, socket |> put_flash(:info, "Created") |> push_navigate(to: ~p"/users/#{user}")}
{:error, changeset} ->
{:noreply, assign(socket, :form, to_form(changeset))}
end
end
<.form for={@form} phx-change="validate" phx-submit="save">
<.input field={@form[:email]} type="email" label="Email" />
<.input field={@form[:name]} label="Name" />
<.button>Save</.button>
</.form>
Rule: Always use to_form/1 — never assign(:changeset, ...). The Phoenix.HTML.Form struct carries error/change state for <.input> to render correctly. (See ecto-expert for changeset construction, validation, and error helper patterns.)
def mount(_, _, socket) do
{:ok,
socket
|> assign(:uploaded_files, [])
|> allow_upload(:avatar, accept: ~w(.jpg .png), max_entries: 1, max_file_size: 5_000_000)}
end
def handle_event("save", _params, socket) do
uploaded =
consume_uploaded_entries(socket, :avatar, fn %{path: path}, _entry ->
dest = Path.join("priv/static/uploads", Path.basename(path))
File.cp!(path, dest)
{:ok, "/uploads/#{Path.basename(dest)}"}
end)
{:noreply, update(socket, :uploaded_files, &(&1 ++ uploaded))}
end
<form phx-change="validate" phx-submit="save">
<.live_file_input upload={@uploads.avatar} />
<%= for entry <- @uploads.avatar.entries do %>
<progress value={entry.progress} max="100" />
<% end %>
</form>
# In the template
<div id="map" phx-hook="Map" data-coords={Jason.encode!(@coords)}></div>
// app.js
let Hooks = {}
Hooks.Map = {
mounted() {
this.map = initMap(JSON.parse(this.el.dataset.coords))
// Server → client: receive events pushed from LiveView
this.handleEvent("recenter", ({lat, lng}) => this.map.panTo([lat, lng]))
// Client → server: send events that fire LiveView's handle_event/3
this.map.on("click", (e) => {
this.pushEvent("marker-clicked", {lat: e.latlng.lat, lng: e.latlng.lng})
})
},
destroyed() { this.map.remove() }
}
let liveSocket = new LiveSocket("/live", Socket, {hooks: Hooks, ...})
# Server → client
push_event(socket, "recenter", %{lat: 37.7, lng: -122.4})
# Client → server (handle_event/3 catches the pushEvent above)
def handle_event("marker-clicked", %{"lat" => lat, "lng" => lng}, socket) do
{:noreply, assign(socket, :selected, {lat, lng})}
end
Rule: Hooks must have a unique id and a registered name in Hooks on the JS side. mounted/updated/destroyed/disconnected/reconnected are the lifecycle callbacks. Use pushEvent for client→server, handleEvent for server→client.
# BAD
def mount(_, _, socket) do
{:ok, assign(socket, :posts, Posts.list_all())} # 10,000 posts
end
Why it bites: Every assign lives in process memory, and every diff serializes through the WebSocket. A 10k-row list bloats the LiveView process and makes every update slow.
Instead:
# GOOD
def mount(_, _, socket) do
{:ok, stream(socket, :posts, Posts.list_all())}
end
Streams keep items in the DOM only — the server holds just the dom_id mapping.
render/1 or HEEx<!-- BAD -->
<%= for post <- MyApp.Posts.list() do %>
...
<% end %>
Why it bites: render/1 runs on every diff. You'll hammer the database on every assign change. There's also no error handling — a DB hiccup crashes the LiveView.
Instead: Load in mount/3 (or handle_event if user-triggered) and assign the result. Render reads only from socket assigns.
live_redirect / live_patch helpers# BAD (deprecated)
<%= live_redirect "Users", to: Routes.user_index_path(@socket, :index) %>
Why it bites: Deprecated since LiveView 0.18 in favor of <.link> + verified routes. Old code keeps working but new code shouldn't compound the tech debt.
Instead:
<.link navigate={~p"/users"}>Users</.link>
<.link patch={~p"/users?page=2"}>Page 2</.link>
# BAD
{:ok, pid} = Task.start_link(...)
assign(socket, :worker_pid, pid)
Why it bites: When the user reconnects (network blip, navigation), the LiveView remounts as a new process. Old PIDs are dead. Refs from Process.monitor are equally invalid.
Instead: Use assign_async/3 for one-shot work, or have a separate supervised process keyed by user/session ID and look it up by name/Registry on mount.
if connected?(socket), do: .... PubSub subscriptions belong inside the guard.temporary_assigns is mostly superseded — streams handle the "don't keep this in memory" use case better. Reach for temporary_assigns only for non-collection data you explicitly want to drop after render.stream_insert/4 matches existing items by dom_id and updates them in place. If you want a freshly-updated post to jump to the top, you must stream_delete and stream_insert(..., at: 0). There's no auto-sort by field — order is whatever you inserted.handle_params/3 fires on every URL change including push_patch from the same LiveView. Don't put expensive work there without checking what actually changed.:if and :for (since 0.18) is preferred over <%= if/for %>. Cleaner and more debuggable.~p requires Phoenix.VerifiedRoutes import — usually wired into use MyAppWeb, :live_view. If you get "undefined sigil ~p" you're missing the import.assign_new/3 for layouts and live_session — use assign_new(socket, :current_user, fn -> ... end) so root layout and child LiveViews share assigns without re-fetching.handle_event in a LiveComponent receives only events from its own DOM subtree. Send to parent with send(self(), ...) or Phoenix.LiveView.send_update/2.LiveView lifecycle callbacks (in order on first connect):
mount/3 (HTTP) — connected?: false
render/1 — initial HTML
mount/3 (WS upgrade) — connected?: true
handle_params/3 — if URL has params
render/1 — reconciled diff
Per-event lifecycle:
handle_event / handle_info / handle_params → render/1 → diff
Stream operations:
stream/3 — initialize or reset (with reset: true)
stream_insert/4 — add or update (matches by dom_id)
stream_delete/3 — remove
stream_delete_by_dom_id/3
references/streams.mdreferences/forms.mdreferences/uploads.mdPhoenix.LiveView.JS)? → Read references/js-and-hooks.mdPhoenix.LiveViewTest (render_click, has_element?, file_input, async tests)? → Read references/testing.md