README.md

# 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