Phoenix LiveView guidelines
- Never use the deprecated
live_redirect and live_patch functions, instead always use the <.link navigate={href}> and <.link patch={href}> in templates, and push_navigate and push_patch functions LiveViews
- Avoid LiveComponent's unless you have a strong, specific need for them
- LiveViews should be named like
AppWeb.WeatherLive, with a Live suffix. When you go to add LiveView routes to the router, the default :browser scope is already aliased with the AppWeb module, so you can just do live "/weather", WeatherLive
- Remember anytime you use
phx-hook="MyHook" and that js hook manages its own DOM, you must also set the phx-update="ignore" attribute
- Never write embedded
<script> tags in HEEx. Instead always write your scripts and hooks in the assets/js directory and integrate them with the assets/js/app.js file
LiveView streams
Always use LiveView streams for collections for assigning regular lists to avoid memory ballooning and runtime termination with the following operations:
- basic append of N items -
stream(socket, :messages, [new_msg])
- resetting stream with new items -
stream(socket, :messages, [new_msg], reset: true) (e.g. for filtering items)
- prepend to stream -
stream(socket, :messages, [new_msg], at: -1)
- deleting items -
stream_delete(socket, :messages, msg)
When using the stream/3 interfaces in the LiveView, the LiveView template must 1) always set phx-update="stream" on the parent element, with a DOM id on the parent element like id="messages" and 2) consume the @streams.stream_name collection and use the id as the DOM id for each child. For a call like stream(socket, :messages, [new_msg]) in the LiveView, the template would be:
<div id="messages" phx-update="stream">
<div :for={{id, msg} <- @streams.messages} id={id}>
{msg.text}
</div>
</div>
LiveView streams are not enumerable, so you cannot use Enum.filter/2 or Enum.reject/2 on them. Instead, if you want to filter, prune, or refresh a list of items on the UI, you must refetch the data and re-stream the entire stream collection, passing reset: true:
def handle_event("filter", %{"filter" => filter}, socket) do
# re-fetch the messages based on the filter
messages = list_messages(filter)
{:noreply,
socket
|> assign(:messages_empty?, messages == [])
# reset the stream with the new messages
|> stream(:messages, messages, reset: true)}
end
LiveView streams do not support counting or empty states. If you need to display a count, you must track it using a separate assign. For empty states, you can use Tailwind classes:
<div id="tasks" phx-update="stream">
<div class="hidden only:block">No tasks yet</div>
<div :for={{id, task} <- @stream.tasks} id={id}>
{task.name}
</div>
</div>
The above only works if the empty state is the only HTML block alongside the stream for-comprehension.
Never use the deprecated phx-update="append" or phx-update="prepend" for collections
LiveView tests
Phoenix.LiveViewTest module and LazyHTML (included) for making your assertions
Form tests are driven by Phoenix.LiveViewTest's render_submit/2 and render_change/2 functions
Come up with a step-by-step test plan that splits major test cases into small, isolated files. You may start with simpler tests that verify content exists, gradually add interaction tests
Always reference the key element IDs you added in the LiveView templates in your tests for Phoenix.LiveViewTest functions like element/2, has_element/2, selectors, etc
Never tests again raw HTML, always use element/2, has_element/2, and similar: assert has_element?(view, "#my-form")
Instead of relying on testing text content, which can change, favor testing for the presence of key elements
Focus on testing outcomes rather than implementation details
Be aware that Phoenix.Component functions like <.form> might produce different HTML than expected. Test against the output HTML structure, not your mental model of what you expect it to be
When facing test failures with element selectors, add debug statements to print the actual HTML, but use LazyHTML selectors to limit the output, ie:
html = render(view)
document = LazyHTML.from_fragment(html)
matches = LazyHTML.filter(document, "your-complex-selector")
IO.inspect(matches, label: "Matches")
Form handling
Creating a form from params
If you want to create a form based on handle_event params:
def handle_event("submitted", params, socket) do
{:noreply, assign(socket, form: to_form(params))}
end
When you pass a map to to_form/1, it assumes said map contains the form params, which are expected to have string keys.
You can also specify a name to nest the params:
def handle_event("submitted", %{"user" => user_params}, socket) do
{:noreply, assign(socket, form: to_form(user_params, as: :user))}
end
Creating a form from changesets
When using changesets, the underlying data, form params, and errors are retrieved from it. The :as option is automatically computed too. E.g. if you have a user schema:
defmodule MyApp.Users.User do
use Ecto.Schema
...
end
And then you create a changeset that you pass to to_form:
%MyApp.Users.User{}
|> Ecto.Changeset.change()
|> to_form()
Once the form is submitted, the params will be available under %{"user" => user_params}.
In the template, the form form assign can be passed to the <.form> function component:
<.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, like id="todo-form".
Avoiding form errors
Always use a form assigned via to_form/2 in the LiveView, and the <.input> component in the template. In the template always access forms this:
<%!-- ALWAYS do this (valid) --%>
<.form for={@form} id="my-form">
<.input field={@form[:field]} type="text" />
</.form>
And never do this:
<%!-- NEVER do this (invalid) --%>
<.form for={@changeset} id="my-form">
<.input field={@changeset[:field]} type="text" />
</.form>
- You are FORBIDDEN from accessing the changeset in the template as it will cause errors
- Never use
<.form let={f} ...> in the template, instead always use <.form for={@form} ...>, then drive all form references from the form assign as in @form[:field]. The UI should always be driven by a to_form/2 assigned in the LiveView module that is derived from a changeset
1---2name: phoenix-liveview3description: Phoenix LiveView guidelines4---5
6## Phoenix LiveView guidelines
7
8- **Never** use the deprecated `live_redirect` and `live_patch` functions, instead **always** use the `<.link navigate={href}>` and `<.link patch={href}>` in templates, and `push_navigate` and `push_patch` functions LiveViews
9- **Avoid LiveComponent's** unless you have a strong, specific need for them
10- LiveViews should be named like `AppWeb.WeatherLive`, with a `Live` suffix. When you go to add LiveView routes to the router, the default `:browser` scope is **already aliased** with the `AppWeb` module, so you can just do `live "/weather", WeatherLive`
11- Remember anytime you use `phx-hook="MyHook"` and that js hook manages its own DOM, you **must** also set the `phx-update="ignore"` attribute
12- **Never** write embedded `<script>` tags in HEEx. Instead always write your scripts and hooks in the `assets/js` directory and integrate them with the `assets/js/app.js` file
13
14### LiveView streams
15
16- **Always** use LiveView streams for collections for assigning regular lists to avoid memory ballooning and runtime termination with the following operations:
17 - basic append of N items - `stream(socket, :messages, [new_msg])`
18 - resetting stream with new items - `stream(socket, :messages, [new_msg], reset: true)` (e.g. for filtering items)
19 - prepend to stream - `stream(socket, :messages, [new_msg], at: -1)`
20 - deleting items - `stream_delete(socket, :messages, msg)`
21
22- When using the `stream/3` interfaces in the LiveView, the LiveView template must 1) always set `phx-update="stream"` on the parent element, with a DOM id on the parent element like `id="messages"` and 2) consume the `@streams.stream_name` collection and use the id as the DOM id for each child. For a call like `stream(socket, :messages, [new_msg])` in the LiveView, the template would be:
23
24 <div id="messages" phx-update="stream">
25 <div :for={{id, msg} <- @streams.messages} id={id}>
26 {msg.text}
27 </div>
28 </div>
29
30- LiveView streams are *not* enumerable, so you cannot use `Enum.filter/2` or `Enum.reject/2` on them. Instead, if you want to filter, prune, or refresh a list of items on the UI, you **must refetch the data and re-stream the entire stream collection, passing reset: true**:
31
32 def handle_event("filter", %{"filter" => filter}, socket) do
33 # re-fetch the messages based on the filter
34 messages = list_messages(filter)
35
36 {:noreply,
37 socket
38 |> assign(:messages_empty?, messages == [])
39 # reset the stream with the new messages
40 |> stream(:messages, messages, reset: true)}
41 end
42
43- LiveView streams *do not support counting or empty states*. If you need to display a count, you must track it using a separate assign. For empty states, you can use Tailwind classes:
44
45 <div id="tasks" phx-update="stream">
46 <div class="hidden only:block">No tasks yet</div>
47 <div :for={{id, task} <- @stream.tasks} id={id}>
48 {task.name}
49 </div>
50 </div>
51
52 The above only works if the empty state is the only HTML block alongside the stream for-comprehension.
53
54- **Never** use the deprecated `phx-update="append"` or `phx-update="prepend"` for collections
55
56### LiveView tests
57
58- `Phoenix.LiveViewTest` module and `LazyHTML` (included) for making your assertions
59- Form tests are driven by `Phoenix.LiveViewTest`'s `render_submit/2` and `render_change/2` functions
60- Come up with a step-by-step test plan that splits major test cases into small, isolated files. You may start with simpler tests that verify content exists, gradually add interaction tests
61- **Always reference the key element IDs you added in the LiveView templates in your tests** for `Phoenix.LiveViewTest` functions like `element/2`, `has_element/2`, selectors, etc
62- **Never** tests again raw HTML, **always** use `element/2`, `has_element/2`, and similar: `assert has_element?(view, "#my-form")`
63- Instead of relying on testing text content, which can change, favor testing for the presence of key elements
64- Focus on testing outcomes rather than implementation details
65- Be aware that `Phoenix.Component` functions like `<.form>` might produce different HTML than expected. Test against the output HTML structure, not your mental model of what you expect it to be
66- When facing test failures with element selectors, add debug statements to print the actual HTML, but use `LazyHTML` selectors to limit the output, ie:
67
68 html = render(view)
69 document = LazyHTML.from_fragment(html)
70 matches = LazyHTML.filter(document, "your-complex-selector")
71 IO.inspect(matches, label: "Matches")
72
73### Form handling
74
75#### Creating a form from params
76
77If you want to create a form based on `handle_event` params:
78
79 def handle_event("submitted", params, socket) do
80 {:noreply, assign(socket, form: to_form(params))}
81 end
82
83When you pass a map to `to_form/1`, it assumes said map contains the form params, which are expected to have string keys.
84
85You can also specify a name to nest the params:
86
87 def handle_event("submitted", %{"user" => user_params}, socket) do
88 {:noreply, assign(socket, form: to_form(user_params, as: :user))}
89 end
90
91#### Creating a form from changesets
92
93When using changesets, the underlying data, form params, and errors are retrieved from it. The `:as` option is automatically computed too. E.g. if you have a user schema:
94
95 defmodule MyApp.Users.User do
96 use Ecto.Schema
97 ...
98 end
99
100And then you create a changeset that you pass to `to_form`:
101
102 %MyApp.Users.User{}
103 |> Ecto.Changeset.change()
104 |> to_form()
105
106Once the form is submitted, the params will be available under `%{"user" => user_params}`.
107
108In the template, the form form assign can be passed to the `<.form>` function component:
109
110 <.form for={@form} id="todo-form" phx-change="validate" phx-submit="save">
111 <.input field={@form[:field]} type="text" />
112 </.form>
113
114Always give the form an explicit, unique DOM ID, like `id="todo-form"`.
115
116#### Avoiding form errors
117
118**Always** use a form assigned via `to_form/2` in the LiveView, and the `<.input>` component in the template. In the template **always access forms this**:
119
120 <%!-- ALWAYS do this (valid) --%>
121 <.form for={@form} id="my-form">
122 <.input field={@form[:field]} type="text" />
123 </.form>
124
125And **never** do this:
126
127 <%!-- NEVER do this (invalid) --%>
128 <.form for={@changeset} id="my-form">
129 <.input field={@changeset[:field]} type="text" />
130 </.form>
131
132- You are FORBIDDEN from accessing the changeset in the template as it will cause errors
133- **Never** use `<.form let={f} ...>` in the template, instead **always use `<.form for={@form} ...>`**, then drive all form references from the form assign as in `@form[:field]`. The UI should **always** be driven by a `to_form/2` assigned in the LiveView module that is derived from a changeset