| name | phoenix-liveview |
| description | Critical Phoenix LiveView guidelines that prevent common bugs, memory issues, and deprecated patterns. Use when writing LiveView modules, LiveView tests, or working with streams, navigation, forms, or JS hooks. Prevents memory ballooning from improper collection handling, deprecated function usage, and common test failures. |
Phoenix LiveView Guidelines
Navigation
- Never use deprecated
live_redirectorlive_patch - Always use:
- Templates:
<.link navigate={href}>and<.link patch={href}> - LiveViews:
push_navigate/2andpush_patch/2
- Templates:
Module Naming & Routing
- Name LiveViews with
Livesuffix:AppWeb.WeatherLive - Router's
:browserscope is aliased withAppWeb, so use:live "/weather", WeatherLive
LiveComponents
Avoid LiveComponents unless you have a strong, specific need. Prefer function components.
JavaScript Hooks
- Never write embedded
<script>tags in HEEx - Always write scripts in
assets/js/and integrate viaassets/js/app.js - When using
phx-hook="MyHook"where the hook manages its own DOM, must also setphx-update="ignore":
<div id="chart" phx-hook="ChartHook" phx-update="ignore"></div>
Streams
Always use streams for collections to avoid memory ballooning:
# Append items
stream(socket, :messages, [new_msg])
# Prepend items
stream(socket, :messages, [new_msg], at: -1)
# Reset with new items (for filtering)
stream(socket, :messages, messages, reset: true)
# Delete item
stream_delete(socket, :messages, msg)
Never use deprecated phx-update="append" or phx-update="prepend".
Stream Template Pattern
Parent must have phx-update="stream" and a DOM id. Children consume @streams.name with the stream-provided id:
<div id="messages" phx-update="stream">
<div :for={{id, msg} <- @streams.messages} id={id}>
{msg.text}
</div>
</div>
Streams Are Not Enumerable
Cannot use Enum.filter/2, Enum.reject/2, etc. on streams. To filter/refresh, refetch and reset:
def handle_event("filter", %{"filter" => filter}, socket) do
messages = list_messages(filter)
{:noreply,
socket
|> assign(:messages_empty?, messages == [])
|> stream(:messages, messages, reset: true)}
end
Stream Empty States
Streams don't support counting. Track count separately. For empty states, use Tailwind:
<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>
Form Handling
Always use to_form/2 and access via @form:
# From params
def handle_event("submitted", params, socket) do
{:noreply, assign(socket, form: to_form(params))}
end
# From changeset
%User{} |> Ecto.Changeset.change() |> to_form()
<.form for={@form} id="todo-form" phx-change="validate" phx-submit="save">
<.input field={@form[:field]} type="text" />
</.form>
Never do this:
<%!-- FORBIDDEN: accessing changeset directly --%>
<.form for={@changeset}>
<.form let={f}>
- Never pass changeset directly to template
- Never use
<.form let={f} ...>syntax - Always give forms unique DOM IDs
LiveView Tests
- Use
Phoenix.LiveViewTestandLazyHTMLfor assertions - Use
render_submit/2andrender_change/2for form tests - Always reference element IDs from templates in tests
- Never test against raw HTML; use
element/2,has_element?/2:
assert has_element?(view, "#my-form")
- Test for element presence rather than text content
- Focus on outcomes, not implementation details
Debugging Test Failures
html = render(view)
document = LazyHTML.from_fragment(html)
matches = LazyHTML.filter(document, "your-selector")
IO.inspect(matches, label: "Matches")