Phoenix LiveView renders HTML on the server and updates only the changed DOM over a persistent WebSocket connection — no JavaScript framework, no JSON API, no client state management. The mount/3 callback establishes state. handle_event/3 responds to user interactions. handle_info/2 processes process messages, including Phoenix PubSub broadcasts for multi-user synchronization. LiveComponents encapsulate reusable state. Streams handle large lists efficiently without holding all data in socket assigns. Claude Code generates LiveView modules, LiveComponent implementations, PubSub broadcast patterns, and the form change and submit handlers for production real-time Elixir applications.
CLAUDE.md for LiveView Projects
## LiveView Stack
- Phoenix >= 1.7.14, LiveView >= 1.0
- Real-time: Phoenix.PubSub for multi-user broadcasts
- Large lists: stream() + stream_insert/stream_delete — NOT Enum.map in assigns
- Forms: LiveView forms with phx-change + phx-submit + Ecto changesets
- Client interop: JS hooks for DOM manipulation, push_event for server→client events
- Components: function components for stateless, LiveComponent for stateful+isolated
- Auth: on_mount lifecycle hooks with Phoenix.LiveView.on_mount
- Uploads: allow_upload for server-side file handling
Basic LiveView Module
# lib/my_app_web/live/orders_live.ex
defmodule MyAppWeb.OrdersLive do
use MyAppWeb, :live_view
alias MyApp.Orders
alias Phoenix.PubSub
@impl true
def mount(_params, _session, socket) do
# Subscribe to order updates for real-time sync
if connected?(socket) do
PubSub.subscribe(MyApp.PubSub, "orders:#{socket.assigns.current_user.id}")
end
orders = Orders.list_orders(socket.assigns.current_user.id)
socket =
socket
|> assign(:filter_status, "all")
|> assign(:loading, false)
|> stream(:orders, orders) # stream for efficient large list rendering
{:ok, socket}
end
@impl true
def handle_params(%{"status" => status}, _uri, socket) do
orders =
socket.assigns.current_user.id
|> Orders.list_orders(status: status)
socket =
socket
|> assign(:filter_status, status)
|> stream(:orders, orders, reset: true)
{:noreply, socket}
end
def handle_params(_params, _uri, socket), do: {:noreply, socket}
@impl true
def handle_event("cancel_order", %{"id" => order_id}, socket) do
case Orders.cancel_order(order_id, socket.assigns.current_user) do
{:ok, order} ->
# Update the specific stream entry
{:noreply, stream_insert(socket, :orders, order)}
{:error, reason} ->
{:noreply, put_flash(socket, :error, "Failed: #{reason}")}
end
end
def handle_event("filter", %{"status" => status}, socket) do
{:noreply, push_patch(socket, to: ~p"/orders?status=#{status}")}
end
@impl true
# Handle PubSub broadcast: new order created by another session
def handle_info({:order_created, order}, socket) do
{:noreply, stream_insert(socket, :orders, order, at: 0)}
end
# Handle PubSub broadcast: order status changed
def handle_info({:order_updated, order}, socket) do
{:noreply, stream_insert(socket, :orders, order)}
end
@impl true
def render(assigns) do
~H"""
<div class="max-w-4xl mx-auto p-6">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold">Orders</h1>
<div class="flex gap-2">
<%= for status <- ["all", "pending", "processing", "shipped"] do %>
<button
phx-click="filter"
phx-value-status={status}
class={["px-3 py-1 rounded", @filter_status == status && "bg-blue-500 text-white"]}
>
<%= String.capitalize(status) %>
</button>
<% end %>
</div>
</div>
<div id="orders" phx-update="stream" class="space-y-3">
<%= for {dom_id, order} <- @streams.orders do %>
<div id={dom_id} class="border rounded-lg p-4">
<div class="flex justify-between">
<div>
<span class="font-mono text-sm"><%= order.id %></span>
<p class="font-semibold">$<%= :erlang.float_to_binary(order.amount / 100, decimals: 2) %></p>
</div>
<div class="flex items-center gap-3">
<span class={"badge badge-#{order.status}"}><%= order.status %></span>
<%= if order.status == :pending do %>
<button
phx-click="cancel_order"
phx-value-id={order.id}
data-confirm="Cancel this order?"
class="text-red-500 hover:text-red-700 text-sm"
>
Cancel
</button>
<% end %>
</div>
</div>
</div>
<% end %>
</div>
</div>
"""
end
end
LiveComponent for Reusable State
# lib/my_app_web/live/components/order_form_component.ex
defmodule MyAppWeb.OrderFormComponent do
use MyAppWeb, :live_component
alias MyApp.Orders
alias MyApp.Orders.Order
@impl true
def update(%{order: order} = assigns, socket) do
changeset = Orders.change_order(order)
socket =
socket
|> assign(assigns)
|> assign_form(changeset)
{:ok, socket}
end
@impl true
def handle_event("validate", %{"order" => order_params}, socket) do
changeset =
socket.assigns.order
|> Orders.change_order(order_params)
|> Map.put(:action, :validate)
{:noreply, assign_form(socket, changeset)}
end
def handle_event("save", %{"order" => order_params}, socket) do
save_order(socket, socket.assigns.action, order_params)
end
defp save_order(socket, :new, params) do
case Orders.create_order(socket.assigns.current_user, params) do
{:ok, order} ->
# Notify parent LiveView
notify_parent({:saved, order})
{:noreply,
socket
|> put_flash(:info, "Order created")
|> push_patch(to: socket.assigns.patch)}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign_form(socket, changeset)}
end
end
defp save_order(socket, :edit, params) do
case Orders.update_order(socket.assigns.order, params) do
{:ok, order} ->
notify_parent({:saved, order})
{:noreply, push_patch(socket, to: socket.assigns.patch)}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign_form(socket, changeset)}
end
end
defp assign_form(socket, changeset) do
assign(socket, :form, to_form(changeset))
end
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
@impl true
def render(assigns) do
~H"""
<div>
<.header>
<%= @title %>
</.header>
<.simple_form
for={@form}
id="order-form"
phx-target={@myself}
phx-change="validate"
phx-submit="save"
>
<.input field={@form[:customer_id]} type="text" label="Customer ID" />
<.input field={@form[:amount]} type="number" label="Amount (cents)" />
<.input
field={@form[:status]}
type="select"
label="Status"
options={[{"Pending", "pending"}, {"Processing", "processing"}]}
/>
<:actions>
<.button phx-disable-with="Saving...">Save Order</.button>
</:actions>
</.simple_form>
</div>
"""
end
end
PubSub for Multi-User Updates
# lib/my_app/orders.ex — broadcast changes via PubSub
defmodule MyApp.Orders do
alias MyApp.Repo
alias MyApp.Orders.Order
alias Phoenix.PubSub
def create_order(user, attrs) do
%Order{}
|> Order.changeset(Map.put(attrs, "user_id", user.id))
|> Repo.insert()
|> tap(fn
{:ok, order} -> broadcast_order_event(order, :order_created)
_ -> :ok
end)
end
def update_order_status(order_id, new_status) do
order = Repo.get!(Order, order_id)
order
|> Order.status_changeset(%{status: new_status})
|> Repo.update()
|> tap(fn
{:ok, updated} -> broadcast_order_event(updated, :order_updated)
_ -> :ok
end)
end
# Broadcast to user-specific topic
defp broadcast_order_event(order, event) do
PubSub.broadcast(
MyApp.PubSub,
"orders:#{order.user_id}",
{event, order}
)
end
# Broadcast to admin dashboard topic
def broadcast_admin_event(order, event) do
PubSub.broadcast(
MyApp.PubSub,
"admin:orders",
{event, order}
)
end
end
JS Hooks for Client Interop
// assets/js/hooks.js — LiveView JS hooks
const Hooks = {}
// Hook: focus input after LiveView update
Hooks.AutoFocus = {
updated() {
this.el.querySelector("[data-autofocus]")?.focus()
}
}
// Hook: infinite scroll — send load_more event when near bottom
Hooks.InfiniteScroll = {
mounted() {
this.observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
this.pushEvent("load_more", {})
}
}, { threshold: 0.1 })
const sentinel = this.el.querySelector("[data-scroll-sentinel]")
if (sentinel) this.observer.observe(sentinel)
},
destroyed() {
this.observer?.disconnect()
}
}
// Hook: receive server events for client-side effects
Hooks.Toast = {
mounted() {
this.handleEvent("show_toast", ({ message, type }) => {
showToastNotification(message, type)
})
}
}
export default Hooks
# Push event from LiveView to JS hook
def handle_event("complete_checkout", _params, socket) do
{:noreply, push_event(socket, "show_toast", %{
message: "Order placed successfully!",
type: "success"
})}
end
For the Phoenix basics with channels and context modules, see the Elixir Phoenix guide for REST APIs and GenServer patterns. For the tRPC subscriptions approach that achieves similar real-time updates in TypeScript stacks using WebSocket, the tRPC subscriptions guide covers typed event streams. The Claude Skills 360 bundle includes LiveView skill sets covering real-time components, PubSub workflows, and form patterns. Start with the free tier to try LiveView module generation.