Intent in-phoenix-liveview

Phoenix LiveView lifecycle rules: two-phase mount, streams, async loading, thin LiveViews, components

install
source · Clone the upstream repo
git clone https://github.com/matthewsinclair/intent
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/matthewsinclair/intent "$T" && mkdir -p ~/.claude/skills && cp -r "$T/intent/plugins/claude/skills/in-phoenix-liveview" ~/.claude/skills/matthewsinclair-intent-in-phoenix-liveview && rm -rf "$T"
manifest: intent/plugins/claude/skills/in-phoenix-liveview/SKILL.md
source content

Phoenix LiveView Essentials

LiveView lifecycle and rendering rules enforced on every LiveView module. These are mandatory -- no exceptions.

Rules

1. Two-phase mount -- guard async operations with
connected?(socket)

Mount is called twice: once for static HTML render (disconnected), once for WebSocket (connected). Never subscribe to PubSub, start timers, or spawn async work during the static render.

# BAD -- subscribes during static render
@impl true
def mount(_params, _session, socket) do
  Phoenix.PubSub.subscribe(MyApp.PubSub, "updates")
  {:ok, assign(socket, :items, load_items())}
end

# GOOD -- guard with connected?
@impl true
def mount(_params, _session, socket) do
  if connected?(socket) do
    Phoenix.PubSub.subscribe(MyApp.PubSub, "updates")
  end

  {:ok, assign(socket, :items, load_items())}
end

2. Streams for large or dynamic lists

Never assign full collections that grow or update frequently. Use

stream/3
for memory-efficient rendering where only changes are sent to the client.

# BAD -- full list re-rendered on every update
@impl true
def mount(_params, _session, socket) do
  {:ok, assign(socket, :messages, list_messages())}
end

def handle_info({:new_message, msg}, socket) do
  {:noreply, update(socket, :messages, &[msg | &1])}
end

# GOOD -- stream, only diffs sent
@impl true
def mount(_params, _session, socket) do
  {:ok, stream(socket, :messages, list_messages())}
end

def handle_info({:new_message, msg}, socket) do
  {:noreply, stream_insert(socket, :messages, msg, at: 0)}
end

Template requires

phx-update="stream"
:

<ul id="messages" phx-update="stream">
  <li :for={{dom_id, msg} <- @streams.messages} id={dom_id}>{msg.body}</li>
</ul>

3.
@impl true
on all LiveView callbacks

Every callback must be annotated. Catches typos at compile time and makes callback vs custom function distinction clear.

# BAD
def mount(_params, _session, socket), do: {:ok, socket}
def handle_event("click", _params, socket), do: {:noreply, socket}

# GOOD
@impl true
def mount(_params, _session, socket), do: {:ok, socket}

@impl true
def handle_event("click", _params, socket), do: {:noreply, socket}

4. Thin LiveViews -- domain logic in context/domain modules

LiveViews are coordinators. They assign state, dispatch to domain, and update assigns. No business logic, data transformation, or aggregation queries.

# BAD -- business logic in LiveView
@impl true
def handle_event("publish", %{"id" => id}, socket) do
  post = MyApp.Content.get_post!(id)
  if post.status == :draft and post.word_count > 100 do
    MyApp.Content.update_post(post, %{status: :published, published_at: DateTime.utc_now()})
    # send notification, update analytics...
  end
end

# GOOD -- domain function handles all logic
@impl true
def handle_event("publish", %{"id" => id}, socket) do
  case MyApp.Content.publish_post(id, actor: socket.assigns.current_user) do
    {:ok, post} -> {:noreply, stream_insert(socket, :posts, post)}
    {:error, reason} -> {:noreply, put_flash(socket, :error, reason)}
  end
end

5.
push_navigate
vs
push_patch
-- correct semantics

push_patch
stays in the same LiveView (triggers
handle_params
).
push_navigate
goes to a different LiveView (triggers full mount).

# GOOD -- same LiveView, updating filters
def handle_event("filter", %{"status" => status}, socket) do
  {:noreply, push_patch(socket, to: ~p"/posts?status=#{status}")}
end

# GOOD -- different LiveView, navigating away
def handle_event("view_details", %{"id" => id}, socket) do
  {:noreply, push_navigate(socket, to: ~p"/posts/#{id}")}
end

# BAD -- push_patch to a different LiveView (won't remount)
def handle_event("go_home", _params, socket) do
  {:noreply, push_patch(socket, to: ~p"/")}
end

6.
assign_async
for non-blocking data loading

Never block mount with expensive operations. Use

assign_async/3
for concurrent data loading after initial render.

# BAD -- blocks initial render
@impl true
def mount(_params, _session, socket) do
  stats = MyApp.Analytics.compute_stats!()  # slow query
  {:ok, assign(socket, :stats, stats)}
end

# GOOD -- non-blocking, shows loading state
@impl true
def mount(_params, _session, socket) do
  {:ok,
   assign_async(socket, :stats, fn ->
     {:ok, %{stats: MyApp.Analytics.compute_stats!()}}
   end)}
end

Handle async states in template:

<.async_result :let={stats} assign={@stats}>
  <:loading>Computing stats...</:loading>
  <:failed :let={_reason}>Failed to load stats</:failed>
  <div>Total: {stats.total}</div>
</.async_result>

7. Extract repeated HEEX into reusable components

When the same HTML structure appears twice, extract it into a function component with typed attributes.

# BAD -- duplicated markup across LiveViews
~H"""
<span class="badge badge-green">Active</span>
...
<span class="badge badge-red">Inactive</span>
"""

# GOOD -- reusable component
attr :color, :atom, values: [:green, :red, :yellow, :gray], required: true
attr :label, :string, required: true

def status_badge(assigns) do
  ~H"""
  <span class={["badge", "badge-#{@color}"]}>{@label}</span>
  """
end

Use

attr
declarations for compile-time validation of component inputs.