--- name: liveview-patterns description: 'Build LiveView: async data (assign_async), PubSub (check connected?), phx-change events, form components/modals/uploads, streams for lists, live_patch. Use when handling interactions, debugging events, or tracking Presence.' --- # LiveView Patterns Reference > **Ash projects**: Use `ash-framework` skill for `AshPhoenix.Form`. Lifecycle: `AshPhoenix.Form.validate/3` on `phx-change`, `AshPhoenix.Form.submit/2` on submit, `to_form/1` for HEEx. Do not use `Ecto.Changeset.cast/3`. Reference for building with Phoenix LiveView 1.0/1.1. ## Iron Laws — Never Violate These 1. **NO UNCONDITIONAL DB QUERIES IN MOUNT** — Mount runs TWICE. Default: `assign_async`. SEO routes: `connected?` guard + cache-backed disconnected branch (crawlers read that HTML) 2. **ALWAYS USE STREAMS FOR LISTS** — Regular assigns = O(n) memory per user. Streams = O(1) 3. **CHECK connected?/1 BEFORE SUBSCRIPTIONS** — Prevents double subscriptions 4. **EXTRACT VARIABLES BEFORE assign_async CLOSURE** — Closures copy entire referenced variables 5. **LOAD PRIMARY DATA IN mount/3, PAGINATION IN handle_params/3** — handle_params runs on EVERY URL change 6. **NEVER PASS SOCKET TO BUSINESS LOGIC** — Extract data before calling contexts 7. **CHECK CHANGESET ERRORS BEFORE UI DEBUGGING** — Silent form save = check `{:error, changeset}` first, not viewport/JS 8. **HIDDEN INPUTS FOR ALL REQUIRED EMBEDDED FIELDS** — Every required field in an embedded schema MUST have a `hidden_input` if not directly editable 9. **NEVER USE `assign_new` FOR LIFECYCLE VALUES** — `assign_new` skips the function if key exists. Use `assign/3` for locale, current user, or any value refreshed every mount 10. **MATCH `{:error, %Ecto.Changeset{}}` EXPLICITLY** — Bare `{:error, _}` merges changeset and non-changeset errors; the form silently never re-renders validation errors. Handle other errors separately ## Memory Impact | Pattern | 3K items | 10K users × 10K items | |---------|----------|----------------------| | Regular assigns | ~5.1 MB | ~10+ GB | | Streams | ~1.1 MB | Minimal (O(1)) | **Decision**: Lists with >100 items → Use streams, not assigns ## Quick Patterns ### Async Assigns (CRITICAL) ```elixir def mount(%{"slug" => slug}, _session, socket) do # Extract needed values BEFORE the closure scope = socket.assigns.current_scope {:ok, socket |> assign_async(:org, fn -> {:ok, %{org: fetch_org(scope, slug)}} end)} end ``` ### Streams for Lists ```elixir def mount(_params, _session, socket) do {:ok, stream(socket, :items, Items.list_items())} end # Insert/update/delete stream_insert(socket, :items, item, at: 0) stream_delete(socket, :items, item) ``` ### SEO Dead-Render (cache-backed disconnected branch) For public/SEO-visible routes (marketing, articles, product listings) the disconnected render IS the HTML crawlers see. Fetch from a cache there, real data on connect: ```elixir def mount(_params, _session, socket) do products = if connected?(socket), do: Catalog.list_products(), else: Cache.get_products() || [] {:ok, assign(socket, products: products)} end ``` Empty list → `