From elixir-phoenix-guide
Enforces Phoenix LiveView best practices: @impl true callbacks, assign initialization in mount/handle_params, connected? checks, proper tuples, and two-phase rendering awareness. Invoke before LiveView modules or .heex templates.
npx claudepluginhub j-morgan6/elixir-phoenix-guide --plugin elixir-phoenix-guideThis skill uses the workspace's default tool permissions.
1. **Always add @impl true** before every callback (mount, handle_event, handle_info, render)
Searches, retrieves, and installs Agent Skills from prompts.chat registry using MCP tools like search_skills and get_skill. Activates for finding skills, browsing catalogs, or extending Claude.
Searches prompts.chat for AI prompt templates by keyword or category, retrieves by ID with variable handling, and improves prompts via AI. Use for discovering or enhancing prompts.
Checks Next.js compilation errors using a running Turbopack dev server after code edits. Fixes actionable issues before reporting complete. Replaces `next build`.
{:ok, socket} from mount, {:noreply, socket} from handle_eventwith for error handling in event handlers — assign errors to socket, don't crashcore_components.ex for existing components before creating custom onesLiveView renders happen in TWO phases:
Static/Disconnected Render - Initial HTTP request
connected?(socket) returns falseConnected Render - WebSocket established
connected?(socket) returns trueCommon Bug: Accessing uninitialized assigns during static render crashes with KeyError.
Solution: Initialize assigns before render — use mount/3 for static defaults, handle_params/3 for URL-dependent state.
@impl true
def mount(_params, _session, socket) do
# Initialize static defaults here; URL-dependent assigns go in handle_params
socket =
socket
|> assign(:user, nil)
|> assign(:loading, false)
|> assign(:data, [])
# Only subscribe when connected
if connected?(socket) do
Phoenix.PubSub.subscribe(MyApp.PubSub, "topic")
end
{:ok, socket}
end
Why check connected? PubSub subscriptions and timers only work with WebSocket connection.
Use pattern matching for different actions.
@impl true
def handle_event("save", %{"post" => post_params}, socket) do
case Posts.create_post(post_params) do
{:ok, post} ->
socket =
socket
|> put_flash(:info, "Created!")
|> assign(:post, post)
{:noreply, socket}
{:error, changeset} ->
{:noreply, assign(socket, :changeset, changeset)}
end
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
Posts.delete_post(id)
{:noreply, assign(socket, :posts, Posts.list_posts())}
end
Handle async messages and PubSub broadcasts.
@impl true
def handle_info({:post_created, post}, socket) do
{:noreply, update(socket, :posts, fn posts -> [post | posts] end)}
end
@impl true
def handle_info(%{event: "presence_diff"}, socket) do
{:noreply, assign(socket, :online_users, get_presence_count())}
end
Respond to URL changes (called in BOTH render phases).
@impl true
def handle_params(%{"id" => id}, _uri, socket) do
# This runs during static AND connected render
post = Posts.get_post!(id)
if connected?(socket) do
# Only subscribe when connected
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
Use assign/2 or assign/3 to update socket state.
# Single assign
socket = assign(socket, :count, 0)
# Multiple assigns
socket = assign(socket, count: 0, name: "User", active: true)
# Update existing assign
socket = update(socket, :count, &(&1 + 1))
In render/1: Direct access is safe if initialized in mount.
@impl true
def mount(_params, _session, socket) do
{:ok, assign(socket, :count, 0)}
end
@impl true
def render(assigns) do
~H"""
<p>Count: <%= @count %></p> <!-- Safe -->
"""
end
In helper functions: Use Map.get for optional assigns.
# ❌ BAD - Crashes if not a map with :name
defp format_user(%{name: name}), do: name
# ✅ GOOD - Handles nil case
defp format_user(socket) do
case Map.get(socket.assigns, :current_user) do
nil -> "Guest"
user -> user.name
end
end
Use temporary assigns for large collections that don't need to persist.
@impl true
def mount(_params, _session, socket) do
socket = assign(socket, :posts, [])
{:ok, socket, temporary_assigns: [posts: []]}
end
Use put_flash/3 and clear_flash/2 for user feedback.
@impl true
def handle_event("save", params, socket) do
case save_data(params) do
{:ok, _} ->
socket = put_flash(socket, :info, "Saved successfully!")
{:noreply, socket}
{:error, _} ->
socket = put_flash(socket, :error, "Failed to save")
{:noreply, socket}
end
end
Use push_navigate/2 or push_patch/2 for 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}")}
Use streams for efficient rendering of large lists.
@impl true
def mount(_params, _session, socket) do
{:ok, stream(socket, :posts, Posts.list_posts())}
end
@impl true
def handle_event("add", %{"post" => attrs}, socket) do
{:ok, post} = Posts.create_post(attrs)
{:noreply, stream_insert(socket, :posts, post, at: 0)}
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
Posts.delete_post(id)
{:noreply, stream_delete_by_dom_id(socket, :posts, "posts-#{id}")}
end
Extract reusable UI into function components.
def card(assigns) do
~H"""
<div class="card">
<h3><%= @title %></h3>
<p><%= @content %></p>
</div>
"""
end
# Usage in template
<.card title="Hello" content="World" />
Bind forms to changesets for validation.
<.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
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
Always handle errors gracefully in LiveViews.
@impl true
def handle_event("risky_operation", _params, socket) do
case perform_operation() do
{:ok, result} ->
{:noreply, assign(socket, :result, result)}
{:error, reason} ->
{:noreply, put_flash(socket, :error, "Operation failed: #{reason}")}
end
end
Handle errors in handle_event to prevent LiveView crashes.
@impl true
def handle_event("save", params, socket) do
case save_record(params) do
{:ok, record} ->
socket =
socket
|> put_flash(:info, "Saved successfully")
|> assign(:record, record)
{:noreply, socket}
{:error, %Ecto.Changeset{} = changeset} ->
socket =
socket
|> put_flash(:error, "Please correct the errors")
|> assign(:changeset, changeset)
{:noreply, socket}
{:error, reason} ->
socket = put_flash(socket, :error, "An error occurred: #{reason}")
{:noreply, socket}
end
end
Use PubSub for real-time updates across LiveViews.
# Subscribe in mount
@impl true
def mount(_params, _session, socket) do
if connected?(socket) do
Phoenix.PubSub.subscribe(MyApp.PubSub, "posts")
end
{:ok, assign(socket, :posts, list_posts())}
end
# Broadcast when data changes
def create_post(attrs) do
with {:ok, post} <- Repo.insert(changeset) do
Phoenix.PubSub.broadcast(MyApp.PubSub, "posts", {:post_created, post})
{:ok, post}
end
end
# Handle broadcast
@impl true
def handle_info({:post_created, post}, socket) do
{:noreply, update(socket, :posts, fn posts -> [post | posts] end)}
end
When writing LiveView tests, invoke elixir-phoenix-guide:testing-essentials before writing any _test.exs file.
def render(assigns) do
~H"""
<p>Count: <%= @count %></p> <!-- Crash if @count not initialized -->
"""
end
@impl true
def mount(_params, _session, socket) do
{:ok, assign(socket, :count, 0)}
end
@impl true
def mount(_params, _session, socket) do
# BAD - Subscribes during static render (doesn't work)
Phoenix.PubSub.subscribe(MyApp.PubSub, "topic")
{:ok, socket}
end
@impl true
def mount(_params, _session, socket) do
if connected?(socket) do
Phoenix.PubSub.subscribe(MyApp.PubSub, "topic")
end
{:ok, socket}
end
@impl true
def mount(_params, _session, socket) do
# BAD - Runs expensive query twice (static + connected)
data = run_expensive_query()
{:ok, assign(socket, :data, data)}
end
@impl true
def mount(_params, _session, socket) do
socket =
if connected?(socket) do
# Only run when connected
assign(socket, :data, run_expensive_query())
else
# Placeholder for static render
assign(socket, :data, [])
end
{:ok, socket}
end
1. HTTP Request arrives
↓
2. mount/3 called (connected? = false)
↓
3. handle_params/3 called (connected? = false)
↓
4. render/1 called (STATIC HTML generated)
↓
5. HTML sent to browser
↓
6. Browser connects WebSocket
↓
7. mount/3 called AGAIN (connected? = true)
↓
8. handle_params/3 called AGAIN (connected? = true)
↓
9. render/1 called (sent over WebSocket)
↓
10. LiveView now active and reactive
# ✅ Initialize in mount
assign(socket, :key, default_value)
# ✅ Use Map.get for optional
Map.get(socket.assigns, :key, default)
# ✅ Check connected for side effects
if connected?(socket), do: subscribe()
# ✅ Pattern match with fallback
def helper(%{name: name}), do: name
def helper(_), do: "default"
# ✅ Add @impl true
@impl true
def mount(...), do: ...
# ❌ Direct access without initialization
socket.assigns.key
# ❌ Subscribe without checking
Phoenix.PubSub.subscribe(...)
# ❌ Expensive ops in both phases
mount(...) do
data = expensive_query()
end
# ❌ Missing @impl true
def mount(...), do: ...