Using React components in Phoenix LiveView
There are several ways to integrate React or Svelte into a Phoenix project. Tools like Inertia.js also work quite well with Phoenix. However, for this experiment I wanted to do it without relying on external libraries, using only what Phoenix provides out of the box.
The first step was to create a package.json to manage dependencies and configure esbuild as the JavaScript bundler, following the official guide: Phoenix Asset Management Docs
After that, I installed React and removed the default esbuild configuration generated by Phoenix. With that ready, I used a LiveView Hook to mount a React component directly into the DOM.
package.json
{
"name": "assets",
"version": "1.0.0",
"main": "build.js",
"scripts": {
"dev": "node build.js --watch",
"build": "node build.js --deploy"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"heroicons": "2.2.0",
"phoenix": "file:../deps/phoenix",
"phoenix_html": "file:../deps/phoenix_html",
"phoenix_live_view": "file:../deps/phoenix_live_view",
"react": "19.2.1",
"react-dom": "19.2.1",
"topbar": "3.0.0"
},
"devDependencies": {
"daisyui": "5.5.8",
"esbuild": "0.27.0"
}
}
test_live.ex
defmodule DemoReactWeb.TestLive do
use DemoReactWeb, :live_view
def mount(_params, _session, socket) do
if connected?(socket), do: send(self(), :tick)
{:ok, socket}
end
def handle_event("say_hello", _, socket) do
{:noreply, put_flash(socket, :info, "Hello Word! from live view")}
end
def handle_event("get_orders", _, socket) do
order = %{code: "Az3", client: "John Doe"}
{:reply, order, socket}
end
def handle_info(:tick, socket) do
Process.send_after(self(), :tick, 1000)
random = :rand.uniform(100)
{:noreply, socket |> push_event("random:update", %{value: random})}
end
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<div id="demo" phx-hook="HomeHook" phx-update="ignore"
class="container flex justify-center items-center mx-auto">
<div id="root"></div>
</div>
</Layouts.app>
"""
end
end
home.jsx
import React from "react";
import ReactDOM from "react-dom/client";
import Home from "../components/home";
export const HomeHook = {
mounted() {
const rootElement = this.el.querySelector("#root");
if (!rootElement) return;
const props = {
onHandleEvent: this.handleEvent.bind(this),
pushEvent: this.pushEvent.bind(this),
};
this._reactRoot = ReactDOM.createRoot(rootElement);
this._reactRoot.render(<Home {...props} />);
},
destroyed() {
this._reactRoot?.unmount();
},
};
Home.jsx
import React, { useEffect, useState } from "react";
const fetcher = (pushEvent, eventName, payload = {}) => {
return new Promise((resolve, reject) => {
pushEvent(eventName, payload, (response) => {
if (response?.error) reject(response.error);
else resolve(response);
});
});
};
export default function Home({ pushEvent, onHandleEvent }) {
const [number, setNumber] = useState(null);
const [order, setOrder] = useState(null);
const sayHello = () => pushEvent("say_hello");
const fetchData = async () => {
try {
setOrder(await fetcher(pushEvent, "get_orders"));
} catch (err) {
console.error("Error fetching orders:", err);
}
};
useEffect(() => {
onHandleEvent("random:update", (payload) => {
setNumber(payload.value);
});
}, [onHandleEvent]);
return (
<div className="flex flex-col gap-4 w-full justify-center items-center">
<div className="flex gap-2">
<button className="btn" onClick={sayHello}>Say Hello!</button>
<button className="btn" onClick={fetchData}>Fetch data</button>
</div>
{order && <pre>{JSON.stringify(order)}</pre>}
<div className="flex flex-col items-center mt-4">
<pre>Random number from server: {JSON.stringify(number)}</pre>
</div>
</div>
);
}
Source code here :typingcat:
In summary, I think this approach is viable if a certain level of complexity is required on the client side, and we don’t want the backend to have to deal with it. I think it’s an economical solution in the sense that it saves latency for each user interaction, but we don’t stop using LiveView and everything it offers. :deploy: