Skip to content
Development
Skill

/phoenix-liveview-essentials

Use when writing LiveView modules or HEEx templates — mount/handle_params lifecycle, forms with to_form and phx-change, streams, assign_async/start_async, JS commands, hooks, function components.

BOOST
From plugin
elixir-phoenix-guide
16924 skills3 hooks
Install
$ npx -y skills add j-morgan6/elixir-phoenix-guide --skill phoenix-liveview-essentials --agent claude-code

How it fires

How this skill gets triggered: by you, by Claude, or both.

  • Fires itselfAuto-invocation. Claude auto-loads it when your prompt matches the work.Auto-invocation is when the right skill fires by itself at the right moment, driven by a FLOW.md router and a hook, instead of you invoking it by name. It is the difference between a skill being installed and a skill actually getting used.Read the full definition →
  • You can call itInvoke it directly when you want it.
  • Slash command/phoenix-liveview-essentials

Context preview

The summary Claude sees to decide when to auto-load this skill.

Use when writing LiveView modules or HEEx templates — mount/handle_params lifecycle, forms with to_form and phx-change, streams, assign_async/start_async, JS commands, hooks, function components.

SKILL.md

phoenix-liveview-essentials.SKILL.md
name: phoenix-liveview-essentials
description: "Use when writing LiveView modules or HEEx templates — mount/handle_params lifecycle, forms with to_form and phx-change, streams, assign_async/start_async, JS commands, hooks, function components."

Phoenix LiveView Essentials

> Verified against Elixir 1.20, Phoenix 1.8, LiveView 1.2 — October 2026

RULES — Follow these with no exceptions

1. **`@impl true` before every callback** — `mount`, `handle_params`, `handle_event`, `handle_info`, `handle_async`, `render`. 2. **Pass `socket.assigns.current_scope` as the first argument to every context call** — `Blog.get_post!(scope, id)`, never `Blog.get_post!(id)`. Loading, updating or deleting by a client-supplied id without the scope is an IDOR. LiveViews call contexts, never `Repo`. 3. **Set every assign `render/1` reads before the first render** — static defaults in `mount/3`, URL-dependent ones (filter, sort, page) in `handle_params/3`. 4. **Subscribe and start timers in `mount/3` inside `if connected?(socket)`** — never in `handle_params/3`, which re-runs on every `push_patch` and stacks duplicate subscriptions. 5. **Load slow data with `assign_async`, `stream_async` or `start_async`** — not with `if connected?(socket)` branches or blocking queries in `mount/3`. 6. **Collections are streams** — `stream/4`, `stream_insert/4`, `stream_delete/3`, `stream(..., reset: true)` to refilter. The container needs an `id` and `phx-update="stream"`. 7. **Forms:** `to_form/2` into `@form`; `<.form for={@form} id="..." phx-change="validate" phx-submit="save">`; `<.button>` directly inside the form (`<.form>` declares only `inner_block` — an actions slot is silently dropped, so the button never renders). Validate with `to_form(changeset, action: :validate)`. 8. **Expected failures don't crash** — on `{:error, %Ecto.Changeset{}}` re-assign the form; let only bugs raise. 9. **Function components declare `attr`/`slot`**, with `default:` for optional attrs — never `Map.get(assigns, :x, default)` or `assigns[:x]`. 10. **HEEx:** `{@x}` in text and attributes, `:for`/`:if` attributes, `<%!-- --%>` comments; add `:key` to `:for` over plain lists. 11. **JS:** reach for `Phoenix.LiveView.JS` commands first, then colocated hooks; every `phx-hook` element needs a unique DOM `id`. 12. **Reuse `core_components.ex`** (`<.input>`, `<.button>`, `<.table>`, `<.header>`, `<.list>`, `<.icon>`) before writing new components.

Before finishing or reviewing a LiveView, walk through [the LiveView checklist](checklist.md). For hook callbacks, `push_event` payloads, `ColocatedJS`/`ColocatedCSS` setup and runtime hooks, read [the JS interop reference](js-interop.md).

Related skills: `phoenix-liveview-auth` (`live_session`, `on_mount` auth), `phoenix-pubsub-patterns` (broadcasting), `phoenix-uploads` (`allow_upload`, consuming entries), `testing-essentials` (LiveView tests), `phoenix-tailwind-daisyui` (Tailwind v4 `@source`, daisyUI themes, extending `core_components`).

---

Lifecycle

HTTP GET       mount/3 -> handle_params/3 -> render/1   connected?(socket) == false, static HTML
WebSocket      mount/3 -> handle_params/3 -> render/1   connected?(socket) == true, now live
push_patch     handle_params/3 -> render/1              same process; mount/3 does NOT run
push_navigate  new LiveView mounts over the same socket (same live_session); no page reload
redirect/2     full HTTP page load

`mount/3` runs twice per page load, so synchronous work in it runs twice. Async tasks only start once connected — that's why `assign_async` replaces the old `if connected?` loading pattern. `push_navigate` to a route in a *different* `live_session` falls back to a full page load.

A complete index LiveView

Based on `mix phx.gen.live` output (scoped context calls, subscription in `mount`, streams), plus a URL filter and single-item stream updates instead of the generator's re-fetch.

defmodule MyAppWeb.PostLive.Index do
  use MyAppWeb, :live_view

  alias MyApp.Blog
  alias MyApp.Blog.Post

  @impl true
  def mount(_params, _session, socket) do
    # Once per LiveView process. handle_params/3 re-runs on every push_patch.
    if connected?(socket) do
      Blog.subscribe_posts(socket.assigns.current_scope)
    end

    {:ok, assign(socket, :page_title, "Listing Posts")}
  end

  @impl true
  def handle_params(params, _uri, socket) do
    status = params["status"] || "all"
    # list_posts/2 is the generated list_posts/1 plus a filter option
    posts = Blog.list_posts(socket.assigns.current_scope, status: status)

    {:noreply,
     socket
     |> assign(:status, status)
     |> stream(:posts, posts, reset: true)}
  end

  @impl true
  def handle_event("delete", %{"id" => id}, socket) do
    scope = socket.assigns.current_scope
    # Scoped lookup: another user's id raises Ecto.NoResultsError instead of deleting
    post = Blog.get_post!(scope, id)
    {:ok, _} = Blog.delete_post(scope, post)

    {:noreply, stream_delete(socket, :posts, post)}
  end

  @impl true
  def handle_info({:created, %Post{} = post}, socket) do
    if visible?(post, socket.assigns.status) do
      {:noreply, stream_insert(socket, :posts, post, at: 0)}
    else
      {:noreply, socket}
    end
  end

  def handle_info({:updated, %Post{} = post}, socket) do
    # Existing DOM id -> updated in place; an edit can also move it out of the filter
    if visible?(post, socket.assigns.status) do
      {:noreply, stream_insert(socket, :posts, post)}
    else
      {:noreply, stream_delete(socket, :posts, post)}
    end
  end

  def handle_info({:deleted, %Post{} = post}, socket) do
    {:noreply, stream_delete(socket, :posts, post)}
  end

  # Broadcasts carry every post in the scope; keep the stream matching the URL filter.
  defp visible?(_post, "all"), do: true
  defp visible?(%Post{status: status}, filter), do: to_string(status) == filter
end
<Layouts.app flash={@flash} current_scope={@current_scope}>
  <.link patch={~p"/posts?
Read more
Ships withelixir-phoenix-guide

· Changelog A Claude Code plugin (with Codex support for the skills) that keeps Claude's Elixir and Phoenix code idiomatic and current.

Get the whole plugin

Other skills on elixir-phoenix-guide.