agentleFS
igmarinigmarin/elixir-phoenix-skills/skills/liveview-streams/SKILL.md

liveview-streams

Skill4 starsChanged 4 months agoMITView on GitHub

MANDATORY for handling large collections in LiveView. Invoke before rendering lists with 100+ items. Covers Phoenix.LiveView.stream/4, stream_insert, stream_delete, DOM patching efficiency, and pagination with streams. Available in LiveView 0.19+. Trigger words: stream, LiveView stream, large list, pagination, DOM patching, stream_insert,

The file

We split this skill into the rules it gives an agent

3 rules. About 0 are common, 2 are distinctive, and 1 is very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈2

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 1

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    Follow this sequence when implementing LiveView streams:

    line 35
  2. Very specific:

    Use reset: true to clear existing items before inserting new ones — required for filtering and sorting:

    line 189
  3. Distinctive:

    If stream patching is not working as expected, check for:

    line 278

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

LiveView Streams

RULES — Follow these with no exceptions

1. Use streams for collections with 100+ items; combine with pagination or infinite scroll — smaller lists use regular assigns; never stream unlimited items 2. Use stream_insert/3 and stream_delete/3 for incremental updates — never replace the entire stream assign 3. Use phx-update="stream" in templates — required for stream DOM patching; missing this causes full re-renders 4. Use reset: true for filtering and sorting — clears existing items before inserting the new result set 5. Call stream_configure/3 before stream/3 — configuring a custom dom_id after the stream is initialized has no effect 6. Re-fetch from the database when sorting or filtering — streams do not keep items in assigns, so you cannot reorder them in memory

End-to-End Workflow

Follow this sequence when implementing LiveView streams:

  1. Identify collection size — see Rule 1 above for the threshold
  2. Define DOM ID strategy — decide on id attribute format (e.g., post-#{post.id})
  3. Add stream_configure/3 in mount/3 to set custom DOM ID generator
  4. Initialize stream — call stream(socket, :name, collection) in mount/3
  5. Update template — add phx-update="stream" container and id={dom_id} on items
  6. Handle events — use stream_insert/stream_delete for all mutations
  7. Add handle_info — handle Phoenix.PubSub broadcasts with stream updates
  8. Verify WS frames — confirm targeted DOM patches in browser DevTools

Basic Stream Pattern

defmodule MyAppWeb.PostLive.Index do
  use MyAppWeb, :live_view

  @impl true
  def mount(_params, _session, socket) do
    {:ok,
     socket
     |> stream_configure(:posts, dom_id: &"post-#{&1.id}")
     |> stream(:posts, Blog.list_posts())}
  end

  @impl true
  def handle_event("create", %{"post" => params}, socket) do
    case Blog.create_post(params) do
      {:ok, post} ->
        {:noreply, stream_insert(socket, :posts, post, at: 0)}

      {:error, %Ecto.Changeset{} = changeset} ->
        {:noreply, assign(socket, :form, to_form(changeset))}
    end
  end

  @impl true
  def handle_event("delete", %{"id" => id}, socket) do
    with {:ok, post} <- Blog.fetch_post(id),
         {:ok, _} <- Blog.delete_post(post) do
      {:noreply, stream_delete(socket, :posts, post)}
    else
      {:error, :not_found} ->
        {:noreply, put_flash(socket, :error, "Post not found")}

      {:error, %Ecto.Changeset{}} ->
        {:noreply, put_flash(socket, :error, "Could not delete post")}

      {:error, _} ->
        {:noreply, put_flash(socket, :error, "Could not delete post")}
    end
  end

  @impl true
  def handle_info({:post_created, post}, socket) do
    {:noreply, stream_insert(socket, :posts, post, at: 0)}
  end
end

Template with Streams

<div id="posts" phx-update="stream">
  <div :for={{dom_id, post} <- @streams.posts} id={dom_id} class="post">
    <h3><%= post.title %></h3>
    <p><%= post.body %></p>

    <button phx-click="delete" phx-value-id={post.id}>
      Delete
    </button>
  </div>
</div>

Infinite Scroll with Streams

@per_page 20

@impl true
def mount(_params, _session, socket) do
  {:ok,
   socket
   |> assign(:page, 1)
   |> assign(:loading, false)
   |> stream_configure(:posts, dom_id: &"post-#{&1.id}")
   |> stream(:posts, Blog.list_posts(page: 1, per_page: @per_page))}
end

@impl true
def handle_event("load_more", _params, socket) do
  page = socket.assigns.page + 1
  new_posts = Blog.list_posts(page: page, per_page: @per_page)

  {:noreply,
   socket
   |> assign(:page, page)
   |> then(fn s -> Enum.reduce(new_posts, s, &stream_insert(&2, :posts, &1)) end)}
end
<div id="posts" phx-update="stream">
  <div :for={{dom_id, post} <- @streams.posts} id={dom_id}>
    <%= post.title %>
  </div>
</div>

<div phx-hook="InfiniteScroll" data-page={@page}>
  <button phx-click="load_more" disabled={@loading}>Load More</button>
</div>

PubSub Integration with Streams

@impl true
def mount(_params, _session, socket) do
  if connected?(socket) do
    Phoenix.PubSub.subscribe(MyApp.PubSub, "posts")
  end

  {:ok,
   socket
   |> stream_configure(:posts, dom_id: &"post-#{&1.id}")
   |> assign(:editing_id, nil)
   |> assign(:form, nil)
   |> stream(:posts, Blog.list_posts())}
end

@impl true
def handle_info({:post_created, post}, socket) do
  {:noreply, stream_insert(socket, :posts, post, at: 0)}
end

Broadcast from your context:

def create_post(attrs) do
  {:ok, post} = Repo.insert(Post.changeset(%Post{}, attrs))
  Phoenix.PubSub.broadcast(MyApp.PubSub, "posts", {:post_created, post})
  {:ok, post}
end

Resetting and Replacing Streams

Use reset: true to clear existing items before inserting new ones — required for filtering and sorting:

# Filtering
def handle_event("filter", %{"status" => status}, socket) do
  {:noreply, stream(socket, :posts, Blog.list_posts_by_status(status), reset: true)}
end

# Sorting — re-fetch from the database because streams do not store items in assigns
def handle_event("sort", %{"column" => col, "direction" => dir}, socket) do
  direction = String.to_existing_atom(dir)
  sorted = Blog.list_posts(order_by: [{direction, String.to_existing_atom(col)}])

  {:noreply,
   socket
   |> assign(:sort_direction, direction)
   |> stream(:posts, sorted, reset: true)}
end

Edit-in-Place Pattern

@impl true
def handle_event("start_edit", %{"id" => id}, socket) do
  case Blog.fetch_post(id) do
    {:ok, post} ->
      {:noreply,
       socket
       |> assign(:editing_id, id)
       |> assign(:form, to_form(Blog.change_post(post)))}

    {:error, :not_found} ->
      {:noreply, put_flash(socket, :error, "Post not found")}
  end
end

@impl true
def handle_event("save_edit", %{"id" => id, "post" => params}, socket) do
  with {:ok, post} <- Blog.fetch_post(id),
       {:ok, updated_post} <- Blog.update_post(post, params) do
    {:noreply,
     socket
     |> assign(:editing_id, nil)
     |> assign(:form, nil)
     |> stream_insert(:posts, updated_post)}
  else
    {:error, :not_found} ->
      {:noreply, put_flash(socket, :error, "Post not found")}

    {:error, %Ecto.Changeset{} = changeset} ->
      {:noreply,
       socket
       |> assign(:editing_id, id)
       |> assign(:form, to_form(changeset))}
  end
end

@impl true
def handle_event("cancel_edit", _params, socket) do
  {:noreply,
   socket
   |> assign(:editing_id, nil)
   |> assign(:form, nil)}
end
<div id="posts" phx-update="stream">
  <div :for={{dom_id, post} <- @streams.posts} id={dom_id}>
    <%= if @editing_id == post.id do %>
      <.form for={@form} phx-submit="save_edit">
        <input type="hidden" name="id" value={post.id} />
        <.input field={@form[:title]} type="text" label="Title" />
        <button type="submit">Save</button>
        <button type="button" phx-click="cancel_edit">Cancel</button>
      </.form>
    <% else %>
      <h3><%= post.title %></h3>
      <button phx-click="start_edit" phx-value-id={post.id}>Edit</button>
    <% end %>
  </div>
</div>

Debugging Checklist

If stream patching is not working as expected, check for:

  • Missing phx-update="stream" on the container <div>
  • Missing id={dom_id} on each item element
  • Accidentally replacing the entire stream assign instead of using stream_insert/stream_delete
  • DOM ID collisions caused by non-unique item IDs (especially across pages in infinite scroll)
  • Using stream_insert without proper dom_id configuration
  • Forgetting to call stream_configure before stream when using custom IDs

Manual verification step: Open your browser's DevTools panel → Network tab → WS frames. Confirm incoming frames contain targeted patch operations for individual items, not full list replacements. If you see full re-renders, the first two checklist items above are the most common cause.

Common Pitfalls

❌ Don't✅ Do
Replace the whole stream assign to update itUse stream_insert/3 and stream_delete/3 for incremental changes
Omit phx-update="stream" on the containerAdd phx-update="stream" so LiveView patches items instead of re-rendering
Forget id={dom_id} on each item elementSet id={dom_id} so DOM patching can target individual rows
Reuse non-unique IDs across pages in infinite scrollConfigure a stable dom_id (e.g. &"post-#{&1.id}") so IDs never collide
Sort or filter from items held in assignsRe-fetch from the database and apply stream(..., reset: true)
Stream tiny collections (a handful of rows)Use regular assigns; reserve streams for 100+ item lists
Call stream_configure/3 after stream/3Configure the custom dom_id before the stream is initialized

Integration

PredecessorThis SkillSuccessor
phoenix-liveview-essentialsliveview-streamsphoenix-pubsub-patterns
ecto-essentialsliveview-streamstesting-essentials

Companion skills: phoenix-liveview-essentials, phoenix-pubsub-patterns, testing-essentials.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.