| name | apply-phoenix-liveview-conventions |
| type | atomic |
| tags | ["atomic"] |
| license | MIT |
| description | Use when writing new LiveView code in Phoenix applications. Enforces consistent patterns for mount/handle_event/handle_info/handle_params callbacks, HEEx component structure, form binding, socket assigns, and error handling. Covers the two-phase rendering lifecycle, connected? guards, function components, and the assign-error-to-socket pattern. Trigger words: phoenix conventions, liveview conventions, apply phoenix patterns,
liveview patterns, follow phoenix best practices, heex component, liveview mount, handle_event convention, phoenix liveview.
|
| metadata | {"version":"1.0.0","user-invocable":"true"} |
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/3 with safe defaults — initialize all assigns so disconnected render never raises a KeyError
- Add
handle_params/3 for URL-dependent assigns — guard any PubSub subscriptions with connected?(socket)
- Verify disconnected render — confirm static HTML renders without errors before WebSocket connects
- Add
handle_event/handle_info callbacks — one @impl true per callback; use with for 2+ fallible steps
- Extract reusable markup into exported function components — use
def, not defp
- Check error paths — every failure branch assigns errors to the socket via
put_flash or changeset assigns; never raise
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 Posts.delete_post(id) do
{:ok, _post} ->
{:noreply, assign(socket, :posts, Posts.list_posts())}
{: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
post = Posts.get_post!(id)
if connected?(socket) do
Phoenix.PubSub.subscribe(MyApp.PubSub, "post:#{id}")
end
{:noreply, assign(socket, :post, post)}
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
changeset = Post.changeset(%Post{}, %{})
{:ok, assign(socket, form: to_form(changeset))}
end
@impl true
def handle_event("validate", %{"post" => params}, socket) do
changeset =
%Post{}
|> Post.changeset(params)
|> Map.put(:action, :validate)
{:noreply, assign(socket, form: to_form(changeset))}
end
@impl true
def handle_event("save", %{"post" => params}, socket) do
case Posts.create_post(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 |
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 reference
liveview-streams — large collection rendering (100+ items)
phoenix-pubsub-patterns — PubSub subscription management
phoenix-liveview-auth — authentication in LiveViews