# 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](https://github.com/mrdotb/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`, `limit` custom 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-cleanup
- `useLiveForm(serverForm, options)` — Ecto changeset integration with nested fields, validation, dirty/touched tracking
- `useLiveUpload(uploadConfig)` — File upload with progress, cancel, and validation
## Quick Start
### 1. Add dependency
```elixir
# mix.exs
{:react_phx, "~> 0.1.0"}
```
### 2. Configure Vite
```javascript
// 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
```javascript
// 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
```tsx
// 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
```elixir
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 update
```
## Project 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 navigation
```
## Status
**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](https://github.com/mrdotb/live_react) by mrdotb — the foundation this project builds on
- [live_vue](https://github.com/Valian/live_vue) by Valian — architectural inspiration for many features
- [Astro](https://astro.build) — inspiration for the virtual module / islands architecture
## License
MIT