Props & data

Phoenix Streams

A stream assign arrives in React as an array with __dom_id

  • stream/4
  • __dom_id

Key concepts

stream/4 manages a large or growing collection without keeping every item in LiveView memory or re-sending the whole list on every change. Assigning a stream to a prop hands React the current list as a plain array โ€” inserts, updates, deletes and resets all arrive as ordinary prop diffs.

streams_live.ex
defmodule MyAppWeb.StreamsLive do
  use MyAppWeb, :live_view

  @chatter [
    "turns out it was DNS. it's always DNS.",
    "works on my machine ๐Ÿคท",
    "the bug fixed itself. I don't trust it.",
    "coffee count: 4. regrets: 0.",
    "ship it ๐Ÿšข"
  ]

  def mount(_params, _session, socket) do
    socket =
      socket
      |> assign(:next_id, 1)
      |> stream(:messages, [%{id: 0, text: "hey, anyone here? ๐Ÿ‘‹"}])

    {:ok, socket}
  end

  def render(assigns) do
    ~H"""
    <.react name="Streams" messages={@streams.messages} socket={@socket} />
    """
  end

  # A brand new message is appended to the stream.
  def handle_event("add", params, socket) do
    id = socket.assigns.next_id

    socket =
      socket
      |> assign(:next_id, id + 1)
      |> stream_insert(:messages, %{id: id, text: message_text(params)})

    {:noreply, socket}
  end

  # `update_only: true` patches a message already on the page, without
  # moving it or re-inserting it if it's gone.
  def handle_event("edit", %{"id" => id} = params, socket) do
    message = %{id: id, text: message_text(params)}
    {:noreply, stream_insert(socket, :messages, message, update_only: true)}
  end

  def handle_event("delete", %{"id" => id}, socket) do
    {:noreply, stream_delete(socket, :messages, %{id: id})}
  end

  # `reset: true` throws the whole conversation away and starts a new one.
  def handle_event("replace_all", _params, socket) do
    id = socket.assigns.next_id

    messages =
      @chatter
      |> Enum.shuffle()
      |> Enum.take(3)
      |> Enum.with_index(id)
      |> Enum.map(fn {text, index} -> %{id: index, text: text} end)

    socket =
      socket
      |> assign(:next_id, id + length(messages))
      |> stream(:messages, messages, reset: true)

    {:noreply, socket}
  end

  defp message_text(%{"text" => text}) do
    case String.trim(text) do
      "" -> Enum.random(@chatter)
      text -> text
    end
  end

  defp message_text(_params), do: Enum.random(@chatter)
end

How it works

Every item in a stream carries a __dom_id key that LiveReact adds โ€” a stable id derived from Phoenix's own stream ref, not from any field of the message itself. Use it, not message.id, as the React key: it stays correct across stream_insert/3, stream_delete/3 and a full reset: true replacement, which is exactly what a stream's own DOM-patching semantics guarantee on the server side.

stream_insert(socket, :messages, message, update_only: true) patches an item that's already on the page without moving or re-inserting it โ€” that's what backs the "Edit" button, in contrast to a plain insert for "Send" and a full reset: true for "Replace all".