Apply Phoenix LiveView Conventions
Use this skill when writing new LiveView modules or modifying existing LiveView code to ensure consistent, idiomatic Phoenix patterns.
Precondition: Invoke phoenix-liveview-essentials before this skill for the full callback lifecycle reference.
Canonical FP bar: 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/3, handle_event/3, handle_info/2, handle_params/3, render/1)
2. Initialize every assign to a safe default in mount/3 — the disconnected render must never raise KeyError
3. Guard side effects with if connected?(socket) — PubSub subscriptions, timers, and async work run only when the WebSocket is connected
4. Return {:noreply, socket} from handle_event/3 and handle_info/2 — never {:reply, ...} unless replying to a client push
5. Assign errors to the socket via put_flash or changeset assigns — never raise inside a callback
6. Export reusable function components with def, not defp — private components cannot be used across templates
7. Use with for 2+ sequential fallible operations instead of nested case
8. Never query Repo directly from a LiveView — delegate to context functions
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 handle_event/3
@impl true
def handle_event("save", %{"post" => params}, socket) do
title = String.trim(params["title"] || "")
status = if params["publish"] == "true", do: "published", else: "draft"
{:ok, post} = Repo.insert(%Post{title: title, status: status, user_id: socket.assigns.current_user.id # legacy — prefer current_scope})
{: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
End-to-End Workflow — Creating or Modifying a LiveView
- Define
mount/3with safe defaults — initialize all assigns so disconnected render never raises aKeyError - Add
handle_params/3for URL-dependent assigns — guard any PubSub subscriptions withconnected?(socket) - Verify disconnected render — confirm static HTML renders without errors before WebSocket connects
- Add
handle_event/handle_infocallbacks — one@impl trueper callback; usewithfor 2+ fallible steps - Extract reusable markup into exported function components — use
def, notdefp - Check error paths — every failure branch assigns errors to the socket via
put_flashor changeset assigns; neverraise
Conventions — Quick Reference
| Pattern | Convention |
|---|---|
@impl true |
Before every callback (mount, handle_event, handle_info, handle_params, render) |
| Assigns in mount | Static defaults in mount; URL-dependent in handle_params |
| Side effects | Guard with if connected?(socket) — only run when WebSocket connected |
| Return value | {:noreply, socket} from handle_event/handle_info |
| Error handling | Assign errors to socket with put_flash; never raise |
| Components | Use def (exported), not defp |
| Multi-step errors | Use with instead of nested case |
| Database access | Call context functions only — never query Repo directly from a LiveView |
Two-Phase Rendering
LiveView renders twice per page load:
| Phase | Request | connected?(socket) |
Side effects |
|---|---|---|---|
| Disconnected | HTTP | false |
No PubSub, no timers |
| Connected | WebSocket | true |
PubSub, timers, async work |
Always initialize assigns to safe defaults in Phase 1 so the static HTML never raises a KeyError before WebSocket connects.
Mount Callback
✅ Good — @impl true, safe defaults, guarded side effects:
@impl true
def mount(_params, _session, socket) do
socket =
socket
|> assign(:user, nil)
|> assign(:loading, false)
|> assign(:notifications, [])
if connected?(socket) do
Phoenix.PubSub.subscribe(MyApp.PubSub, "notifications")
end
{:ok, socket}
end
Checkpoint: Static HTML must render without KeyError before WebSocket connects.
Handle Event
✅ Good — @impl true, {:noreply, socket}, assign errors to socket:
@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
With for Multi-Step Error Handling
❌ Bad — nested case for 2+ fallible operations:
def handle_event("process", %{"id" => id}, socket) do
case Items.get_item(id) do
{:ok, item} ->
case Items.process(item) do
{:ok, result} -> {:noreply, assign(socket, :result, result)}
{:error, reason} -> {:noreply, put_flash(socket, :error, "Failed")}
end
{:error, :not_found} -> {:noreply, put_flash(socket, :error, "Not found")}
end
end
✅ Good — with for 2+ sequential fallible operations:
@impl true
def handle_event("process", %{"id" => id}, socket) do
with {:ok, item} <- Items.get_item(id),
{:ok, result} <- Items.process(item) do
{:noreply, assign(socket, :result, result)}
else
{:error, :not_found} ->
{:noreply, put_flash(socket, :error, "Item not found")}
{:error, reason} ->
{:noreply, put_flash(socket, :error, "Processing failed: #{inspect(reason)}")}
end
end
Handle Info
✅ Good — @impl true, use update for immutable assign changes:
@impl true
def handle_info({:item_updated, item}, socket) do
{:noreply, update(socket, :items, fn items -> [item | items] end)}
end
@impl true
def handle_info(%{event: "presence_diff"}, socket) do
{:noreply, assign(socket, :online_count, Presence.count())}
end
Handle Params
❌ Bad — no @impl true, side effects not guarded, wrong return tuple:
def handle_params(%{"id" => id}, _uri, socket) do
post = Posts.get_post!(id)
Phoenix.PubSub.subscribe(MyApp.PubSub, "post:#{id}")
{:reply, %{post: post}, assign(socket, :post, post)}
end
✅ Good — @impl true, guard connected? for subscriptions, return {:noreply, socket}:
@impl true
def handle_params(%{"id" => id}, _uri, socket) do
case Blog.fetch_post(socket.assigns.current_scope, id) do
{:ok, post} ->
if connected?(socket) do
Phoenix.PubSub.subscribe(MyApp.PubSub, "post:#{post.id}")
end
{:noreply, assign(socket, :post, post)}
{:error, :not_found} ->
{:noreply, push_navigate(socket, to: ~p"/posts")}
end
end
@impl true
def handle_params(_params, _uri, socket) do
{:noreply, socket}
end
HEEx Component Structure
Function Components (exported, reusable)
❌ Bad — defp makes the component unexportable and unusable across templates:
defmodule MyAppWeb.Components do
use Phoenix.Component
defp card(assigns) do
~H"""
<div class="card"><h3><%= @title %></h3></div>
"""
end
end
✅ Good — def (exported), reusable across templates:
defmodule MyAppWeb.Components do
use Phoenix.Component
def card(assigns) do
~H"""
<div class="card">
<h3><%= @title %></h3>
<p><%= @content %></p>
</div>
"""
end
def badge(assigns) do
~H"""
<span class={"badge badge-#{@variant}"}><%= @label %></span>
"""
end
end
Usage in templates:
<.card title="Hello" content="World" />
<.badge variant="success" label="Active" />
Slot Patterns for Children
❌ Bad — hardcoded children instead of slots:
def modal(assigns) do
~H"""
<div class="modal">
<header><%= @title %></header>
<div>Are you sure?</div>
<button phx-click="cancel">Cancel</button>
</div>
"""
end
✅ Good — use render_slot for flexible content:
def modal(assigns) do
~H"""
<div class="modal">
<header><%= @title %></header>
<%= render_slot(@inner_block) %>
<footer><%= render_slot(@footer) %></footer>
</div>
"""
end
Usage:
<.modal title="Confirm">
Are you sure?
<:footer>
<button phx-click="cancel">Cancel</button>
</:footer>
</.modal>
Form Binding
✅ Good — @impl true, separate validate event, assign changeset on error:
@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
@impl true
def handle_event("save", %{"post" => params}, socket) do
case Blog.create_post(socket.assigns.current_scope, params) do
{:ok, _post} ->
{:noreply, put_flash(socket, :info, "Created!")}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, form: to_form(changeset))}
end
end
<.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>
Socket Assigns — Best Practices
✅ Good — use assign/update for immutable changes:
# Single assign
socket = assign(socket, :count, 0)
# Multiple assigns
socket = assign(socket, count: 0, name: "User", active: true)
# Update existing
socket = update(socket, :count, &(&1 + 1))
In render/1 — direct access is safe when initialized in mount:
@impl true
def render(assigns) do
~H"""<p>Count: <%= @count %></p>"""
end
In helper functions — use Map.get for optional assigns:
defp format_user(socket) do
case Map.get(socket.assigns, :current_user) do
nil -> "Guest"
user -> user.name
end
end
Common Pitfalls
| ❌ Don't | ✅ Do |
|---|---|
Access an assign in render/1 that mount/3 never set |
Initialize every assign in mount/3 before the first render |
Subscribe to PubSub unconditionally in mount/3 |
Wrap subscriptions in if connected?(socket) |
Omit @impl true on callbacks |
Add @impl true above every callback |
Define a reusable component with defp |
Export it with def so templates can call it |
Nest case for multi-step operations |
Use with and handle failures in else |
raise on a failed operation inside a callback |
Assign the error to the socket via put_flash |
Call Repo directly inside a LiveView |
Delegate to a context function |
Call Post.changeset/2 from the LiveView |
Call Blog.change_post/2 |
Integration
| Predecessor | This Skill | Successor |
|---|---|---|
| elixir-essentials | apply-phoenix-liveview-conventions | code-quality |
| phoenix-liveview-essentials | apply-phoenix-liveview-conventions | testing-essentials |
Companion skills:
phoenix-liveview-essentials— deep LiveView callback lifecycle referenceliveview-streams— large collection rendering (100+ items)phoenix-pubsub-patterns— PubSub subscription managementphoenix-liveview-auth— authentication in LiveViews