elixir-liveview
SKILL.md
Phoenix LiveView Patterns
Expert guidance for building real-time, interactive web applications with Phoenix LiveView.
Lifecycle & State
- Keep
mount/3minimal — assign only what's needed for initial render - Use
handle_params/3for URL-driven state, notmount/3 - Prefer
assign_new/3overassign/3when value may already exist - Use
assign_async/3andstart_async/3for expensive operations - Never block
mount/3with slow database queries or API calls
# ✅ Good: Minimal mount, async loading
def mount(_params, _session, socket) do
{:ok, assign(socket, page_title: "Dashboard", loading: true)}
end
def handle_params(%{"id" => id}, _uri, socket) do
{:noreply,
socket
|> assign(:id, id)
|> start_async(:load_data, fn -> fetch_data(id) end)}
end
def handle_async(:load_data, {:ok, data}, socket) do
{:noreply, assign(socket, data: data, loading: false)}
end
# ❌ Bad: Blocking mount
def mount(%{"id" => id}, _session, socket) do
data = Repo.get!(Item, id) |> Repo.preload(:associations) # Blocks render
{:ok, assign(socket, data: data)}
end
Streams for Collections
- Use streams (
stream/3,stream_insert/3,stream_delete/3) for lists that change - Never re-assign entire lists when only one item changes
- Use
phx-update="stream"with streams, notphx-update="replace" - Provide DOM IDs for stream items
# ✅ Good: Using streams
def mount(_params, _session, socket) do
{:ok, stream(socket, :items, list_items())}
end
def handle_info({:item_created, item}, socket) do
{:noreply, stream_insert(socket, :items, item, at: 0)}
end
def handle_info({:item_deleted, item}, socket) do
{:noreply, stream_delete(socket, :items, item)}
end
<%!-- Template with streams --%>
<div id="items" phx-update="stream">
<div :for={{dom_id, item} <- @streams.items} id={dom_id}>
<%= item.name %>
</div>
</div>
# ❌ Bad: Re-assigning entire list
def handle_info({:item_created, item}, socket) do
items = [item | socket.assigns.items] # Forces full re-render
{:noreply, assign(socket, items: items)}
end
Events & Messages
- Validate all params in
handle_event/3— never trust client data - Use
push_patch/2for same-LiveView navigation,push_navigate/2for different LiveView - Handle
handle_info/2for PubSub, async results, and process messages - Return
{:noreply, socket}from event handlers, not justsocket
# ✅ Good: Validating event params
def handle_event("delete", %{"id" => id}, socket) do
case Integer.parse(id) do
{id, ""} ->
item = Items.get_item!(id)
# Verify authorization
if item.user_id == socket.assigns.current_user.id do
Items.delete_item(item)
{:noreply, stream_delete(socket, :items, item)}
else
{:noreply, put_flash(socket, :error, "Not authorized")}
end
:error ->
{:noreply, put_flash(socket, :error, "Invalid ID")}
end
end
# ❌ Bad: Trusting client data
def handle_event("delete", %{"id" => id}, socket) do
Items.delete_item!(id) # No validation, no auth check
{:noreply, socket}
end
Components
- Use
<.live_component>only when you need isolated state orhandle_event - Prefer stateless function components for pure rendering
- Always define
@impl truefor LiveView callbacks - Use
on_mount/1hooks for shared authentication/authorization logic
# Function component (preferred for stateless rendering)
attr :item, :map, required: true
attr :on_delete, :any, default: nil
def item_card(assigns) do
~H"""
<div class="card">
<h3><%= @item.title %></h3>
<button :if={@on_delete} phx-click={@on_delete} phx-value-id={@item.id}>
Delete
</button>
</div>
"""
end
# LiveComponent (only when you need isolated state)
defmodule MyAppWeb.ItemFormComponent do
use MyAppWeb, :live_component
@impl true
def mount(socket) do
{:ok, assign(socket, form: to_form(%{}))}
end
@impl true
def handle_event("save", params, socket) do
# Component handles its own events
{:noreply, socket}
end
end
Performance
- Use
temporary_assignsfor large lists rendered once - Debounce rapid events with
phx-debounce(forms: 300ms, search: 500ms) - Use
phx-throttlefor scroll/resize events - Avoid assigns that change on every render — LiveView diffs on assign changes
# ✅ Good: Temporary assigns for large static lists
def mount(_params, _session, socket) do
{:ok, assign(socket, items: list_all_items()), temporary_assigns: [items: []]}
end
<%!-- Debounce search input --%>
<input type="text" name="q" phx-change="search" phx-debounce="500" />
<%!-- Throttle scroll events --%>
<div phx-hook="InfiniteScroll" phx-throttle="100"></div>
PubSub & Real-time
- Subscribe in
mount/3only whenconnected?(socket)is true - Unsubscribe happens automatically on disconnect — don't manage manually
- Broadcast granular topics (e.g.,
"task:#{id}") not global topics - Use
Phoenix.Presencefor user tracking, not custom GenServers
@impl true
def mount(%{"id" => id}, _session, socket) do
if connected?(socket) do
# Subscribe only when connected (not during static render)
Phoenix.PubSub.subscribe(MyApp.PubSub, "item:#{id}")
end
{:ok, assign(socket, id: id, item: get_item(id))}
end
@impl true
def handle_info({:item_updated, item}, socket) do
{:noreply, assign(socket, item: item)}
end
# Broadcasting from context
def update_item(item, attrs) do
case Repo.update(Item.changeset(item, attrs)) do
{:ok, item} ->
Phoenix.PubSub.broadcast(MyApp.PubSub, "item:#{item.id}", {:item_updated, item})
{:ok, item}
error ->
error
end
end
Forms
- Use
to_form/1to convert changesets to form structs - Handle
phx-changefor validation,phx-submitfor persistence - Use
phx-trigger-actionfor traditional form submissions when needed - Implement
phx-feedback-forto show errors only after user interaction
@impl true
def mount(_params, _session, socket) do
changeset = Items.change_item(%Item{})
{:ok, assign(socket, form: to_form(changeset))}
end
@impl true
def handle_event("validate", %{"item" => params}, socket) do
changeset =
%Item{}
|> Items.change_item(params)
|> Map.put(:action, :validate)
{:noreply, assign(socket, form: to_form(changeset))}
end
@impl true
def handle_event("save", %{"item" => params}, socket) do
case Items.create_item(params) do
{:ok, item} ->
{:noreply,
socket
|> put_flash(:info, "Created!")
|> push_navigate(to: ~p"/items/#{item}")}
{:error, changeset} ->
{:noreply, assign(socket, form: to_form(changeset))}
end
end
<.form for={@form} phx-change="validate" phx-submit="save">
<.input field={@form[:title]} label="Title" phx-feedback-for="item[title]" />
<.input field={@form[:body]} type="textarea" label="Body" />
<.button>Save</.button>
</.form>
Nested Forms
- Use
<.inputs_for />for has_many/embeds_many associations - Use sort/drop hidden inputs (
recipe[ingredients_sort][],recipe[ingredients_drop][]) for reordering and removing - Access
.indexon the form struct to number rows (0-based, useindex + 1for display)
Numbering nested inputs
<.inputs_for> doesn't expose a collection for Enum.with_index. Use the .index attribute on the form struct instead:
<.inputs_for :let={ingredient_f} field={@form[:ingredients]}>
<div class="flex items-center space-x-2">
<div>{ingredient_f.index + 1}</div>
<.input field={ingredient_f[:name]} type="text" />
</div>
</.inputs_for>
Keeping indexes in sync with Sortable.js
Sortable.js reorders DOM elements client-side, but LiveView won't know until the next phx-change. This causes stale numbering after drag-and-drop. Fix by dispatching an input event in onEnd to force a round-trip:
const SortableInputsFor = {
mounted() {
new Sortable(this.el, {
animation: 150,
ghostClass: "opacity-50",
handle: ".hero-bars-3",
onEnd: (event) => {
event.item.querySelector("input").dispatchEvent(
new Event("input", { bubbles: true })
)
}
})
}
}
This triggers phx-change with the new form order, syncing server state and re-rendering correct indexes.
Navigation
- Use
push_patch/2when staying on the same LiveView (updates URL, callshandle_params) - Use
push_navigate/2when going to a different LiveView - Use
<.link patch={...}>and<.link navigate={...}>in templates
# Same LiveView, different params (e.g., pagination, filters)
{:noreply, push_patch(socket, to: ~p"/items?page=#{page + 1}")}
# Different LiveView
{:noreply, push_navigate(socket, to: ~p"/items/#{item.id}")}
Common Mistakes
<%!-- ❌ Don't use assigns directly in comprehensions for dynamic lists --%>
<div :for={item <- @items}>...</div>
<%!-- ✅ Use streams for dynamic lists --%>
<div :for={{id, item} <- @streams.items} id={id}>...</div>
# ❌ Don't subscribe in mount without checking connected?
def mount(_, _, socket) do
PubSub.subscribe(...) # Will fail on static render
end
# ✅ Check connected? first
def mount(_, _, socket) do
if connected?(socket), do: PubSub.subscribe(...)
end
# ❌ Don't forget @impl true
def mount(_, _, socket) do # Missing @impl
end
# ✅ Always use @impl true
@impl true
def mount(_, _, socket) do
end
Weekly Installs
1
Repository
hwatkins/my-skillsFirst Seen
9 days ago
Security Audits
Installed on
amp1
cline1
opencode1
cursor1
kimi-cli1
codex1