liveview-lifecycle

SKILL.md

LiveView Rendering Lifecycle

Critical Understanding

LiveView renders happen in TWO phases:

  1. Static/Disconnected Render - Initial HTTP request, server-side HTML

    • No WebSocket connection
    • No live functionality yet
    • connected?(socket) returns false
  2. Connected Render - WebSocket established, full LiveView active

    • Live updates work
    • Events are handled
    • connected?(socket) returns true

The Problem: Uninitialized Assigns

During static render, socket assigns may not be fully initialized:

# ❌ DANGEROUS - Can crash during static render
def render(assigns) do
  user_name = assigns.current_user.name  # KeyError if not set!
  ~H"<p>Hello <%= user_name %></p>"
end

Error: KeyError: key :current_user not found

The Solution: Defensive Access

Always use safe access patterns:

# ✅ SAFE - Works in both render phases
def render(assigns) do
  user_name = Map.get(assigns, :current_user, %{name: "Guest"}).name
  ~H"<p>Hello <%= user_name %></p>"
end

# ✅ BETTER - Initialize in mount
@impl true
def mount(_params, session, socket) do
  socket = assign(socket, :current_user, get_user(session))
  {:ok, socket}
end

Best Practices

1. Initialize All Assigns in mount/3

Always initialize every assign you'll use:

@impl true
def mount(_params, _session, socket) do
  socket =
    socket
    |> assign(:user, nil)
    |> assign(:loading, false)
    |> assign(:data, [])
    |> assign(:error, nil)

  {:ok, socket}
end

2. Use Map.get for Optional Assigns

When accessing assigns that might not exist:

# ❌ BAD
defp render_user(socket) do
  socket.assigns.current_user.name
end

# ✅ GOOD
defp render_user(socket) do
  case Map.get(socket.assigns, :current_user) do
    nil -> "Guest"
    user -> user.name
  end
end

# ✅ ALSO GOOD
defp render_user(socket) do
  Map.get(socket.assigns, :current_user, %{name: "Guest"}).name
end

3. Use Pattern Matching Safely

# ❌ BAD - Crashes if not a map with :name
defp format_user(%{name: name}), do: name

# ✅ GOOD - Handles nil case
defp format_user(%{name: name}), do: name
defp format_user(_), do: "Unknown"

# ✅ ALSO GOOD - Check first
defp format_user(user) when is_map(user), do: Map.get(user, :name, "Unknown")
defp format_user(_), do: "Unknown"

4. Use assigns_to_attributes for Components

# Component with dynamic assigns
def card(assigns) do
  ~H"""
  <div class="card" {@rest}>
    <%= render_slot(@inner_block) %>
  </div>
  """
end

# Usage
<.card id="my-card" data-role="admin">
  Content
</.card>

Connected Check

Use connected?/1 for operations that only work with WebSocket:

@impl true
def mount(_params, _session, socket) do
  socket =
    if connected?(socket) do
      # Only run when WebSocket is connected
      Phoenix.PubSub.subscribe(MyApp.PubSub, "updates")
      schedule_refresh()
      socket
    else
      # Static render - skip expensive operations
      socket
    end

  socket = assign(socket, :data, load_initial_data())
  {:ok, socket}
end

Why? PubSub subscriptions, timers, and live updates only work when connected.

Handle Params Considerations

handle_params/3 is 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)  # OK - database queries work in both phases

  if connected?(socket) do
    # Only subscribe when connected
    Phoenix.PubSub.subscribe(MyApp.PubSub, "post:#{id}")
  end

  {:noreply, assign(socket, :post, post)}
end

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 in mount

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

def render(assigns) do
  ~H"""
  <p>Count: <%= @count %></p>  <!-- Safe now -->
  """
end

❌ Mistake 2: Subscribing in Both Phases

@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?

@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 Static Render

@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

@impl true
def mount(_params, _session, socket) do
  socket =
    if connected?(socket) do
      # Only run expensive operations when connected
      assign(socket, :data, run_expensive_query())
    else
      # Use placeholder data 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

Debugging Tips

Check if LiveView is connected

def render(assigns) do
  ~H"""
  <div data-connected={@connected?}>
    <!-- Shows connection state -->
  </div>
  """
end

@impl true
def mount(_params, _session, socket) do
  {:ok, assign(socket, :connected?, connected?(socket))}
end

Log render phases

@impl true
def mount(_params, _session, socket) do
  IO.puts("Mount called - Connected: #{connected?(socket)}")
  {:ok, socket}
end

@impl true
def handle_params(params, _uri, socket) do
  IO.puts("Handle params - Connected: #{connected?(socket)}")
  {:noreply, socket}
end

Safe Assign Access Helpers

Create helper functions for safe access:

defp get_assign(socket, key, default \\ nil) do
  Map.get(socket.assigns, key, default)
end

defp has_assign?(socket, key) do
  Map.has_key?(socket.assigns, key)
end

# Usage
def some_function(socket) do
  if has_assign?(socket, :current_user) do
    user = get_assign(socket, :current_user)
    # Do something with user
  end
end

Testing Both Phases

test "renders correctly in both phases", %{conn: conn} do
  # Static render (disconnected)
  {:ok, _view, html} = live(conn, "/page")
  assert html =~ "Expected content"

  # Now connected
  # Can test live interactions
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

Quick Reference

Safe Patterns

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

Unsafe Patterns

# ❌ Direct access without initialization
socket.assigns.key

# ❌ Subscribe without checking
Phoenix.PubSub.subscribe(...)

# ❌ Expensive ops in both phases
mount(...) do
  data = expensive_query()
end

# ❌ Pattern match without fallback
def helper(%{name: name}), do: name
Weekly Installs
5
GitHub Stars
77
First Seen
Jan 29, 2026
Installed on
codex4
github-copilot3
amp2
opencode2
kimi-cli2
gemini-cli2