LiveViewReact passes Phoenix LiveStream assigns to React as immutable arrays
from disconnected SSR and no-JavaScript HTML through hydration, connected
updates, and reconnects. The server keeps Phoenix's operation order; the
browser applies each connected frame with copy-on-write updates so untouched
streams and items retain their references.
Setup
Create and update streams through Phoenix LiveView:
def mount(_params, _session, socket) do
{:ok, stream(socket, :users, list_users())}
end
def handle_event("add-user", params, socket) do
{:noreply, stream_insert(socket, :users, create_user!(params), at: 0)}
endPass the LiveStream directly to the React root:
<.react
id="users"
component="UserList"
socket={@socket}
users={@streams.users}
/>Each encoded item includes Phoenix's computed __dom_id. Use it as the React
key rather than deriving another identity:
type User = {
readonly __dom_id: string;
readonly id: number;
readonly name: string;
};
function UserList({ users }: { readonly users: readonly User[] }) {
return users.map((user) => <p key={user.__dom_id}>{user.name}</p>);
}Custom IDs configured with stream_configure/3 are preserved exactly.
An empty named stream is never inferred away: the component receives that prop
as [] in the mandatory initial frame.
Semantics
Phoenix has three related but distinct stream modes. LiveViewReact names and tests them separately rather than hiding them behind one snapshot flag.
Disconnected render
The SSR frame uses Phoenix's dead-render enumeration. It restores insertion
call order and retains only the newest item for a duplicate DOM ID. Like a
normal no-WebSocket Phoenix stream comprehension, it does not enforce limit
on the first render. Pending client-operation metadata such as at,
update_only, delete, and reset is not replayed against a client collection to
produce this frame.
That materialized plain-JSON snapshot, including every computed __dom_id, is
passed to renderToString and stored unchanged in the v2 hydration descriptor.
The browser hydrates it directly; it does not hydrate an empty list and fill it
from an effect.
Connected snapshot and incremental operations
Connected stream transport is one atomic frame per stream. The frame keeps
Phoenix's materialized items separate from its raw insert metadata, deletes,
and reset flag. This separation is necessary because update_only, at, and
limit depend on whether a DOM ID was already present in the browser; replaying
generic JSON Patch operations from an empty array cannot reproduce that rule.
An incremental frame reconciles against the current collection:
at: -1appends;at: 0prepends; other non-negative positions insert at that index. A position beyond the current length appends.- An existing
__dom_idthat was not reset or deleted updates in place. Phoenix does not reapply that item'satorlimit. - A missing
update_only: trueitem is skipped, including its limit. A present item is updated in place. - Deletes of missing IDs are idempotent.
- Delete and reset removal happen before incoming items. If an incoming DOM ID
existed immediately before that removal, Phoenix restores it as a new insert;
its
atandlimittherefore apply. A reset drops every unmentioned item. - For an accepted new or restored insert, a positive limit immediately keeps the first N items and a negative limit immediately keeps the last N.
- Multiple streams are independent. Updating one does not clone untouched sibling stream arrays.
A connected mount or reconnect snapshot rebuilds membership solely from the
incoming frame and drops stale stream names and unmentioned items. It still
consults prior browser membership to decide whether an incoming update_only
item may be restored, matching LiveView's join patch rather than pretending the
prior DOM never existed. When a connected snapshot arrives during hydration,
LiveViewReact first commits the exact disconnected frame and then renders the
newest queued state.
Transport v2
The initial SSR request and hydration descriptor use one mandatory flat frame:
{version: 2, component, identifierPrefix, props, streams, events, slots}All four data namespaces are required, even when empty, and every pair is checked for name collisions before React props are merged. A missing field, unknown field, malformed stream value, or unsupported version fails closed. There is no v1 parser or compatibility fallback.
Successful SSR serializes the full stream snapshot once in the hydration descriptor. The outer stream transport is empty until it carries a newer connected snapshot or patch, so the same full stream payload is not duplicated across two transport attributes. Client-only rendering instead uses the outer authoritative snapshot because no hydration descriptor exists.
Connected stream payloads use only this v2 operation:
{op: "stream", path: "/<stream-name>", value: {items, inserts, deletes, reset}}There is no stream-specific upsert, limit, or $$dom_id extension in the
generic JSON Patch lane. Frames and paths have an exact schema and fail closed
when fields, DOM-ID membership, numeric ranges, or prototype-sensitive names
are invalid.
Phoenix LiveView contract
Phoenix LiveView's
LiveStream struct
is not a public extension API. LiveViewReact isolates its use in one internal
adapter and currently supports the tuple and struct contract shipped by Phoenix
LiveView ~> 1.2.11. Stream keys must match its name; unsupported field or
insert-tuple shapes fail immediately instead of being guessed.
Keep LiveView within the Hex version range declared by liveview_react. Run
the package tests before widening that range for a newer LiveView line.