home / skills / j-morgan6 / elixir-phoenix-guide / phoenix-liveview-essentials

phoenix-liveview-essentials skill

/skills/phoenix-liveview-essentials

This skill enforces LiveView best practices, ensuring proper mounting, connected rendering, and safe state management for reliable interactive UI.

npx playbooks add skill j-morgan6/elixir-phoenix-guide --skill phoenix-liveview-essentials

Review the files below or copy the command above to add this skill to your agents.

Files (1)
SKILL.md
11.3 KB
---
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

1. **Always add @impl true** before every callback (mount, handle_event, handle_info, render)
2. **Initialize ALL assigns in mount/3** — never access uninitialized assigns in render/1
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 handle_event
6. **Use `with` for error handling** in event handlers — assign errors to socket, don't crash
7. **Never use auto_upload: true with form submission** — use manual uploads instead

---

## Critical Concept: Two-Phase Rendering

**LiveView renders happen in TWO phases:**

1. **Static/Disconnected Render** - Initial HTTP request
   - No WebSocket connection
   - `connected?(socket)` returns `false`
   - Side effects (PubSub, timers) won't work

2. **Connected Render** - WebSocket established
   - Full live functionality active
   - `connected?(socket)` returns `true`
   - Events and live updates work

**Common Bug:** Accessing uninitialized assigns during static render crashes with `KeyError`.

**Solution:** Initialize ALL assigns in mount/3 before using them.

---

## LiveView Lifecycle

### Mount Callback

```elixir
@impl true
def mount(_params, _session, socket) do
  # Initialize ALL assigns first
  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.

### Handle Event

Use pattern matching for different actions.

```elixir
@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 Info

Handle async messages and PubSub broadcasts.

```elixir
@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
```

### Handle Params

Respond to URL changes (called in BOTH render phases).

```elixir
@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
```

## Socket Assigns

Use `assign/2` or `assign/3` to update socket state.

```elixir
# 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))
```

### Safe Assign Access

**In render/1:** Direct access is safe if initialized in mount.

```elixir
@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.

```elixir
# ❌ 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
```

## Temporary Assigns

Use temporary assigns for large collections that don't need to persist.

```elixir
@impl true
def mount(_params, _session, socket) do
  socket = assign(socket, :posts, [])
  {:ok, socket, temporary_assigns: [posts: []]}
end
```

## Flash Messages

Use `put_flash/3` and `clear_flash/2` for user feedback.

```elixir
@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
```

## Live Navigation

Use `push_navigate/2` or `push_patch/2` for navigation.

```elixir
# 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.

```elixir
@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
```

## Components

Extract reusable UI into function components.

```elixir
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.

```heex
<.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>
```

```elixir
@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
```

## Error Handling

Always handle errors gracefully in LiveViews.

```elixir
@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
```

### Error Boundaries

Handle errors in handle_event to prevent LiveView crashes.

```elixir
@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
```

## PubSub Broadcasting

Use PubSub for real-time updates across LiveViews.

```elixir
# 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
```

## Testing LiveViews

Use `Phoenix.LiveViewTest` for testing.

```elixir
test "creates post successfully", %{conn: conn} do
  {:ok, lv, _html} = live(conn, "/posts/new")

  lv
  |> form("#post-form", post: %{title: "Test", body: "Content"})
  |> render_submit()

  assert has_element?(lv, "p", "Saved successfully")
end

test "initializes assigns in mount", %{conn: conn} do
  {:ok, view, _html} = live(conn, "/page")

  # Check assigns are set
  assert view.assigns.count == 0
  assert view.assigns.user != nil
end
```

## Common Lifecycle Mistakes

### ❌ Mistake 1: Assuming Assigns Exist

```elixir
def render(assigns) do
  ~H"""
  <p>Count: <%= @count %></p>  <!-- Crash if @count not initialized -->
  """
end
```

### ✅ Fix: Initialize in mount

```elixir
@impl true
def mount(_params, _session, socket) do
  {:ok, assign(socket, :count, 0)}
end
```

### ❌ Mistake 2: Subscribing in Both Phases

```elixir
@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
```

### ✅ Fix: Check connected?

```elixir
@impl true
def mount(_params, _session, socket) do
  if connected?(socket) do
    Phoenix.PubSub.subscribe(MyApp.PubSub, "topic")
  end

  {:ok, socket}
end
```

### ❌ Mistake 3: Expensive Operations in Both Phases

```elixir
@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
```

### ✅ Fix: Defer to connected phase

```elixir
@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
```

## 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

```elixir
# ✅ 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: ...
```

### Unsafe Patterns

```elixir
# ❌ 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: ...
```

Overview

This skill is a mandatory checklist and compact reference for building reliable Phoenix LiveViews. It enforces lifecycle best practices, assign initialization, and safe side-effect handling to prevent common runtime crashes. Use it before writing LiveView modules or .heex templates to avoid typical pitfalls and ensure predictable behavior.

How this skill works

The skill inspects your LiveView patterns against a set of hard rules: always add @impl true to callbacks, initialize all assigns in mount/3, and guard side effects with connected?(socket). It explains two-phase rendering, when mount and handle_params run twice, and shows safe patterns for assigns, PubSub subscriptions, streams, forms, and error handling. Follow its examples to return correct tuples, use with for error flows, and avoid unsafe operations during the static render phase.

When to use it

  • Before creating any LiveView module or .heex template
  • When initializing assigns and planning side effects (PubSub, timers, expensive queries)
  • When implementing mount/3, handle_event/3, handle_info/2, or handle_params/3
  • When wiring forms, uploads, streams, or live navigation
  • When testing LiveViews with Phoenix.LiveViewTest

Best practices

  • Always add @impl true before every LiveView callback to satisfy compiler checks
  • Initialize every assign in mount/3 so render/1 never reads uninitialized data
  • Check connected?(socket) before subscribing, starting timers, or running expensive queries
  • Use Map.get(socket.assigns, :key, default) in helpers for optional assigns
  • Return correct tuples: {:ok, socket} from mount, {:noreply, socket} from event/info handlers
  • Use with or pattern matching to handle errors and assign failures to the socket instead of crashing

Example use cases

  • Mount a posts LiveView: initialize posts to [] and subscribe to PubSub only when connected
  • Build a form LiveView: assign form = to_form(changeset) in mount and validate with phx-change handlers
  • Stream large lists: use stream(socket, :posts, list) and stream_insert/stream_delete in handle_event
  • Handle broadcasts: subscribe in mount when connected and update assigns in handle_info
  • Implement navigation: use push_navigate/2 for full LiveView changes and push_patch/2 for intra-view patches

FAQ

Why must mount run assignments even when not connected?

LiveView does a static render on the initial HTTP request. If assigns are missing the static render can crash with KeyError, so mount must set safe defaults for both phases.

When should I subscribe to PubSub or start timers?

Only when connected?(socket) is true. Subscribing or starting timers during the static render has no effect and can cause duplicate or expensive operations.