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.jsx
import { useState } from "react";
import { useLiveReact } from "live_react";

export function Streams({ messages = [] }) {
  const { pushEvent } = useLiveReact();
  const [draft, setDraft] = useState("");
  const [editingId, setEditingId] = useState(null);
  const [editText, setEditText] = useState("");

  const send = (e) => {
    e.preventDefault();
    if (!draft.trim()) return;
    pushEvent("add", { text: draft });
    setDraft("");
  };

  const startEditing = (message) => {
    setEditingId(message.id);
    setEditText(message.text);
  };

  const saveEdit = (e) => {
    e.preventDefault();
    pushEvent("edit", { id: editingId, text: editText });
    setEditingId(null);
  };

  return (
    <div className="flex flex-col gap-3">
      <div className="flex gap-2">
        <form className="flex gap-2" onSubmit={send}>
          <input
            type="text"
            value={draft}
            placeholder="say something…"
            onChange={(e) => setDraft(e.target.value)}
            className="rounded-md border px-2 py-1"
          />
          <button type="submit" className="rounded-md border px-3 py-1">
            Send
          </button>
        </form>

        <button
          type="button"
          className="rounded-md border px-3 py-1"
          onClick={() => pushEvent("replace_all", {})}
        >
          Replace all
        </button>
      </div>

      <ul className="flex flex-col gap-1">
        {messages.map((message) => (
          // `__dom_id` is added by LiveReact for every stream item — a
          // stable id derived from Phoenix's own stream ref, safe to use as
          // the React key even across inserts, deletes and resets.
          <li
            key={message.__dom_id}
            className="flex items-center justify-between border-t border-[#eee] py-1"
          >
            {editingId === message.id ? (
              <form className="flex grow gap-2" onSubmit={saveEdit}>
                <input
                  type="text"
                  value={editText}
                  autoFocus
                  onChange={(e) => setEditText(e.target.value)}
                  className="grow rounded-md border px-2 py-1"
                />
                <button type="submit" className="rounded-md border px-2 py-0.5 text-sm">
                  Save
                </button>
              </form>
            ) : (
              <>
                <span>{message.text}</span>
                <span className="flex gap-2">
                  <button
                    type="button"
                    className="rounded-md border px-2 py-0.5 text-sm"
                    onClick={() => startEditing(message)}
                  >
                    Edit
                  </button>
                  <button
                    type="button"
                    className="rounded-md border px-2 py-0.5 text-sm"
                    onClick={() => pushEvent("delete", { id: message.id })}
                  >
                    Delete
                  </button>
                </span>
              </>
            )}
          </li>
        ))}
      </ul>
    </div>
  );
}

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