Skill v1.0.0
currentAutomated scan100/100version: "1.0.0" name: phoenix-liveview-essentials description: MANDATORY for ALL LiveView work. Invoke before writing LiveView modules or .heex templates. file_patterns:
- "/live//*.ex"
- "**/*_live.ex"
- "**/*.html.heex"
- "/live//*.html.heex"
auto_suggest: true
Phoenix LiveView Essentials
RULES — Follow these with no exceptions
- Always add @impl true before every callback (mount, handle_event, handle_info, render)
- Initialize assigns before they're accessed in render/1 — use mount/3 for static defaults, handle_params/3 for URL-dependent assigns (pagination, filters, sorting)
- Check connected?(socket) before PubSub subscriptions, timers, or side effects
- Use Map.get(assigns, :key, default) for optional assigns in helper functions
- Return proper tuples —
{:ok, socket}from mount,{:noreply, socket}from handle_event - Use `with` for error handling in event handlers — assign errors to socket, don't crash
- Never use auto_upload: true with form submission — use manual uploads instead
- Check `core_components.ex` for existing components before creating custom ones
- Never query the database directly from LiveViews — call context functions instead
Critical Concept: Two-Phase Rendering
LiveView renders happen in TWO phases:
- Static/Disconnected Render - Initial HTTP request
- No WebSocket connection
connected?(socket)returnsfalse- Side effects (PubSub, timers) won't work
- Connected Render - WebSocket established
- Full live functionality active
connected?(socket)returnstrue- Events and live updates work
Common 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.
LiveView Lifecycle
Mount Callback
@impl truedef mount(_params, _session, socket) do# Initialize static defaults here; URL-dependent assigns go in handle_paramssocket =socket|> assign(:user, nil)|> assign(:loading, false)|> assign(:data, [])# Only subscribe when connectedif connected?(socket) doPhoenix.PubSub.subscribe(MyApp.PubSub, "topic")end{:ok, socket}end
Why check connected? PubSub subscriptions and timers only work with WebSocket connection.
Handle Event
Use pattern matching for different actions.
@impl truedef handle_event("save", %{"post" => post_params}, socket) docase 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)}endend@impl truedef handle_event("delete", %{"id" => id}, socket) doPosts.delete_post(id){:noreply, assign(socket, :posts, Posts.list_posts())}end
Handle Info
Handle async messages and PubSub broadcasts.
@impl truedef handle_info({:post_created, post}, socket) do{:noreply, update(socket, :posts, fn posts -> [post | posts] end)}end@impl truedef handle_info(%{event: "presence_diff"}, socket) do{:noreply, assign(socket, :online_users, get_presence_count())}end
Handle Params
Respond to URL changes (called in BOTH render phases).
@impl truedef handle_params(%{"id" => id}, _uri, socket) do# This runs during static AND connected renderpost = Posts.get_post!(id)if connected?(socket) do# Only subscribe when connectedPhoenix.PubSub.subscribe(MyApp.PubSub, "post:#{id}")end{:noreply, assign(socket, :post, post)}end@impl truedef handle_params(_params, _uri, socket) do{:noreply, socket}end
Socket Assigns
Use assign/2 or assign/3 to update socket state.
# Single assignsocket = assign(socket, :count, 0)# Multiple assignssocket = assign(socket, count: 0, name: "User", active: true)# Update existing assignsocket = update(socket, :count, &(&1 + 1))
Safe Assign Access
In render/1: Direct access is safe if initialized in mount.
@impl truedef mount(_params, _session, socket) do{:ok, assign(socket, :count, 0)}end@impl truedef 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 :namedefp format_user(%{name: name}), do: name# ✅ GOOD - Handles nil casedefp format_user(socket) docase Map.get(socket.assigns, :current_user) donil -> "Guest"user -> user.nameendend
Temporary Assigns
Use temporary assigns for large collections that don't need to persist.
@impl truedef mount(_params, _session, socket) dosocket = assign(socket, :posts, []){:ok, socket, temporary_assigns: [posts: []]}end
Flash Messages
Use put_flash/3 and clear_flash/2 for user feedback.
@impl truedef handle_event("save", params, socket) docase 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}endend
Live Navigation
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}")}
Streams
Use streams for efficient rendering of large lists.
@impl truedef mount(_params, _session, socket) do{:ok, stream(socket, :posts, Posts.list_posts())}end@impl truedef handle_event("add", %{"post" => attrs}, socket) do{:ok, post} = Posts.create_post(attrs){:noreply, stream_insert(socket, :posts, post, at: 0)}end@impl truedef handle_event("delete", %{"id" => id}, socket) doPosts.delete_post(id){:noreply, stream_delete_by_dom_id(socket, :posts, "posts-#{id}")}end
Components
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" />
Form Binding
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 truedef mount(_params, _session, socket) dochangeset = Post.changeset(%Post{}, %{}){:ok, assign(socket, form: to_form(changeset))}end@impl truedef handle_event("validate", %{"post" => params}, socket) dochangeset =%Post{}|> Post.changeset(params)|> Map.put(:action, :validate){:noreply, assign(socket, form: to_form(changeset))}end
Error Handling
Always handle errors gracefully in LiveViews.
@impl truedef handle_event("risky_operation", _params, socket) docase perform_operation() do{:ok, result} ->{:noreply, assign(socket, :result, result)}{:error, reason} ->{:noreply, put_flash(socket, :error, "Operation failed: #{reason}")}endend
Error Boundaries
Handle errors in handle_event to prevent LiveView crashes.
@impl truedef handle_event("save", params, socket) docase 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}endend
PubSub Broadcasting
Use PubSub for real-time updates across LiveViews.
# Subscribe in mount@impl truedef mount(_params, _session, socket) doif connected?(socket) doPhoenix.PubSub.subscribe(MyApp.PubSub, "posts")end{:ok, assign(socket, :posts, list_posts())}end# Broadcast when data changesdef create_post(attrs) dowith {:ok, post} <- Repo.insert(changeset) doPhoenix.PubSub.broadcast(MyApp.PubSub, "posts", {:post_created, post}){:ok, post}endend# Handle broadcast@impl truedef handle_info({:post_created, post}, socket) do{:noreply, update(socket, :posts, fn posts -> [post | posts] end)}end
Testing
When writing LiveView tests, invoke elixir-phoenix-guide:testing-essentials before writing any _test.exs file.
Common Lifecycle Mistakes
❌ Mistake 1: Assuming Assigns Exist
def render(assigns) do~H"""<p>Count: <%= @count %></p> <!-- Crash if @count not initialized -->"""end
✅ Fix: Initialize before render (mount or handle_params)
@impl truedef mount(_params, _session, socket) do{:ok, assign(socket, :count, 0)}end
❌ Mistake 2: Subscribing in Both Phases
@impl truedef mount(_params, _session, socket) do# BAD - Subscribes during static render (doesn't work)Phoenix.PubSub.subscribe(MyApp.PubSub, "topic"){:ok, socket}end
✅ Fix: Check connected?
@impl truedef mount(_params, _session, socket) doif connected?(socket) doPhoenix.PubSub.subscribe(MyApp.PubSub, "topic")end{:ok, socket}end
❌ Mistake 3: Expensive Operations in Both Phases
@impl truedef mount(_params, _session, socket) do# BAD - Runs expensive query twice (static + connected)data = run_expensive_query(){:ok, assign(socket, :data, data)}end
✅ Fix: Defer to connected phase
@impl truedef mount(_params, _session, socket) dosocket =if connected?(socket) do# Only run when connectedassign(socket, :data, run_expensive_query())else# Placeholder for static renderassign(socket, :data, [])end{:ok, socket}end
Lifecycle Flow
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
Quick Reference
Safe Patterns
# ✅ Initialize in mountassign(socket, :key, default_value)# ✅ Use Map.get for optionalMap.get(socket.assigns, :key, default)# ✅ Check connected for side effectsif connected?(socket), do: subscribe()# ✅ Pattern match with fallbackdef helper(%{name: name}), do: namedef helper(_), do: "default"# ✅ Add @impl true@impl truedef mount(...), do: ...
Unsafe Patterns
# ❌ Direct access without initializationsocket.assigns.key# ❌ Subscribe without checkingPhoenix.PubSub.subscribe(...)# ❌ Expensive ops in both phasesmount(...) dodata = expensive_query()end# ❌ Missing @impl truedef mount(...), do: ...