Packages

React inside Phoenix LiveView — async code splitting, client-side props diffing, and zero-config component discovery.

Retired package: Release invalid - Not production-ready

Current section

Files

Jump to
react_phx lib react_phx.ex
Raw

lib/react_phx.ex

defmodule ReactPhx do
@moduledoc """
See READ.md for installation instructions and examples.
"""
use Phoenix.Component
import Phoenix.HTML
alias Phoenix.LiveView
alias ReactPhx.SSR
alias ReactPhx.Slots
require Logger
@ssr_default Application.compile_env(:react_phx, :ssr, false)
@doc """
Render a React component.
"""
def react(assigns) do
# Validate component name looks like a component, not a data value.
# Catches the common mistake of `<.react name={@name} ...>` where @name is a data prop.
component_name = Map.get(assigns, :name)
if is_binary(component_name) and
component_name != "" and
String.first(component_name) == String.downcase(String.first(component_name)) do
Logger.warning(
"react_phx: name=\"#{component_name}\" looks like a data value, not a component name. " <>
"Component names should be PascalCase (e.g., \"Counter\"). " <>
"If you meant to pass a prop called 'name', rename it (e.g., user_name={@name})."
)
end
init = assigns.__changed__ == nil
dead = assigns[:socket] == nil or not LiveView.connected?(assigns[:socket])
render_ssr? = init and dead and Map.get(assigns, :ssr, @ssr_default)
# we manually compute __changed__ for the computed props and slots so it's not sent without reason
{props, props_changed?} = extract(assigns, :props)
props = Map.new(props, fn {k, v} -> {k, ReactPhx.Encoder.encode(v)} end)
{slots, slots_changed?} = extract(assigns, :slots)
{streams, streams_changed?} = extract(assigns, :streams)
component_name = Map.get(assigns, :name)
streams_diff =
if streams_changed? do
stream_patches(streams)
else
[]
end
# Generate a stable ID on first render; reuse on subsequent renders.
# Map.put_new_lazy ensures id/1 is only called when :id is not already set.
assigns = Map.put_new_lazy(assigns, :id, fn -> id(component_name) end)
assigns =
assigns
|> Map.put_new(:class, nil)
|> Map.put(:__component_name, component_name)
|> Map.put(:props, props)
|> Map.put(:slots, if(slots_changed?, do: Slots.rendered_slot_map(slots), else: %{}))
|> Map.put(:streams_diff, streams_diff)
assigns =
Map.put(assigns, :ssr_render, if(render_ssr?, do: ssr_render(assigns), else: nil))
computed_changed =
%{
props: props_changed?,
slots: slots_changed?,
streams_diff: streams_changed?,
ssr_render: render_ssr?
}
assigns =
update_in(assigns.__changed__, fn
nil -> nil
changed -> for {k, true} <- computed_changed, into: changed, do: {k, true}
end)
# It's important to not add extra `\n` in the inner div or it will break hydration
~H"""
<div
id={@id}
data-name={@__component_name}
data-props={"#{json(@props)}"}
data-slots={"#{@slots |> Slots.base_encode_64 |> json}"}
data-streams-diff={if(@streams_diff != [], do: json(@streams_diff))}
data-ssr={is_map(@ssr_render)}
data-props-version={System.unique_integer([:monotonic, :positive])}
phx-update="ignore"
phx-hook="ReactHook"
class={@class}
><%= raw(@ssr_render[:html]) %></div>
<%= if assigns[:uploads] do %>
<div style="display:none">
<%= for {_key, upload} <- uploads_list(assigns[:uploads]) do %>
<.live_file_input upload={upload} />
<% end %>
</div>
<% end %>
"""
end
defp extract(assigns, type) do
Enum.reduce(assigns, {%{}, false}, fn {key, value}, {acc, changed} ->
case normalize_key(key, value) do
^type -> {Map.put(acc, key, value), changed || key_changed(assigns, key)}
_ -> {acc, changed}
end
end)
end
defp normalize_key(key, _val)
when key in ~w(id class ssr name socket uploads __changed__ __given__)a,
do: :special
defp normalize_key(_key, %Phoenix.LiveView.LiveStream{}), do: :streams
defp normalize_key(_key, [%{__slot__: _} | _]), do: :slots
defp normalize_key(key, val) when is_atom(key), do: key |> to_string() |> normalize_key(val)
defp normalize_key(_key, _val), do: :props
defp key_changed(%{__changed__: nil}, _key), do: true
defp key_changed(%{__changed__: changed}, key), do: changed[key] != nil
defp ssr_render(assigns) do
try do
name = Map.get(assigns, :name)
SSR.render(name, assigns.props, assigns.slots)
rescue
SSR.NotConfigured ->
nil
end
end
# ---------------------------------------------------------------------------
# Stream patch generation
# ---------------------------------------------------------------------------
# Depends on Phoenix.LiveView.LiveStream internals (>= 1.0.0).
# The struct fields (inserts, deletes, reset?) are NOT public API and may
# change across LiveView versions. We wrap processing in try/rescue so that
# a structural mismatch degrades gracefully rather than crashing the render.
# ---------------------------------------------------------------------------
@doc false
def stream_patches(streams) when is_map(streams) do
Enum.flat_map(streams, fn {name, %Phoenix.LiveView.LiveStream{} = stream} ->
stream_to_patches(name, stream)
end)
end
@doc false
def stream_to_patches(name, %Phoenix.LiveView.LiveStream{} = stream) do
try do
path = "/#{name}"
if stream.reset? do
# When reset is true, replace the entire stream with current inserts.
items =
stream.inserts
|> deduplicate_inserts()
|> Enum.map(fn {dom_id, _at, item, _limit, _update_only} ->
item
|> encode_stream_item()
|> Map.put("__dom_id", dom_id)
end)
[%{op: "replace", path: path, value: items}]
else
insert_ops =
stream.inserts
|> deduplicate_inserts()
|> Enum.flat_map(fn {dom_id, at, item, limit, _update_only} ->
encoded =
item
|> encode_stream_item()
|> Map.put("__dom_id", dom_id)
upsert = %{op: "upsert", path: path, match: dom_id, value: encoded}
upsert = if at != -1, do: Map.put(upsert, :at, at), else: upsert
if limit do
[upsert, %{op: "limit", path: path, value: limit}]
else
[upsert]
end
end)
delete_ops =
Enum.map(stream.deletes, fn dom_id ->
%{op: "delete_by_id", path: path, match: dom_id}
end)
insert_ops ++ delete_ops
end
rescue
_ ->
Logger.warning(
"react_phx: failed to process stream #{inspect(name)}. " <>
"LiveStream internals may have changed. Skipping stream patches."
)
[]
end
end
# Inserts are stored in reverse order and may contain duplicates.
# We deduplicate keeping only the most recent (first occurrence in reversed list)
# then reverse to restore insertion order.
defp deduplicate_inserts(inserts) do
{deduped, _seen} =
Enum.reduce(inserts, {[], MapSet.new()}, fn {id, _, _, _, _} = insert, {acc, seen} ->
if MapSet.member?(seen, id) do
{acc, seen}
else
{[insert | acc], MapSet.put(seen, id)}
end
end)
deduped
end
defp encode_stream_item(item) when is_struct(item) do
item
|> ReactPhx.Encoder.encode()
|> ensure_string_keys()
end
defp encode_stream_item(item) when is_map(item) do
item
|> Map.new(fn {k, v} -> {to_string(k), ReactPhx.Encoder.encode(v)} end)
end
defp encode_stream_item(item), do: ReactPhx.Encoder.encode(item)
defp ensure_string_keys(map) when is_map(map) do
Map.new(map, fn {k, v} -> {to_string(k), v} end)
end
defp ensure_string_keys(other), do: other
# Normalize uploads into a keyword-like list of {name, config} tuples.
# Accepts a map (e.g. %{avatar: %UploadConfig{}, photos: %UploadConfig{}})
# or a single UploadConfig struct.
defp uploads_list(%{} = uploads) do
Map.to_list(uploads)
end
defp json(data), do: Jason.encode!(data, escape: :html_safe)
defp id(name) do
# Deterministic ID based on component name alone.
# If you render multiple instances of the same component, you MUST pass an
# explicit `:id` attribute to each one — this matches standard Phoenix
# component behavior.
"react-#{name}"
end
end