igmarin/elixir-phoenix-skills

apply-phoenix-liveview-conventions

Use when writing new LiveView code in Phoenix applications. Enforces consistent patterns for mount/handle_event/handle_info/handle_params callbacks, HEEx component structure, form binding, socket assigns, and error handling. Covers the two-phase rendering lifecycle, connected? guards, function components, and the assign-error-to-socket pattern. Trigger words: phoenix conventions, liveview conventions, apply phoenix patterns, liveview patterns, follow phoenix best practices, heex component, live…

First seen Jun 22, 2026

Installation

$ npx skills add igmarin/elixir-phoenix-skills --skill apply-phoenix-liveview-conventions

Similar popular skills

Related neighbors and high-traction skills in the same topics — useful to compare before installing.

Also in this package

Other skills from igmarin/elixir-phoenix-skills · top by installs.

npx skills add igmarin/elixir-phoenix-skills

Browse all from igmarin/elixir-phoenix-skills

More details

Agent compatibility

Declared targets from SKILL.md / docs. Unmarked agents are not listed — the skill may still install via the CLI.

Claude Code Not declared
Cursor Not declared
Codex Not declared
GitHub Copilot Not declared
Windsurf Not declared
Gemini CLI Not declared
Cline Not declared
OpenCode Not declared

Repository health

Stars 2
License LICENSE
Default branch main
Open issues 0
Status Active

Skill metadata

Parsed from SKILL.md frontmatter.

Version1.0.0
LicenseMIT
More metadata
version
1.0.0
user-invocable
true

Package contents

Files included with this skill beyond the listing page.

  • skill md SKILL.md 12,997 B
  • docs SUMMARY.md 595 B

History

  1. First seen on skills.sh
  2. First recorded snapshot · 20 installs

SKILL.md

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](../../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, handleevent/3, handleinfo/2, handleparams/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 handleevent/3 and handleinfo/2 — never {:reply, ...} unless replying to a client push 5. Assign errors to the socket via putflash 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 handleevent/handleinfo 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, handleevent, handleinfo, 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 handleevent/handleinfo
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