SKILL.md
Phoenix LiveView Essentials
Use this skill before writing ANY LiveView module or .heex template.
Canonical FP bar: [docs/fcis-engineering-rules.md](../../docs/fcis-engineering-rules.md) — Functional Core, Imperative Shell: pure domain modules; side effects at edges. Keep LiveView/controller callbacks thin; delegate business rules to contexts/pure modules.
RULES — Follow these with no exceptions
1. Always add @impl true before every callback (mount, handleevent, handleinfo, render) 2. Initialize assigns before they're accessed in render/1 — use mount/3 for static defaults, handleparams/3 for URL-dependent assigns 3. Check connected?(socket) before PubSub subscriptions, timers, or side effects 4. Use Map.get(assigns, :key, default) for optional assigns in helper functions 5. Return proper tuples — {:ok, socket} from mount, {:noreply, socket} from handleevent 6. Use with for error handling in event handlers — assign errors to socket, don't crash 7. Never use autoupload: true with form submission — manual uploads are required to control when files are consumed relative to form data 8. Check corecomponents.ex for existing components before creating custom ones 9. Never query the database directly from LiveViews — call context functions instead 10. Use streams for large collections — see liveview-streams skill for details
FCIS at this boundary
LiveView callbacks are the imperative shell. Delegate business rules to contexts/pure modules; only assign results and handle UI errors here.
❌ Bad: heavy logic in handleevent/3 (also uses legacy currentuser — prefer current_scope)
@impl true
def handle_event("save", %{"post" => params}, socket) do
title = String.trim(params["title"] || "")
status = if params["publish"] == "true", do: "published", else: "draft"
# legacy assign shape — do not copy; use current_scope in real apps
{:ok, post} = Repo.insert(%Post{title: title, status: status, user_id: socket.assigns.current_user.id})
{:noreply, assign(socket, :post, post)}
end
✅ Good: thin event → context → assign
@impl true
def handle_event("save", %{"post" => params}, socket) do
case Blog.create_post(socket.assigns.current_scope, params) do
{:ok, post} ->
{:noreply, socket |> assign(:post, post) |> put_flash(:info, "Saved")}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, :form, to_form(changeset))}
end
end
Recommended Build Order
- Define
mount/3— initialize all assigns with static defaults - Add
handle_params/3— set URL-dependent assigns, subscribe to PubSub - Write
render/1— reference only assigns initialized in steps 1–2 - Add
handle_event/3— implement user interactions with proper error handling - Verify static render — confirm no
KeyErrorbefore WebSocket connects
Critical Concept: Two-Phase Rendering
Both phases run mount → handle_params → render. Initialize all assigns to safe defaults in Phase 1 (connected?(socket) is false) so the static HTML never raises a KeyError. Side effects, PubSub, and timers only work in Phase 2 (connected?(socket) is true).
Mount Callback
@impl true
def mount(_params, _session, socket) do
socket =
socket
|> assign(:user, nil)
|> assign(:loading, false)
|> assign(:post_id, nil)
|> assign(:data, [])
if connected?(socket) do
Phoenix.PubSub.subscribe(MyApp.PubSub, "topic")
end
{:ok, socket}
end
Defer expensive operations to the connected phase:
@impl true
def mount(_params, _session, socket) do
socket =
if connected?(socket) do
assign(socket, :data, run_expensive_query())
else
assign(socket, :data, [])
end
{:ok, socket}
end
✅ Validation checkpoint: Verify all assigns used in render/1 are initialized — the static render must display without a KeyError.
Handle Event
@impl true
def handle_event("delete", %{"id" => id}, socket) do
case Blog.delete_post(socket.assigns.current_scope, id) do
{:ok, _post} ->
{:noreply, assign(socket, :posts, Blog.list_posts(socket.assigns.current_scope))}
{:error, _reason} ->
{:noreply, put_flash(socket, :error, "Could not delete post")}
end
end
For create/update events, use the Error Handling pattern below — assign changeset errors to the socket rather than raising.
✅ Validation checkpoint: Each handler must return {:noreply, socket}; error paths assign errors to the socket rather than raising.
Handle Info
Match on the message shape and update assigns accordingly:
@impl true
def handle_info({:post_updated, post}, socket) do
{:noreply, assign(socket, :post, post)}
end
Handle Params
Called in BOTH render phases on URL changes. Place URL-dependent assigns here so they are available in both static and connected renders.
@impl true
def handle_params(%{"id" => id}, _uri, socket) do
case Blog.fetch_post(socket.assigns.current_scope, id) do
{:error, :not_found} ->
{:noreply, push_navigate(socket, to: ~p"/posts")}
{:ok, post} ->
old_id = socket.assigns[:post_id]
socket =
if connected?(socket) and not is_nil(old_id) and old_id != post.id do
Phoenix.PubSub.unsubscribe(MyApp.PubSub, "post:#{old_id}")
socket
else
socket
end
if connected?(socket) do
Phoenix.PubSub.subscribe(MyApp.PubSub, "post:#{post.id}")
end
{:noreply,
socket
|> assign(:post, post)
|> assign(:post_id, post.id)}
end
end
@impl true
def handle_params(_params, _uri, socket) do
{:noreply, socket}
end
Socket Assigns
Assigns drive rendering. Use assign/3 for direct values and update/3 when the new value depends on the current one. In render/1, direct @key access is safe when the assign is initialized in mount; in helper functions, guard optional assigns with Map.get/3 to avoid KeyError.
Single assign:
socket = assign(socket, :count, 0)
Multiple assigns at once:
socket =
socket
|> assign(:user, user)
|> assign(:loading, false)
|> assign(:posts, [])
Incremental change — depend on the current value:
❌ Bad — reads the assign manually, then re-assigns:
count = socket.assigns.count
assign(socket, :count, count + 1)
✅ Good — update/3 transforms the current value:
update(socket, :count, fn count -> count + 1 end)
Safe helper access:
❌ Bad — a maybe-missing @current_user raises KeyError:
defp format_user(socket) do
socket.assigns.current_user.name
end
✅ Good — guard optional assigns with Map.get/3:
defp format_user(socket) do
case Map.get(socket.assigns, :current_user, nil) do
nil -> "Guest"
user -> user.name
end
end
Live Navigation
# Full page reload (new LiveView)
{:noreply, push_navigate(socket, to: ~p"/users")}
# Patch (same LiveView, different params)
{:noreply, push_patch(socket, to: ~p"/posts/#{post}")}
Form Binding
<.simple_form for={@form} phx-change="validate" phx-submit="save">
<.input field={@form[:title]} label="Title" />
<.input field={@form[:body]} type="textarea" label="Body" />
<:actions>
<.button>Save</.button>
</:actions>
</.simple_form>
@impl true
def mount(_params, _session, socket) do
{:ok, assign(socket, form: to_form(Blog.change_post(%Post{})))}
end
@impl true
def handle_event("validate", %{"post" => params}, socket) do
changeset =
%Post{}
|> Blog.change_post(params)
|> Map.put(:action, :validate)
{:noreply, assign(socket, form: to_form(changeset))}
end
Error Handling
@impl true
def handle_event("save", %{"post" => post_params}, socket) do
case Blog.create_post(socket.assigns.current_scope, post_params) do
{:ok, post} ->
socket =
socket
|> put_flash(:info, "Created!")
|> assign(:post, post)
{:noreply, socket}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, form: to_form(changeset))}
{:error, reason} ->
{:noreply, put_flash(socket, :error, "An error occurred: #{reason}")}
end
end
Related skills: liveview-streams, phoenix-pubsub-patterns, phoenix-liveview-auth, phoenix-scopes, testing-essentials.
When Not to Use
- Static pages or controller/view patterns — no LiveView module or
.heextemplate involved - Large collection rendering (100+ items) — use
liveview-streamsinstead for DOM efficiency - Authentication flows — use
phoenix-liveview-authandphoenix-scopesinstead
Testing LiveViews
Drive the full lifecycle with Phoenix.LiveViewTest — mount, render, events, and navigation. See [assets/liveviewtesttemplate.md](assets/liveviewtesttemplate.md) for a LiveView test template and [assets/componenttesttemplate.md](assets/componenttesttemplate.md) for a function-component test template.
Common Pitfalls
| ❌ Don't | ✅ Do |
|---|---|
Access an assign in render/1 that was never initialized |
Initialize every assign in mount/3 or handle_params/3 before it is rendered |
| Subscribe to PubSub or start timers in the static mount | Guard side effects with if connected?(socket) |
| Query the database directly from the LiveView | Call context functions (e.g. Posts.list_posts/0) instead |
| Read an assign then re-assign it to increment | Use update/3 to transform the current value |
Use @key for an optional assign in a helper |
Use Map.get(assigns, :key, default) to avoid KeyError |
| Return a bare socket or the wrong tuple from a callback | Return {:ok, socket} from mount, {:noreply, socket} from handlers |
Omit @impl true on a callback |
Add @impl true before mount, handleevent, handleinfo, handle_params |
Integration
| Predecessor | This Skill | Successor |
|---|---|---|
| elixir-essentials | phoenix-liveview-essentials | liveview-streams |
| ecto-changeset-patterns | phoenix-liveview-essentials | phoenix-liveview-auth |
Companion skills: liveview-streams, phoenix-scopes, phoenix-pubsub-patterns, testing-essentials.