## 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` ### 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:
{msg.text}
- 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:
{task.name}
The above only works if the empty state is the only HTML block alongside the stream for-comprehension. - When updating an assign that should change content inside any streamed item(s), you MUST re-stream the items along with the updated assign: 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})) # re-insert message so @editing_message_id toggle logic takes effect for that stream item {:noreply, socket |> stream_insert(:messages, message) |> assign(:editing_message_id, String.to_integer(message_id)) |> assign(:edit_form, edit_form)} end And in the template:
{message.username} <%= if @editing_message_id == message.id do %> <%!-- Edit mode --%> <.form for={@edit_form} id="edit-form-#{message.id}" phx-submit="save_edit"> ... <% end %>
- **Never** use the deprecated `phx-update="append"` or `phx-update="prepend"` for collections ### LiveView JavaScript interop - Remember anytime you use `phx-hook="MyHook"` and that JS hook manages its own DOM, you **must** also set the `phx-update="ignore"` attribute - **Always** provide an unique DOM id alongside `phx-hook` otherwise a compiler error will be raised LiveView hooks come in two flavors, 1) colocated js hooks for "inline" scripts defined inside HEEx, and 2) external `phx-hook` annotations where JavaScript object literals are defined and passed to the `LiveSocket` constructor. #### Inline colocated js hooks **Never** write raw embedded ` - colocated hooks are automatically integrated into the app.js bundle - colocated hooks names **MUST ALWAYS** start with a `.` prefix, i.e. `.PhoneNumber` #### External phx-hook 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 } }); #### Pushing events between client and server Use LiveView's `push_event/3` when you need to push events/data to the client for a phx-hook to handle. **Always** return or rebind the socket on `push_event/3` when pushing events: # re-bind socket so we maintain event state to be pushed socket = push_event(socket, "my_event", %{...}) # or return the modified socket directly: def handle_event("some_event", _, socket) do {:noreply, push_event(socket, "my_event", %{...})} end Pushed events can then be picked up in a JS hook with `this.handleEvent`: mounted() { this.handleEvent("my_event", data => console.log("from server:", data)); } Clients can also push an event to the server and receive a reply with `this.pushEvent`: mounted() { this.el.addEventListener("click", e => { this.pushEvent("my_event", { one: 1 }, reply => console.log("got reply from server:", reply)); }) } Where the server handled it via: def handle_event("my_event", %{"one" => 1}, socket) do {:reply, %{two: 2}, socket} end ### 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" /> 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" /> And **never** do this: <%!-- NEVER do this (invalid) --%> <.form for={@changeset} id="my-form"> <.input field={@changeset[:field]} type="text" /> - 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