|
| 1 | +--- |
| 2 | +name: Add React Components to a Phoenix App without adding additional dependencies |
| 3 | +excerpt: When you want to sprinkle a little interactivity without pulling in opaque dependencies |
| 4 | +author: Denny George |
| 5 | +project: "" |
| 6 | +date: 2025-02-23 |
| 7 | +tags: devlog |
| 8 | +cover: ../images/react-component-in-phoenix.png |
| 9 | +--- |
| 10 | + |
| 11 | +# Problem : |
| 12 | + |
| 13 | +- We need SOME client side interactivity |
| 14 | +- Our team has frontend developers who know react |
| 15 | +- React has battle tested components |
| 16 | +- I didn't want to add additional dependencies to our base phoenix project |
| 17 | +- I wanted a mental model consistent with phoenix's way of working - heex, hooks and events |
| 18 | + |
| 19 | + |
| 20 | + |
| 21 | +# Solution : |
| 22 | + |
| 23 | +## Insert the following heex markup in your liveview code |
| 24 | + |
| 25 | +```heex |
| 26 | +<div id="counter-a" phx-hook="CounterHook" start={@count} /> |
| 27 | +``` |
| 28 | + |
| 29 | +This is what I meant by keeping the API consistent with how you would use a Phoenix Component using Phoenix Hooks. |
| 30 | + |
| 31 | +## Create a file for your React Component |
| 32 | + |
| 33 | +<code>assets/js/counter.js</code> |
| 34 | +```jsx |
| 35 | +import React, { useEffect, useState } from "react"; |
| 36 | +import { createRoot } from "react-dom/client"; |
| 37 | + |
| 38 | +const CounterComponent = ({ id, send, onReceive, start }) => { |
| 39 | + const [count, setCount] = useState(start); |
| 40 | + const [serverMessage, setServerMessage] = useState(""); |
| 41 | + |
| 42 | + useEffect(() => { |
| 43 | + onReceive(`even`, (event) => { |
| 44 | + setServerMessage(event.msg); |
| 45 | + }); |
| 46 | + |
| 47 | + onReceive(`odd`, (event) => { |
| 48 | + setServerMessage(""); |
| 49 | + }); |
| 50 | + }, []); |
| 51 | + |
| 52 | + return ( |
| 53 | + <div className="flex flex-row gap-8 items-center "> |
| 54 | + <button |
| 55 | + className="border-2 rounded-md px-4 py-2 bg-lime-300" |
| 56 | + onClick={() => setCount(count + 1)} |
| 57 | + > |
| 58 | + + |
| 59 | + </button> |
| 60 | + <p className="text-lg">{count}</p> |
| 61 | + <button |
| 62 | + className="border-2 rounded-md px-4 py-2 bg-lime-300" |
| 63 | + onClick={() => setCount(count + 1)} |
| 64 | + > |
| 65 | + - |
| 66 | + </button> |
| 67 | + <button |
| 68 | + onClick={() => send("count-update", { id, count }} |
| 69 | + > |
| 70 | + Send To server |
| 71 | + </button> |
| 72 | + <p className="text-red-600">{serverMessage}</p> |
| 73 | + </div> |
| 74 | + ); |
| 75 | +}; |
| 76 | + |
| 77 | +export var CounterHook = { |
| 78 | + mounted() { |
| 79 | + let el = this.el; |
| 80 | + let id = el.getAttribute("id"); |
| 81 | + let start = parseInt(el.getAttribute("start")); |
| 82 | + |
| 83 | + const root = createRoot(el); |
| 84 | + root.render( |
| 85 | + <CounterComponent |
| 86 | + id={id} |
| 87 | + start={start} |
| 88 | + send={this.pushEvent.bind(this)} |
| 89 | + onReceive={this.handleEvent.bind(this)} |
| 90 | + /> |
| 91 | + ); |
| 92 | + }, |
| 93 | +}; |
| 94 | + |
| 95 | +``` |
| 96 | +
|
| 97 | +Some Notable points about the Hook : |
| 98 | +
|
| 99 | +1. In the <code>mounted</code> function we use client side javascript to parse attributes, format them and pass them as props to a React Component. |
| 100 | +2. We mandate two props <code>onReceive</code> and <code>send</code> to be passed to every React Component so that it can communicate directly with the liveview process by sending and receiving events. |
| 101 | +3. Sending an <code>id</code> 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. |
| 102 | +
|
| 103 | +Some Notable points about the React Component |
| 104 | +
|
| 105 | +1. Its familiar to react developers |
| 106 | +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. |
| 107 | +3. To send events from the component to liveview, we use |
| 108 | +```jsx |
| 109 | +<button onClick={() => send("count-update", { id, count }) > Send to server </button> |
| 110 | +``` |
| 111 | +This looks a bit like the equivalent we have grown used to in heex. |
| 112 | +```heex |
| 113 | +<button phx-click{"count-update"}>Send to server</button> |
| 114 | +``` |
| 115 | +4. We use useEffect() to setup listeners for server events. |
| 116 | +
|
| 117 | +## Add an event handler in your liveview |
| 118 | +
|
| 119 | +```ex |
| 120 | +def handle_event("count-update", params, socket) do |
| 121 | + count = params["count"] |
| 122 | + id = params["id"] |
| 123 | + |
| 124 | + socket = |
| 125 | + case rem(count, 2) do |
| 126 | + 0 -> push_event(socket, "even", %{msg: "even"}) |
| 127 | + 1 -> push_event(socket, "odd", %{msg: "odd"}) |
| 128 | + end |
| 129 | + |
| 130 | + {:noreply, socket} |
| 131 | + end |
| 132 | +``` |
| 133 | +
|
| 134 | +## Register the hook in app.js |
| 135 | +
|
| 136 | +This should be familiar to anyone who has used phoenix hooks. |
| 137 | +
|
| 138 | +```js |
| 139 | +import { CounterHook } from "./counter"; |
| 140 | + |
| 141 | +let Hooks = { |
| 142 | + CounterHook, |
| 143 | + ... any other hooks you may have |
| 144 | +}; |
| 145 | + |
| 146 | +let liveSocket = new LiveSocket("/live", Socket, { |
| 147 | + longPollFallbackMs: 2500, |
| 148 | + params: { _csrf_token: csrfToken }, |
| 149 | + hooks: Hooks, |
| 150 | +}); |
| 151 | +``` |
| 152 | +
|
| 153 | +## Change esbuild configuration to support JSX |
| 154 | +
|
| 155 | +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 |
| 156 | +```ex |
| 157 | +args: ~w(js/app.js --loader:.js=jsx --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*) |
| 158 | +``` |
| 159 | +
|
| 160 | +You are good to go now. |
| 161 | +
|
| 162 | +## Conclusion : |
| 163 | +There are many places where you could abstract some things out to reduce code redundancy. Thats probably also why libraries like [live_react](https://github.com/mrdotb/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. |
| 164 | +
|
| 165 | +Full code is available as a [gist](https://gist.github.com/dennyabrain/a7056d1f4912ab63d5a3ee331105cd29). |
0 commit comments