react_phx
This is an experimental project. It is NOT production-ready. Use at your own risk.
React inside Phoenix LiveView — with automatic code splitting, client-side props diffing, and zero-config component discovery.
A fork of live_react, rebuilt with async component loading, an Astro-inspired Vite plugin, and full TypeScript support.
Why
Phoenix LiveView is great for server-driven UIs. But when you need rich client-side interactivity — complex forms, data visualizations, drag-and-drop — React's ecosystem is unmatched. react_phx bridges the two:
- Write your server logic in Elixir LiveView
- Write your interactive components in React
- They communicate over WebSocket in real-time
The problem with live_react: All React components are bundled into a single JS file. In large projects (400+ components), this produces a 5.8 MB app.js that blocks page load.
react_phx solves this: Automatic per-component code splitting. A 5.8 MB bundle becomes a 12 KB entry + lazy-loaded chunks.
Features
- Zero-config code splitting — Astro-inspired Vite plugin auto-discovers components and creates per-component chunks
- Async component loading — Components load on demand via
import.meta.glob({ eager: false }) - Client-side props diffing — Only changed props trigger React re-renders
- Phoenix Streams — Full support with
upsert,delete_by_id,limitcustom patch ops - Encoder protocol — Safe serialization of Elixir structs (DateTime, Form, Upload) with fail-closed default
- Reconnect handling — Full state recovery after WebSocket disconnect
- Error boundaries — React errors don't crash the LiveView
- TypeScript — Full type definitions for all hooks and APIs
React Hooks
useLiveReact()— Access LiveView hook functions (pushEvent, handleEvent, etc.)useLiveEvent(event, callback)— Subscribe to server events with auto-cleanupuseLiveForm(serverForm, options)— Ecto changeset integration with nested fields, validation, dirty/touched trackinguseLiveUpload(uploadConfig)— File upload with progress, cancel, and validation
Quick Start
1. Add dependency
# mix.exs
{:react_phx, "~> 0.1.0"}2. Configure Vite
// assets/vite.config.js
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import reactPhx from "react_phx/vite-plugin";
export default defineConfig({
plugins: [react(), reactPhx()],
build: { outDir: "../priv/static/assets" },
});3. Wire up app.js
// assets/js/app.js
import "phoenix_html";
import { Socket } from "phoenix";
import { LiveSocket } from "phoenix_live_view";
import hooks from "virtual:react-phx";
let liveSocket = new LiveSocket("/live", Socket, {
hooks: { ...hooks },
});
liveSocket.connect();4. Create React components
// assets/react-components/Counter.tsx
import { useLiveReact } from "react_phx";
export default function Counter({ count }: { count: number }) {
const { pushEvent } = useLiveReact();
return (
<div>
<span>{count}</span>
<button onClick={() => pushEvent("increment", {})}>+1</button>
</div>
);
}5. Use in LiveView
defmodule MyAppWeb.CounterLive do
use MyAppWeb, :live_view
import ReactPhx
def mount(_params, _session, socket) do
{:ok, assign(socket, count: 0)}
end
def render(assigns) do
~H"""
<.react name="Counter" count={@count} />
"""
end
def handle_event("increment", _, socket) do
{:noreply, update(socket, :count, &(&1 + 1))}
end
end
That's it. The Vite plugin auto-discovers Counter.tsx, creates a lazy chunk, and loads it on demand. No manual component registry needed.
Benchmark Results
Measured against the same 35-component benchmark app, comparing live_react's bundling approach (all components eagerly imported into one file) vs react_phx's approach (Vite plugin with per-component lazy chunks):
| Metric | Eager bundle (live_react style) | react_phx (lazy + vendor split) |
|---|---|---|
| Entry JS | 335 KB (single file) | 12 KB (bootstrap only) |
| First page total JS | 335 KB (all upfront) | ~210 KB (entry + vendors + 1 page chunk) |
| Subsequent page nav | 0 KB (cached) | ~2-5 KB (only the new page chunk) |
| Vendor caching | Not possible (mixed in entry) | Yes (react + phoenix in separate cacheable chunks) |
| Component mount | Sync | 1-3 ms (async, no blocking) |
| Reconnect state | Not handled | Full recovery |
Note: In real-world projects with heavy dependencies (D3, PDF.js, KaTeX, etc.), the eager bundle grows much larger (we've seen 5.8 MB). The code splitting benefit scales with project size — more components and heavier deps mean bigger savings.
How It Works
Phoenix LiveView Browser
| |
| <.react name="Counter" |
| count={@count} /> |
| |
| ──── HTML with data-* ──────> |
| data-name="Counter" |
| data-props='{"count":0}' |
| phx-hook="ReactHook" |
| |
| ReactHook.mounted()
| → resolve("Counter")
| → lazy import chunk
| → createRoot + render
| |
| <── pushEvent("increment") ── | (user clicks +1)
| |
| handle_event → assign |
| |
| ──── WebSocket diff ────────> |
| data-props='{"count":1}' |
| |
| ReactHook.updated()
| → computeDiff(prev, next)
| → selective React updateProject Structure
lib/
├── react_phx.ex # Main <.react> component
├── react_phx/
│ ├── encoder.ex # Struct → JSON protocol
│ ├── components.ex # SharedProps macro
│ ├── slots.ex # Slot rendering
│ ├── ssr.ex # SSR behaviour
│ └── ssr/
│ ├── vite_js.ex # Dev SSR (Vite)
│ └── node_js.ex # Prod SSR (Node.js)
assets/
├── hooks.ts # LiveView hook (async mount, diffing)
├── context.tsx # React context (useLiveReact)
├── useLiveEvent.ts # Server event subscription
├── useLiveForm.ts # Form integration
├── useLiveUpload.ts # File upload
├── jsonPatch.ts # RFC 6902 + custom ops
├── errorBoundary.tsx # React error boundary
├── vite-plugin.ts # Astro-style auto-discovery
├── server.ts # SSR entry
└── link.tsx # Phoenix navigationStatus
Experimental. This project is under active development. The API may change without notice.
- 71 Elixir tests
- 168 TypeScript tests
- 3 Playwright E2E tests
- Reviewed by Claude, Gemini, and GPT-5.4 across 7+ rounds
Credits
- live_react by mrdotb — the foundation this project builds on
- live_vue by Valian — architectural inspiration for many features
- Astro — inspiration for the virtual module / islands architecture
License
MIT