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: