SuperAPI AI Directory

apply-phoenix-liveview-conventions

Writing & research

View repository

Updated 10-15 22:16

About this skill

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

  1. Define mount/3 with safe defaults — initialize all assigns so disconnected render never raises a KeyError
  2. Add handle_params/3 for URL-dependent assigns — guard any PubSub subscriptions with connected?(socket)
  3. Verify disconnected render — confirm static HTML renders without errors before WebSocket connects
  4. Add handle_event/handle_info callbacks — one @impl true per callback; use with for 2+ fallible steps
  5. Extract reusable markup into exported function components — use def, not defp
  6. 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 Blog.delete_post(socket.assigns.current_scope, id) do
    {:ok, _post} ->
      {:noreply, assign(socket, :posts, Blog.list_posts(socket.assigns.current_scope))}

    {: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
  case Blog.fetch_post(socket.assigns.current_scope, id) do
    {:ok, post} ->
      if connected?(socket) do
        Phoenix.PubSub.subscribe(MyApp.PubSub, "post:#{post.id}")
      end

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

    {:error, :not_found} ->
      {:noreply, push_navigate(socket, to: ~p"/posts")}
  end
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
  {:ok, assign(socket, form: to_form(Blog.change_post(%Post{})))}
end

@impl true
def handle_event("validate", %{"post" => params}, socket) do
  changeset =
    %Post{}
    |> Blog.change_post(params)
    |> Map.put(:action, :validate)

  {:noreply, assign(socket, form: to_form(changeset))}
end

@impl true
def handle_event("save", %{"post" => params}, socket) do
  case Blog.create_post(socket.assigns.current_scope, 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
Call Post.changeset/2 from the LiveView Call Blog.change_post/2

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