From elixir-phoenix
Phoenix LiveView patterns covering streams, JavaScript interop (hooks, colocated scripts, push events), testing, and form handling. Use when building or reviewing LiveView features.
How this skill is triggered — by the user, by Claude, or both
Slash command
/elixir-phoenix:liveview-guidelinesThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
- **Never** use the deprecated `live_redirect` and `live_patch` functions, instead **always** use `<.link navigate={href}>` and `<.link patch={href}>` in templates, and `push_navigate` and `push_patch` in LiveViews
live_redirect and live_patch functions, instead always use <.link navigate={href}> and <.link patch={href}> in templates, and push_navigate and push_patch in LiveViewsAppWeb.WeatherLive, with a Live suffix. When adding routes, the default :browser scope is already aliased with the AppWeb module, so you can just do live "/weather", WeatherLiveAlways use LiveView streams for collections instead of assigning regular lists to avoid memory ballooning and runtime termination:
stream(socket, :messages, [new_msg])stream(socket, :messages, [new_msg], reset: true) (e.g. for filtering)stream(socket, :messages, [new_msg], at: -1)stream_delete(socket, :messages, msg)When using streams, the template must:
phx-update="stream" on the parent element with a DOM id@streams.stream_name and use the id as the DOM id for each child:<div id="messages" phx-update="stream">
<div :for={{id, msg} <- @streams.messages} id={id}>
{msg.text}
</div>
</div>
Enum.filter/2 or Enum.reject/2 on them. To filter or refresh, refetch the data and re-stream with reset: true:def handle_event("filter", %{"filter" => filter}, socket) do
messages = list_messages(filter)
{:noreply,
socket
|> assign(:messages_empty?, messages == [])
|> stream(:messages, messages, reset: true)}
end
<div id="tasks" phx-update="stream">
<div class="hidden only:block">No tasks yet</div>
<div :for={{id, task} <- @streams.tasks} id={id}>
{task.name}
</div>
</div>
The empty state only works if it's the only HTML block alongside the stream for-comprehension.
def handle_event("edit_message", %{"message_id" => message_id}, socket) do
message = Chat.get_message!(message_id)
edit_form = to_form(Chat.change_message(message, %{content: message.content}))
{:noreply,
socket
|> stream_insert(:messages, message)
|> assign(:editing_message_id, String.to_integer(message_id))
|> assign(:edit_form, edit_form)}
end
phx-update="append" or phx-update="prepend" for collectionsphx-hook="MyHook" and that JS hook manages its own DOM, you must also set phx-update="ignore"phx-hookNever write raw embedded <script> tags in HEEx. Instead, always use colocated js hook script tags:
<input type="text" name="user[phone_number]" id="user-phone-number" phx-hook=".PhoneNumber" />
<script :type={Phoenix.LiveView.ColocatedHook} name=".PhoneNumber">
export default {
mounted() {
this.el.addEventListener("input", e => {
let match = this.el.value.replace(/\D/g, "").match(/^(\d{3})(\d{3})(\d{4})$/)
if(match) {
this.el.value = `${match[1]}-${match[2]}-${match[3]}`
}
})
}
}
</script>
. prefix (e.g., .PhoneNumber)External JS hooks must be placed in assets/js/ and passed to the LiveSocket constructor:
const MyHook = {
mounted() { ... }
}
let liveSocket = new LiveSocket("/live", Socket, {
hooks: { MyHook }
});
Use push_event/3 to push events/data to the client for a phx-hook to handle. Always return or rebind the socket:
socket = push_event(socket, "my_event", %{...})
# or return directly:
def handle_event("some_event", _, socket) do
{:noreply, push_event(socket, "my_event", %{...})}
end
Receive in JS hook with this.handleEvent:
mounted() {
this.handleEvent("my_event", data => console.log("from server:", data));
}
Push from client to server with reply:
mounted() {
this.el.addEventListener("click", e => {
this.pushEvent("my_event", { one: 1 }, reply => console.log("got reply:", reply));
})
}
Server handles with reply:
def handle_event("my_event", %{"one" => 1}, socket) do
{:reply, %{two: 2}, socket}
end
Phoenix.LiveViewTest module and LazyHTML (included) for assertionsrender_submit/2 and render_change/2element/2, has_element/2, etc.element/2, has_element/2, and similarPhoenix.Component functions may produce different HTML than expected. Test against output structureLazyHTML:html = render(view)
document = LazyHTML.from_fragment(html)
matches = LazyHTML.filter(document, "your-complex-selector")
IO.inspect(matches, label: "Matches")
def handle_event("submitted", params, socket) do
{:noreply, assign(socket, form: to_form(params))}
end
When passing a map to to_form/1, it assumes string keys. You can specify a name:
def handle_event("submitted", %{"user" => user_params}, socket) do
{:noreply, assign(socket, form: to_form(user_params, as: :user))}
end
%YourApp.Users.User{}
|> Ecto.Changeset.change()
|> to_form()
In the template:
<.form for={@form} id="todo-form" phx-change="validate" phx-submit="save">
<.input field={@form[:field]} type="text" />
</.form>
Always give the form an explicit, unique DOM ID.
Always use a form assigned via to_form/2 in the LiveView, and the <.input> component in the template:
<%!-- ALWAYS do this (valid) --%>
<.form for={@form} id="my-form">
<.input field={@form[:field]} type="text" />
</.form>
Never do this:
<%!-- NEVER do this (invalid) --%>
<.form for={@changeset} id="my-form">
<.input field={@changeset[:field]} type="text" />
</.form>
<.form let={f} ...>, always use <.form for={@form} ...> and drive all form references from the form assignnpx claudepluginhub code0100fun/botfiles --plugin elixir-phoenixReferences Phoenix LiveView patterns for PubSub, uploads, components, forms, assign_async, streams. Use when building LiveView features or debugging handle_event lifecycle.
Enforces Phoenix LiveView best practices: @impl true callbacks, assign initialization in mount/handle_params, connected? checks, proper tuples, and two-phase rendering awareness. Invoke before LiveView modules or .heex templates.
Provides Phoenix LiveView best practices: no DB queries in mount (called twice), load data in handle_params, security scopes, scoped PubSub topics, GenServer polling, async assigns, and gotchas.