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:

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

React Hooks

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 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.

Credits

License

MIT