Claude Skill

liveview-patterns

'Build LiveView: async data (assign_async), PubSub (check connected?),

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download oliver-kriska-claude-elixir-phoenix-targets_amp_skills_liveview-patterns-9767a82.zip · 14 KB
Part of oliver-kriska/claude-elixir-phoenix — 93 skills

Install

skills CLI npx skills add https://github.com/oliver-kriska/claude-elixir-phoenix/tree/main/targets/amp/skills/liveview-patterns
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install oliver-kriska-claude-elixir-phoenix@llmmart
Git 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-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)

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, 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
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.

No comments yet.

Reviews (0)

No reviews yet.

Related