Add React Components to a Phoenix App without adding additional dependencies

Published on Sun Feb 23 2025Denny George

Tags:

Problem :

  • We need SOME client side interactivity
  • Our team has frontend developers who know react
  • React has battle tested components
  • I didn't want to add additional dependencies to our base phoenix project
  • I wanted a mental model consistent with phoenix's way of working - heex, hooks and events
react component in phoenix

Solution :

Insert the following heex markup in your liveview code

1
<div id="counter-a" phx-hook="CounterHook" start={@count} />

This is what I meant by keeping the API consistent with how you would use a Phoenix Component using Phoenix Hooks.

Create a file for your React Component

assets/js/counter.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
import React, { useEffect, useState } from "react";
import { createRoot } from "react-dom/client";

const CounterComponent = ({ id, send, onReceive, start }) => {
  const [count, setCount] = useState(start);
  const [serverMessage, setServerMessage] = useState("");

  useEffect(() => {
    onReceive(`even`, (event) => {
      setServerMessage(event.msg);
    });

    onReceive(`odd`, (event) => {
      setServerMessage("");
    });
  }, []);

  return (
    <div className="flex flex-row gap-8 items-center ">
      <button
        className="border-2 rounded-md px-4 py-2 bg-lime-300"
        onClick={() => setCount(count + 1)}
      >
        +
      </button>
      <p className="text-lg">{count}</p>
      <button
        className="border-2 rounded-md px-4 py-2 bg-lime-300"
        onClick={() => setCount(count + 1)}
      >
        -
      </button>
      <button
        onClick={() => send("count-update", { id, count }}
      >
        Send To server
      </button>
      <p className="text-red-600">{serverMessage}</p>
    </div>
  );
};

export var CounterHook = {
  mounted() {
    let el = this.el;
    let id = el.getAttribute("id");
    let start = parseInt(el.getAttribute("start"));

    const root = createRoot(el);
    root.render(
      <CounterComponent
        id={id}
        start={start}
        send={this.pushEvent.bind(this)}
        onReceive={this.handleEvent.bind(this)}
      />
    );
  },
};

Some Notable points about the Hook :

  1. In the mounted function we use client side javascript to parse attributes, format them and pass them as props to a React Component.
  2. We mandate two props onReceive and send to be passed to every React Component so that it can communicate directly with the liveview process by sending and receiving events.
  3. Sending an id prop, although its not enforced anywhere yet. This can be useful when you have multiple instances of the same component and you want to send an event from the server to any particular one of them.

Some Notable points about the React Component

  1. Its familiar to react developers
  2. We continue using tailwind for styling. This is a big win because I value being able to style my components in accordance with the rest of our app; so since we already use tailwind for the rest of the UI components, this is great.
  3. To send events from the component to liveview, we use
1
<button onClick={() => send("count-update", { id, count }) > Send to server </button>

This looks a bit like the equivalent we have grown used to in heex.

1
<button phx-click{"count-update"}>Send to server</button>
  1. We use useEffect() to setup listeners for server events.

Add an event handler in your liveview

1
2
3
4
5
6
7
8
9
10
11
12
def handle_event("count-update", params, socket) do
    count = params["count"]
    id = params["id"]

    socket =
      case rem(count, 2) do
        0 -> push_event(socket, "even", %{msg: "even"})
        1 -> push_event(socket, "odd", %{msg: "odd"})
      end

    {:noreply, socket}
  end

Register the hook in app.js

This should be familiar to anyone who has used phoenix hooks.

1
2
3
4
5
6
7
8
9
10
11
12
import { CounterHook } from "./counter";

let Hooks = {
  CounterHook,
  ... any other hooks you may have
};

let liveSocket = new LiveSocket("/live", Socket, {
  longPollFallbackMs: 2500,
  params: { _csrf_token: csrfToken },
  hooks: Hooks,
});

Change esbuild configuration to support JSX

This part is new. In your config.exs, you need to add the loader flag - --loader:.js=jsx to the args option. The full value should look like

1
args: ~w(js/app.js --loader:.js=jsx --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*)

You are good to go now.

Conclusion :

There are many places where you could abstract some things out to reduce code redundancy. Thats probably also why libraries like live_react exist and you should use them if apt. I like that this approach lets you add React components to an existing Phoenix project without adding any aditional dependencies. I also like that counter.js contains both the Phoenix Hook and the React Component. This makes the counter feel like a Phoenix component that handles markup, style, interactivity and communication with liveview; all in one file.

Full code is available as a gist.

Text and illustrations on the website is licensed under Creative Commons 4.0 License. The code is licensed under GPL. For data, please look at respective licenses.