liveview-patterns
'Build LiveView: async data (assign_async), PubSub (check connected?),
Install
npx skills add https://github.com/oliver-kriska/claude-elixir-phoenix/tree/main/targets/amp/skills/liveview-patterns
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install oliver-kriska-claude-elixir-phoenix@llmmart
git clone https://github.com/oliver-kriska/claude-elixir-phoenix.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole oliver-kriska/claude-elixir-phoenix collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
LiveView Patterns Reference
Ash projects: Use
ash-frameworkskill forAshPhoenix.Form. Lifecycle:AshPhoenix.Form.validate/3onphx-change,AshPhoenix.Form.submit/2on submit,to_form/1for HEEx. Do not useEcto.Changeset.cast/3.
Reference for building with Phoenix LiveView 1.0/1.1.
Iron Laws — Never Violate These
- NO UNCONDITIONAL DB QUERIES IN MOUNT — Mount runs TWICE. Default:
assign_async. SEO routes:connected?guard + cache-backed disconnected branch (crawlers read that HTML) - ALWAYS USE STREAMS FOR LISTS — Regular assigns = O(n) memory per user. Streams = O(1)
- CHECK connected?/1 BEFORE SUBSCRIPTIONS — Prevents double subscriptions
- EXTRACT VARIABLES BEFORE assign_async CLOSURE — Closures copy entire referenced variables
- LOAD PRIMARY DATA IN mount/3, PAGINATION IN handle_params/3 — handle_params runs on EVERY URL change
- NEVER PASS SOCKET TO BUSINESS LOGIC — Extract data before calling contexts
- CHECK CHANGESET ERRORS BEFORE UI DEBUGGING — Silent form save = check
{:error, changeset}first, not viewport/JS - HIDDEN INPUTS FOR ALL REQUIRED EMBEDDED FIELDS — Every required field in an embedded schema MUST have a
hidden_inputif not directly editable - NEVER USE
assign_newFOR LIFECYCLE VALUES —assign_newskips the function if key exists. Useassign/3for locale, current user, or any value refreshed every mount - 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)
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
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:
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 → <noscript>-friendly skeleton. Cache → :persistent_term, ETS,
or Cachex. This satisfies Iron Law #1 AND keeps Googlebot/GPTBot happy.
PubSub with connected? check
def mount(_params, _session, socket) do
if connected?(socket), do: Chat.subscribe(room_id)
{:ok, socket}
end
Navigation Decision Tree
Same LiveView, different params? → patch / push_patch
Different LiveView, same live_session? → navigate / push_navigate
Different live_session or non-LiveView? → href / redirect
Component Decision Tree
Does component need BOTH internal state AND event handling?
│
├── YES → Does it encapsulate APPLICATION logic (not just DOM)?
│ ├── YES → Use LiveComponent ✅
│ └── NO → Refactor to function component with parent handling
│
└── NO → Use Function Component ✅
Official guidance: "Prefer function components over live components"
Common Anti-patterns
| Wrong | Right |
|---|---|
DB queries without assign_async |
Use assign_async for mount queries (SEO routes: connected? + cached dead render) |
assign(socket, items: list) for lists |
stream(socket, :items, list) |
PubSub subscribe without connected? |
if connected?(socket), do: subscribe() |
| Passing socket to context functions | Extract socket.assigns first |
Business logic in handle_event |
Delegate to context |
assign_new for locale/user in hooks |
assign/3 (must run every mount) |
References
For detailed patterns, see:
references/async-streams.md- assign_async, stream_async, streamsreferences/forms-uploads.md- Forms, validation, file uploadsreferences/components.md- Function components, LiveComponentsreferences/pubsub-navigation.md- PubSub, navigation, JS commandsreferences/js-interop.md- Third-party JS libraries, phx-update="ignore", hooksreferences/channels-presence.md- Phoenix Channels, Presence, token auth
Files (claude-elixir-phoenix)
-
references
-
async-streams.md 5.6 KB
# Async and Streams Reference ## Lifecycle Execution Order ``` [Initial HTTP Request] ↓ mount/3 (disconnected) → handle_params/3 → render/1 ↓ [WebSocket Connection] ↓ mount/3 (connected) → handle_params/3 → render/1 ↓ [Stateful Loop] handle_event/3, handle_info/2, handle_async/3 ``` Code in mount runs twice unless you use `assign_async` or check `connected?/1` ## Async Assigns (LiveView 1.0+) Extract variables before the closure to avoid copying the socket: ```elixir def mount(%{"slug" => slug}, _session, socket) do # Extract needed values BEFORE the closure scope = socket.assigns.current_scope {:ok, socket |> assign(:page_title, "Dashboard") |> assign_async(:org, fn -> {:ok, %{org: fetch_org(scope, slug)}} end) |> assign_async([:posts, :comments], fn -> {:ok, %{posts: list_posts(slug), comments: list_comments(slug)}} end)} end # In template - handle loading state ~H""" <.async_result :let={org} assign={@org}> <:loading>Loading <.spinner /></:loading> <:failed :let={_failure}>Error loading</:failed> {org.name} </.async_result> """ ``` ### Cancel Async Operations ```elixir def handle_event("cancel_search", _, socket) do {:noreply, cancel_async(socket, :search_results)} end ``` ### Testing Async Operations ```elixir test "loads data asynchronously", %{conn: conn} do {:ok, view, html} = live(conn, ~p"/dashboard") assert html =~ "Loading..." html = render_async(view) # Wait for async to complete assert html =~ "Dashboard Data" end ``` ## stream_async (LiveView 1.1+) ```elixir def mount(%{"slug" => slug}, _, socket) do {:ok, stream_async(socket, :posts, fn -> {:ok, list_posts!()} end)} end ``` ## Streams (for Lists) ### Basic Stream Operations ```elixir # Mount - initialize stream def mount(_params, _session, socket) do {:ok, stream(socket, :items, Items.list_items())} end # Insert item (at beginning) def handle_event("create", params, socket) do {:ok, item} = Items.create_item(params) {:noreply, stream_insert(socket, :items, item, at: 0)} end # Update item def handle_event("update", %{"id" => id} = params, socket) do item = Items.get_item!(id) {:ok, updated} = Items.update_item(item, params) {:noreply, stream_insert(socket, :items, updated)} end # Delete item def handle_event("delete", %{"id" => id}, socket) do item = Items.get_item!(id) {:ok, _} = Items.delete_item(item) {:noreply, stream_delete(socket, :items, item)} end ``` ### Stream Pagination with Limit ```elixir # Append new items, prune from top (keep last 30) stream(socket, :posts, new_posts, at: -1, limit: -30) # Prepend items, prune from bottom (keep first 30) stream(socket, :posts, Enum.reverse(posts), at: 0, limit: 30) ``` ### Empty Stream Handling (Use CSS) Cannot use `Enum.empty?` on streams. Use `:only-child`: ```elixir ~H""" <tbody id="songs" phx-update="stream"> <tr id="songs-empty" class="only:table-row hidden"> <td colspan="3">No songs found</td> </tr> <tr :for={{dom_id, song} <- @streams.songs} id={dom_id}> <td>{song.title}</td> </tr> </tbody> """ ``` ### Stream Template ```elixir ~H""" <div id="items" phx-update="stream"> <div :for={{dom_id, item} <- @streams.items} id={dom_id}> {item.name} </div> </div> """ ``` ## SEO Dead-Render Pattern (cache-backed disconnected branch) The disconnected mount renders the HTML that Googlebot, GPTBot, ChatGPT-User, PerplexityBot, ClaudeBot, and JS-disabled clients see. For SEO-visible routes (marketing pages, articles, product listings, public catalogs) it IS correct to populate that render with real content. The wrong way is `Repo.all/1` on every dead render — that doubles DB load. The right way is cache-backed: ```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 ``` Cache backends that work well: - `:persistent_term.put/2` for content updated by an Oban cron (sitemap-style) - ETS for sub-microsecond lookups (Cachex, ConCache, hand-rolled GenServer) - Edge cache (Cloudflare, Fly.io edge) — set `cache-control: public` on the route Empty fallback is also acceptable when the page renders a skeleton via CSS: ```elixir else: [] # template shows skeleton with `:only-child` rows ``` What NOT to do: ```elixir # ❌ Doubles DB load — runs on dead render AND connect def mount(_params, _session, socket) do products = Catalog.list_products() {:ok, assign(socket, products: products)} end # ❌ Empty assign on private/authed routes — flicker, no SEO benefit # (use assign_async for private dashboards instead) ``` This pattern satisfies Iron Law #1 because the disconnected branch does NOT hit the database. The cache is populated by a background job, not the request. ## Anti-patterns ```elixir # ❌ Database queries when disconnected (runs TWICE) def mount(_params, _session, socket) do data = Repo.all(User) # ← HTTP render + WebSocket connect {:ok, assign(socket, data: data)} end # ✅ Use assign_async (runs only when connected) def mount(_params, _session, socket) do {:ok, assign_async(socket, :data, fn -> {:ok, %{data: Repo.all(User)}} end)} end # ❌ Copying socket to async closure assign_async(socket, :org, fn -> {:ok, %{org: fetch_org(socket.assigns.slug)}} end) # ✅ Extract before closure slug = socket.assigns.slug assign_async(socket, :org, fn -> {:ok, %{org: fetch_org(slug)}} end) # ❌ Not using streams for lists (memory hog) {:ok, assign(socket, items: Items.list_items())} # ✅ Use streams (O(1) memory) {:ok, stream(socket, :items, Items.list_items())} ``` -
channels-presence.md 5.1 KB
# Channels and Presence Reference ## When to Use Channels vs LiveView | Need | Use | |------|-----| | Interactive UI, server-rendered HTML | LiveView | | Custom binary protocol, gaming | Channels | | Mobile/desktop native client | Channels | | Bidirectional data sync (no HTML) | Channels | | Online user tracking | Presence (with either) | **Default to LiveView** for web apps. Use Channels when you need non-HTML communication or native client support. ## Channel Architecture ### Topic Routing ```elixir # In UserSocket channel "room:*", MyAppWeb.RoomChannel channel "notifications:*", MyAppWeb.NotificationChannel ``` Topics use `"topic:subtopic"` convention with wildcard matching. ### Core Callbacks ```elixir defmodule MyAppWeb.RoomChannel do use MyAppWeb, :channel # Authorization on join def join("room:lobby", _message, socket) do {:ok, socket} end def join("room:" <> _private, _params, _socket) do {:error, %{reason: "unauthorized"}} end # Handle incoming events def handle_in("new_msg", %{"body" => body}, socket) do broadcast!(socket, "new_msg", %{body: body}) {:noreply, socket} end # Intercept outgoing (per-client filtering) intercept ["user_joined"] def handle_out("user_joined", msg, socket) do if ignoring_user?(socket.assigns[:user], msg.user_id) do {:noreply, socket} else push(socket, "user_joined", msg) {:noreply, socket} end end end ``` ### Token Authentication ```elixir # 1. Endpoint config socket "/socket", MyAppWeb.UserSocket, websocket: true, longpoll: false, auth_token: true # 2. Generate token (in conn pipeline) token = Phoenix.Token.sign(conn, "user socket", user.id) assign(conn, :user_token, token) # 3. Verify in Socket.connect/3 def connect(_params, socket, connect_info) do case Phoenix.Token.verify( socket, "user socket", connect_info[:auth_token], max_age: 1_209_600 # 2 weeks ) do {:ok, user_id} -> {:ok, assign(socket, :current_user, user_id)} {:error, _reason} -> :error end end ``` ### Client-Side Patterns ```javascript // Connect let socket = new Socket("/socket", {authToken: window.userToken}) socket.connect() // Join channel let channel = socket.channel("room:lobby", {}) channel.join() .receive("ok", resp => console.log("Joined", resp)) .receive("error", resp => console.log("Failed", resp)) // Send events channel.push("new_msg", {body: "hello"}) // Receive events channel.on("new_msg", payload => { renderMessage(payload.body) }) ``` ## Presence Track online users with CRDT-based conflict resolution. ### Setup ```elixir # lib/my_app_web/channels/presence.ex defmodule MyAppWeb.Presence do use Phoenix.Presence, otp_app: :my_app, pubsub_server: MyApp.PubSub end ``` ### Track Users ```elixir def join("room:" <> room_id, _params, socket) do send(self(), :after_join) {:ok, socket} end def handle_info(:after_join, socket) do {:ok, _} = Presence.track(socket, socket.assigns.user_id, %{ online_at: inspect(System.system_time(:second)), typing: false }) push(socket, "presence_state", Presence.list(socket)) {:noreply, socket} end ``` ### Update Presence Metadata ```elixir def handle_in("typing", %{"typing" => typing}, socket) do {:ok, _} = Presence.update(socket, socket.assigns.user_id, fn meta -> Map.put(meta, :typing, typing) end) {:noreply, socket} end ``` ### Client-Side Presence ```javascript import {Presence} from "phoenix" let presences = {} channel.on("presence_state", state => { presences = Presence.syncState(presences, state) renderOnlineUsers(presences) }) channel.on("presence_diff", diff => { presences = Presence.syncDiff(presences, diff) renderOnlineUsers(presences) }) ``` ### Presence with LiveView ```elixir def mount(_params, _session, socket) do if connected?(socket) do MyAppWeb.Presence.track(self(), "room:lobby", socket.assigns.current_user.id, %{joined_at: DateTime.utc_now()}) Phoenix.PubSub.subscribe(MyApp.PubSub, "room:lobby") end {:ok, assign(socket, :presences, MyAppWeb.Presence.list("room:lobby"))} end def handle_info(%Phoenix.Socket.Broadcast{event: "presence_diff", payload: diff}, socket) do {:noreply, update(socket, :presences, fn presences -> presences |> MyAppWeb.Presence.merge(diff) end)} end ``` ## Reliability Patterns ### Message Delivery Phoenix Channels provide **at-most-once** delivery. For stronger guarantees, implement persistence: ```elixir # Recovery: last-seen ID pattern def join("rooms:" <> id, params, socket) do messages = fetch_messages_since(params["last_seen_id"]) {:ok, %{messages: messages}, socket} end ``` ### Scaling - Millions of subscribers per node with reasonable latency - PubSub handles cluster broadcasts automatically - One message per additional node for distributed broadcasts ## Anti-patterns | Wrong | Right | |-------|-------| | Channel for HTML UI | Use LiveView | | No auth in `join/3` | Always verify in join | | Atom keys in payloads | String keys only | | No token expiry | Set `max_age` on tokens | | Sync DB calls in handle_in | Use async Task or Oban | -
components.md 2.7 KB
# Components Reference ## Function Components ```elixir # In core_components.ex or separate file attr :user, :map, required: true attr :class, :string, default: "" attr :rest, :global, include: ~w(disabled) slot :inner_block slot :actions def user_card(assigns) do ~H""" <div class={["card", @class]} {@rest}> <h3>{@user.name}</h3> <p>{@user.email}</p> {render_slot(@inner_block)} <div :if={@actions != []}> {render_slot(@actions)} </div> </div> """ end # Usage ~H""" <.user_card user={@user} class="mb-4"> <:actions> <.button phx-click="edit">Edit</.button> </:actions> </.user_card> """ ``` ## LiveComponents (Stateful) **Key rule**: DON'T update local state - notify parent to avoid sync issues ```elixir defmodule MyAppWeb.Components.SearchBox do use MyAppWeb, :live_component @impl true def mount(socket) do {:ok, assign(socket, query: "", results: [])} end @impl true def handle_event("search", %{"query" => query}, socket) do results = search(query) {:noreply, assign(socket, query: query, results: results)} end # Notify parent instead of updating shared state @impl true def handle_event("select", %{"id" => id}, socket) do send(self(), {:item_selected, id}) {:noreply, socket} end @impl true def render(assigns) do ~H""" <div> <form phx-change="search" phx-target={@myself}> <input type="text" name="query" value={@query} /> </form> <ul> <li :for={result <- @results} phx-click="select" phx-value-id={result.id} phx-target={@myself}> {result.name} </li> </ul> </div> """ end defp search(query), do: # Search implementation end # Usage ~H""" <.live_component module={SearchBox} id="search" /> """ ``` ## Colocated Hooks (LiveView 1.1+) ```elixir def phone_input(assigns) do ~H""" <input type="text" id="phone" phx-hook=".PhoneNumber" /> <script :type={Phoenix.LiveView.ColocatedHook} name=".PhoneNumber"> export default { mounted() { this.el.addEventListener("input", e => { // Format phone number }) } } </script> """ end ``` ## JS Commands (No Server Round-trip) ```elixir # Chained commands def hide_modal(js \\ %JS{}) do js |> JS.hide(transition: "fade-out", to: "#modal") |> JS.hide(transition: "fade-out-scale", to: "#modal-content") end # In template <button phx-click={hide_modal()}>Close</button> # Push with loading indicator <button phx-click={JS.push("save", loading: "#form")}>Save</button> # Focus management <button phx-click={JS.focus(to: "#input")}>Focus Input</button> # Toggle visibility <button phx-click={JS.toggle(to: "#details")}>Toggle Details</button> ``` -
forms-uploads.md 3.3 KB
# Forms and Uploads Reference ## Form Handling ```elixir # Simple form def mount(_params, _session, socket) do changeset = Accounts.change_user(%User{}) {:ok, assign(socket, form: to_form(changeset))} end def handle_event("validate", %{"user" => params}, socket) do changeset = %User{} |> Accounts.change_user(params) |> Map.put(:action, :validate) # Triggers error display {:noreply, assign(socket, form: to_form(changeset))} end def handle_event("save", %{"user" => params}, socket) do case Accounts.create_user(socket.assigns.current_scope, params) do {:ok, _user} -> {:noreply, socket |> put_flash(:info, "User created") |> push_navigate(to: ~p"/users")} {:error, changeset} -> {:noreply, assign(socket, form: to_form(changeset))} end end # Template ~H""" <.form for={@form} phx-change="validate" phx-submit="save"> <.input field={@form[:name]} label="Name" /> <.input field={@form[:email]} type="email" label="Email" /> <.button>Save</.button> </.form> """ ``` ## Debouncing & Throttling ```elixir # Wait until user stops typing (500ms) <input phx-debounce="500" /> # On blur only <input phx-debounce="blur" /> # Rate limit (immediate, then 1x/second) <button phx-throttle="1000">+</button> ``` ## Dynamic Nested Forms ```elixir # Changeset |> cast_assoc(:items, sort_param: :items_sort, drop_param: :items_drop) # Template ~H""" <.inputs_for :let={item} field={@form[:items]}> <input type="hidden" name="order[items_sort][]" value={item.index} /> <.input field={item[:name]} label="Item Name" /> <button type="button" name="order[items_drop][]" value={item.index}> Remove </button> </.inputs_for> <button type="button" name="order[items_sort][]" value="new"> Add Item </button> """ ``` ## File Uploads ```elixir def mount(_params, _session, socket) do {:ok, socket |> assign(:uploaded_files, []) |> allow_upload(:avatar, accept: ~w(.jpg .jpeg .png), max_entries: 2, max_file_size: 8_000_000)} end def handle_event("save", _params, socket) do uploaded_files = consume_uploaded_entries(socket, :avatar, fn %{path: path}, entry -> dest = Path.join(["priv", "static", "uploads", entry.client_name]) File.cp!(path, dest) {:ok, ~p"/uploads/#{entry.client_name}"} end) {:noreply, update(socket, :uploaded_files, &(&1 ++ uploaded_files))} end # Template ~H""" <form id="upload-form" phx-submit="save" phx-change="validate"> <.live_file_input upload={@uploads.avatar} /> <%= for entry <- @uploads.avatar.entries do %> <progress value={entry.progress} max="100">{entry.progress}%</progress> <%= for err <- upload_errors(@uploads.avatar, entry) do %> <p class="error">{error_to_string(err)}</p> <% end %> <% end %> <.button type="submit">Upload</.button> </form> """ ``` ## LiveView 1.0/1.1 Breaking Changes ```elixir # ❌ REMOVED - phx-feedback-for attribute # ✅ USE - Phoenix.Component.used_input?/1 errors = if Phoenix.Component.used_input?(field), do: field.errors, else: [] # ❌ REMOVED - live_component/2,3 helper <%= live_component(FormComponent, id: "form") %> # ✅ USE - component syntax <.live_component module={FormComponent} id="form" /> # ❌ REMOVED - push_redirect # ✅ USE - push_navigate push_navigate(socket, to: ~p"/path") ``` -
js-interop.md 8.1 KB
# JavaScript Interoperability Reference LiveView uses morphdom for DOM patching. Third-party JS libraries that manage their own DOM state conflict with this. This reference covers resolution patterns. ## Contents - [The Core Problem](#the-core-problem) - [Solution 1: phx-update="ignore"](#solution-1-phx-updateignore) - [Solution 2: Hooks with Lifecycle Management](#solution-2-hooks-with-lifecycle-management) - [Solution 3: Server-Driven Updates via pushEvent](#solution-3-server-driven-updates-via-pushevent) - [Common Library Patterns](#common-library-patterns) - [Anti-Patterns](#anti-patterns) - [Decision Tree](#decision-tree) - [Multi-Locale DOM Safety](#multi-locale-dom-safety) ## The Core Problem ``` LiveView Server Browser DOM │ │ │ sends diff ──────────────────► │ │ │ │ morphdom patches │ │ │ ✗ DESTROYS JS state │ ✗ TipTap loses content │ ✗ Alpine loses x-data │ ✗ Chart.js resets ``` ## Solution 1: phx-update="ignore" Tell LiveView to skip DOM diffing for a subtree. ```heex <div id="editor-wrapper" phx-hook="TipTapEditor"> <div id="editor-content" phx-update="ignore"> <!-- JS library manages everything inside here --> <!-- LiveView will NEVER touch this subtree --> </div> </div> ``` ### Rules 1. **Must have unique ID** - Required for morphdom tracking 2. **Initial content preserved** - Whatever is rendered on mount stays 3. **No LiveView updates** - Assigns changes won't affect this element 4. **Hook still works** - Parent can have phx-hook for JS initialization ### When to Use | Library Type | Use phx-update="ignore"? | |--------------|-------------------------| | Rich text editors (TipTap, Quill, ProseMirror) | Yes | | Charts (Chart.js, D3, Plotly) | Yes | | Maps (Leaflet, Mapbox, Google Maps) | Yes | | Date pickers (Flatpickr) | Yes | | Alpine.js components | Sometimes | | Simple JS animations | Usually not needed | ## Solution 2: Hooks with Lifecycle Management ```javascript // assets/js/hooks/tiptap_editor.js const TipTapEditor = { mounted() { // Initialize when element enters DOM this.editor = new Editor({ element: this.el.querySelector('[data-editor]'), content: this.el.dataset.content || '', onUpdate: ({ editor }) => { // Push changes to server this.pushEvent("editor-update", { content: editor.getHTML() }) } }) // Listen for server events this.handleEvent("set-content", ({ content }) => { this.editor.commands.setContent(content) }) }, updated() { // Called when LiveView updates the element // Usually no-op with phx-update="ignore" }, destroyed() { // Cleanup when element leaves DOM this.editor?.destroy() } } export default TipTapEditor ``` ### Hook Registration ```javascript // assets/js/app.js import TipTapEditor from "./hooks/tiptap_editor" import ChartHook from "./hooks/chart_hook" let Hooks = { TipTapEditor, ChartHook } let liveSocket = new LiveSocket("/live", Socket, { hooks: Hooks, params: { _csrf_token: csrfToken } }) ``` ### HEEx Template ```heex <div id={"editor-#{@post.id}"} phx-hook="TipTapEditor" data-content={@post.content} > <div data-editor phx-update="ignore"></div> </div> ``` ## Solution 3: Server-Driven Updates via pushEvent When server needs to update JS state without DOM patching: ### LiveView ```elixir def handle_event("load-template", %{"id" => id}, socket) do template = Templates.get!(id) # Push event to JS instead of assigning {:noreply, push_event(socket, "set-content", %{content: template.body})} end def handle_event("editor-update", %{"content" => content}, socket) do # Receive updates from JS {:noreply, assign(socket, draft_content: content)} end ``` ### JavaScript Hook ```javascript mounted() { this.handleEvent("set-content", ({ content }) => { // Server tells JS to update, not via DOM this.editor.commands.setContent(content, false) }) } ``` ## Common Library Patterns ### Chart.js ```heex <div id={"chart-#{@chart_id}"} phx-hook="ChartHook" data-type={@chart_type} data-datasets={Jason.encode!(@datasets)} > <canvas phx-update="ignore"></canvas> </div> ``` ```javascript const ChartHook = { mounted() { const ctx = this.el.querySelector('canvas') this.chart = new Chart(ctx, { type: this.el.dataset.type, data: JSON.parse(this.el.dataset.datasets) }) this.handleEvent("update-data", ({ datasets }) => { this.chart.data = datasets this.chart.update() }) }, destroyed() { this.chart?.destroy() } } ``` ### Leaflet Maps ```heex <div id="map-container" phx-hook="LeafletMap" data-lat={@center.lat} data-lng={@center.lng} > <div id="map" phx-update="ignore" style="height: 400px;"></div> </div> ``` ### Alpine.js ```heex <%!-- Option 1: phx-update="ignore" for Alpine-only sections --%> <div id="dropdown" phx-update="ignore" x-data="{ open: false }"> <button @click="open = !open">Toggle</button> <div x-show="open">Content</div> </div> <%!-- Option 2: Alpine for UI state, LiveView for data --%> <div x-data="{ expanded: false }"> <button @click="expanded = !expanded"><%= @item.title %></button> <div x-show="expanded"> <%!-- LiveView can update this content --%> <%= @item.description %> </div> </div> ``` ## Anti-Patterns ### Forget unique IDs ```heex <%!-- BAD: No ID means morphdom can't track it --%> <div phx-update="ignore"><canvas></canvas></div> <%!-- GOOD: Unique ID for tracking --%> <div id="chart-1" phx-update="ignore"><canvas></canvas></div> ``` ### Put phx-update on hook element ```heex <%!-- BAD: Hook won't receive updated() callback --%> <div id="editor" phx-hook="Editor" phx-update="ignore"></div> <%!-- GOOD: Separate hook from ignored content --%> <div id="editor" phx-hook="Editor"> <div id="editor-content" phx-update="ignore"></div> </div> ``` ### Forget cleanup in destroyed() ```javascript // BAD: Memory leak mounted() { this.chart = new Chart(...) } // GOOD: Proper cleanup mounted() { this.chart = new Chart(...) }, destroyed() { this.chart?.destroy() } ``` ### Use assigns for JS-managed content ```elixir # BAD: LiveView tries to update, conflicts with JS def handle_event("save", _, socket) do {:noreply, assign(socket, content: new_content)} end # GOOD: Push event to JS def handle_event("save", _, socket) do {:noreply, push_event(socket, "content-saved", %{})} end ``` ## Decision Tree ``` Is your JS library managing DOM state? │ ├─ NO → Normal LiveView, no special handling │ └─ YES → Does LiveView need to update that DOM area? │ ├─ NO → Use phx-update="ignore" │ JS owns it completely │ └─ YES → Use Hook + pushEvent pattern Server sends events, JS updates itself ``` ## Multi-Locale DOM Safety Translated text can change DOM structure (different word count, RTL, different element wrapping). JS hooks that rely on DOM position break across locales. ### Rules 1. **Don't use positional selectors** (`children[0]`, `firstChild`, `nth-child`) in JS hooks 2. **Use `querySelector` with `data-*` attributes** for stable element targeting 3. **Test with longest locale** — German/Finnish strings are often 30-50% longer than English ### Anti-Pattern ```javascript // BAD: Position changes when translation adds/removes elements mounted() { this.target = this.el.children[0] this.label = this.el.querySelector('span:first-child') } ``` ### Correct Pattern ```javascript // GOOD: data attributes survive translation changes mounted() { this.target = this.el.querySelector('[data-role="content"]') this.label = this.el.querySelector('[data-role="label"]') } ``` ```heex <div id="my-hook" phx-hook="MyHook"> <span data-role="label"><%= gettext("Status") %></span> <div data-role="content"><%= @content %></div> </div> ``` -
pubsub-navigation.md 4.6 KB
# PubSub and Navigation Reference ## PubSub Pattern ### Context Broadcasting Pattern ```elixir defmodule MyApp.Chat do @topic inspect(__MODULE__) def subscribe(room_id) do Phoenix.PubSub.subscribe(MyApp.PubSub, "#{@topic}:#{room_id}") end def create_message(scope, attrs) do case Repo.insert(changeset) do {:ok, message} = result -> Phoenix.PubSub.broadcast( MyApp.PubSub, "#{@topic}:#{message.room_id}", {__MODULE__, :message_created, message} ) result error -> error end end end ``` ### LiveView Subscription Pattern ```elixir def mount(%{"room_id" => room_id}, _session, socket) do if connected?(socket), do: Chat.subscribe(room_id) {:ok, stream(socket, :messages, Chat.list_messages(room_id))} end def handle_info({Chat, :message_created, message}, socket) do {:noreply, stream_insert(socket, :messages, message, at: 0)} end ``` ### Message Design - Use module-scoped topics: `@topic inspect(__MODULE__)` - Include entity ID in topic: `"#{@topic}:#{room_id}"` - Send tuples: `{Module, :event, data}` not maps ## Navigation Decision Tree ``` Same LiveView, different params? → patch / push_patch Different LiveView, same live_session? → navigate / push_navigate Different live_session or non-LiveView? → href / redirect ``` | Template | Server | Behavior | |----------|--------|----------| | `<.link patch={url}>` | `push_patch` | Same LV, calls handle_params | | `<.link navigate={url}>` | `push_navigate` | New LV, keeps layout | | `<.link href={url}>` | `redirect` | Full page reload | ## LiveView Structure ```elixir defmodule MyAppWeb.UserLive.Index do use MyAppWeb, :live_view alias MyApp.Accounts # ============================================ # Lifecycle # ============================================ @impl true def mount(_params, _session, socket) do if connected?(socket) do Accounts.subscribe() end {:ok, socket |> assign(:page_title, "Users") |> stream(:users, Accounts.list_users(socket.assigns.current_scope))} end @impl true def handle_params(params, _url, socket) do {:noreply, apply_action(socket, socket.assigns.live_action, params)} end defp apply_action(socket, :index, _params) do assign(socket, :user, nil) end defp apply_action(socket, :edit, %{"id" => id}) do assign(socket, :user, Accounts.get_user!(socket.assigns.current_scope, id)) end # ============================================ # Events # ============================================ @impl true def handle_event("delete", %{"id" => id}, socket) do user = Accounts.get_user!(socket.assigns.current_scope, id) {:ok, _} = Accounts.delete_user(socket.assigns.current_scope, user) {:noreply, stream_delete(socket, :users, user)} end # ============================================ # PubSub # ============================================ @impl true def handle_info({Accounts, [:user, :created], user}, socket) do {:noreply, stream_insert(socket, :users, user, at: 0)} end def handle_info({Accounts, [:user, :deleted], user}, socket) do {:noreply, stream_delete(socket, :users, user)} end # ============================================ # Render # ============================================ @impl true def render(assigns) do ~H""" <.header>Users</.header> <.table id="users" rows={@streams.users}> <:col :let={{_id, user}} label="Name">{user.name}</:col> <:action :let={{id, user}}> <.link phx-click="delete" phx-value-id={user.id} data-confirm="Sure?"> Delete </.link> </:action> </.table> """ end end ``` ## Anti-patterns ```elixir # ❌ PubSub subscribe without connected? check def mount(_params, _session, socket) do Phoenix.PubSub.subscribe(MyApp.PubSub, "topic") # Double subscribes! {:ok, socket} end # ✅ Check connected? first def mount(_params, _session, socket) do if connected?(socket), do: Phoenix.PubSub.subscribe(MyApp.PubSub, "topic") {:ok, socket} end # ❌ Business logic in handle_event def handle_event("submit", params, socket) do # 50 lines of logic here end # ✅ Delegate to context def handle_event("submit", params, socket) do case MyContext.do_thing(socket.assigns.current_scope, params) do {:ok, result} -> {:noreply, handle_success(socket, result)} {:error, reason} -> {:noreply, handle_error(socket, reason)} end end # ❌ Passing socket to business logic Accounts.update_user(socket, params) # ✅ Extract needed data Accounts.update_user(socket.assigns.current_scope, socket.assigns.user, params) ```
-
-
SKILL.md 5 KB
--- 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 → `<noscript>`-friendly skeleton. Cache → `:persistent_term`, ETS, or Cachex. This satisfies Iron Law #1 AND keeps Googlebot/GPTBot happy. ### PubSub with connected? check ```elixir def mount(_params, _session, socket) do if connected?(socket), do: Chat.subscribe(room_id) {:ok, socket} end ``` ## Navigation Decision Tree ``` Same LiveView, different params? → patch / push_patch Different LiveView, same live_session? → navigate / push_navigate Different live_session or non-LiveView? → href / redirect ``` ## Component Decision Tree ``` Does component need BOTH internal state AND event handling? │ ├── YES → Does it encapsulate APPLICATION logic (not just DOM)? │ ├── YES → Use LiveComponent ✅ │ └── NO → Refactor to function component with parent handling │ └── NO → Use Function Component ✅ ``` **Official guidance**: "Prefer function components over live components" ## Common Anti-patterns | Wrong | Right | |-------|-------| | DB queries without `assign_async` | Use `assign_async` for mount queries (SEO routes: `connected?` + cached dead render) | | `assign(socket, items: list)` for lists | `stream(socket, :items, list)` | | PubSub subscribe without `connected?` | `if connected?(socket), do: subscribe()` | | Passing socket to context functions | Extract `socket.assigns` first | | Business logic in `handle_event` | Delegate to context | | `assign_new` for locale/user in hooks | `assign/3` (must run every mount) | ## References For detailed patterns, see: - `references/async-streams.md` - assign_async, stream_async, streams - `references/forms-uploads.md` - Forms, validation, file uploads - `references/components.md` - Function components, LiveComponents - `references/pubsub-navigation.md` - PubSub, navigation, JS commands - `references/js-interop.md` - Third-party JS libraries, phx-update="ignore", hooks - `references/channels-presence.md` - Phoenix Channels, Presence, token auth
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.