React and Phoenix LiveView in 2022
ReactJS in a LiveView world
Phoenix’s LiveView is great, and has drastically reduced the need for client-side Javascript code for basic use cases in Phoenix apps. However, some projects still need heavier components with richer behaviors than what LiveView can easily deliver.
Thankfully, there’s a tool we can tap into for which an enormous amount of code has already been written - React. Since taking the frontend world by storm in 2013, a lot of libs, blog posts and videos have been published by an enthusiasmic community. React was what made me finally enjoy the little bits of frontend work I do now and again. It’s perfect to help build interaction-heavy frontends to Phoenix apps, and it doesn’t have to be too complicated - especially now that esbuild has replaced Webpack as the builder of choice for Phoenix.
The desired end result is to be able to add an HTML element to a LiveView, associate it with a LiveView hook (not a React hook, but good luck searching for the former without the latter all over the results 😓) and have that LiveView hook control the lifecycle of the React component as a separate standalone app within your view. We’re not trying to have a full blown React app serve as our frontend and communicating with a Phoenix backend that serves no HTML. The LiveView hook mechanism is described in LiveView’s docs.
This post is a merge of sorts of this repo and this article, which had 90% of the answers.
Getting started
We’ll be using Phoenix v1.6 and React v17 - v18 is out already, but is still new enough for me to get away with using the “old” one for this post.
Let’s create a new Phoenix app with PostgreSQL as DB and UUIDs as keys:
mix phx.new counters --database postgres --binary-id
Once the app is generated and the DB created, let’s make sure mix phx.server
works:
Installing React
Adding React to our app is easy-peasy. Just cd
into the assets
folder and run:
npm install --save react@17 react-dom@17
We’ll also install the type definitions, so we can use TypeScript properly:
npm install --save-dev @types/react@17 @types/react-dom@17
Packages sorted, moving on to the LiveView hook.
A hook is all it takes
Let’s create our first LiveView. In our router file, add
# lib/counters_web/router.ex
...
live "/home", HomeLive
right after the index
route for PageController
. Add the following controller:
# lib/counters_web/controllers/home_live.ex
defmodule CountersWeb.HomeLive do
use CountersWeb, :live_view
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:count, 0)}
end
end
and finally the view’s template (since we don’t have a render/1
function):
# lib/counters_web/controllers/home_live.html.heex
Here be some React...
<div
id="greeting"
phx-hook="Greeter"
phx-update="ignore">
</div>
This div
element will be where our React component will live. Notice how we attach a phx-hook
attribute to it, which will be used by LiveView to interact with the component. We also explicitly instruct LiveView to ignore updates for this element, since React does its own management of them.
Now, when we head to http://localhost:4000/home
we should see… not much yet.
Let’s create the Greeter
hook that is associated with that div. In app.js
we’ll make a few changes:
// assets/js/app.tsx
import { mount } from "./mounter";
import { GreeterOpts } from "./greeter";
let Hooks = {}
Hooks.Greeter = {
mounted() {
this.unmountComponent = mount(this.el.id, this.opts());
},
destroyed() {
if (!this.unmountComponent) {
console.error("Greeter unmountComponent not set");
return;
}
this.unmountComponent(this.el);
},
opts(givenCount = 0): GreeterOpts {
return {
name: "Counters",
};
},
}
let csrfToken =
document.querySelector("meta[name='csrf-token']").getAttribute("content")
let liveSocket = new LiveSocket("/live", Socket, {
hooks: Hooks, // <---- don't forget!
params: { _csrf_token: csrfToken },
});
The relevant functions in the hook object to our example are mounted
and destroyed
that run when the component is added to the view, or removed. We also add an opts
function so that potential implementation details from LiveView don’t leak into our React component, making our contact surface between then neat.
The last line (with let liveSocket = ...
) just adds our Hooks
object to the LiveSocket constructor.
We’re using TypeScript in this file, so let’s rename app.js
to app.tsx
and change the esbuild
settings in config/config.exs
to support it (you’ll have to restart the server):
# config/config.exs
config :esbuild,
version: "0.14.29",
default: [
args:
~w(js/app.tsx --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
cd: Path.expand("../assets", __DIR__),
env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
]
So easy! ❤️
Now we just need the extra files we required. The first one is the mounter file that will serve as a bridge between our app and the React component:
// assets/js/mounter.tsx
import React from "react";
import { render, unmountComponentAtNode } from "react-dom";
import { Greeter, GreeterOpts } from "./greeter";
export function mount(id: string, opts: GreeterOpts) {
const rootElement = document.getElementById(id);
render(
<React.StrictMode>
<Greeter {...opts} />
</React.StrictMode>,
rootElement
);
return (el: Element) => {
if (!unmountComponentAtNode(el)) {
console.warn("unmount failed", el);
}
};
}
Pretty simple, we just require the Greeter
component and the interface that defines the accepted props, and then mount the element on the given id. We use StrictMode
to allow React to catch some typical gotchas when working with it. On to the component itself:
// assets/js/greeter.tsx
import React from "react";
export interface GreeterProps {
name: string;
}
export const Greeter: React.FC<GreeterProps> = (props: GreeterProps) => {
const { name } = props;
return (
<section className="phx-hero">
<h1>Welcome to {name} with TypeScript and React!</h1>
</section>
);
};
As soon as you refresh and head to /home
you should see our component in action:
Adding LiveView-backed state to our React component
As it stands now, our component isn’t super interesting - let’s add a basic counter that is governed by state on our LiveView process.
Updating our GreeterProps
interface to include a count
value and an updateCount
function:
// assets/js/greeter.tsx
...
export interface GreeterProps {
name: string;
count: number;
updateCount(newCount: number): any;
}
We’ll update the main function for our component as well:
// assets/js/greeter.tsx
...
export const Greeter: React.FC<GreeterProps> = (props: GreeterProps) => {
const { name, updateCount } = props;
const count = props.count || 0;
const incrementCount = (increment) => {
updateCount(count + increment);
};
return (
<section className="phx-hero">
<h1>Welcome to {name} with TypeScript and React!</h1>
<Button increment={1} onClickFunction={incrementCount} />
<br />
<span>{count}</span>
</section>
);
};
That Button
function needs to be added too:
// assets/js/greeter.tsx
...
const Button = ({ increment, onClickFunction }) => {
const handleClick = () => {
onClickFunction(increment);
};
return <button onClick={handleClick}>+{increment}</button>;
};
There! If you try using the button now, nothing will happen - we need to pass the function to update the count on our app.tsx
file. Here’s the new hook:
// assets/js/app.tsx
...
Hooks.Greeter = {
mounted() {
this.unmountComponent = mount(this.el.id, this.opts());
},
destroyed() {
if (!this.unmountComponent) {
console.error("Greeter unmountComponent not set");
return;
}
this.unmountComponent(this.el);
},
updateCount(newCount) {
this.pushEventTo(this.el, "actions.countInc", { newCount: newCount });
},
opts(givenCount = 0): GreeterOpts {
return {
name: "Counters",
count: givenCount,
updateCount: this.updateCount.bind(this),
};
},
};
Notice how we use pushEventTo
on our div
- this will send an event to our LiveView process, which we can use to update the count and send an event back to the component. Passing the update count function this way also helps us not burden the component with knowledge of LiveView’s functions directly.
If you push the counter’s button now, you’ll see something like this on the server’s log window:
[error] GenServer #PID<0.703.0> terminating
** (UndefinedFunctionError) function CountersWeb.HomeLive.handle_event/3 is undefined or private
(counters 0.1.0) CountersWeb.HomeLive.handle_event("actions.countInc", %{"newCount" => 1}, #Phoenix.LiveView.Socket<assigns: %{__changed__: %{}, count: 0, flash: %{}, live_action: nil}, endpoint: CountersWeb.Endpoint, id: "phx-Fu8OOkYAjN7OsAcB", parent_pid: nil, root_pid: #PID<0.703.0>, router: CountersWeb.Router, transport_pid: #PID<0.697.0>, view: CountersWeb.HomeLive, ...>)
Let’s add the handler for this event on our LiveView controller:
# lib/counters_web/controllers/home_live.ex
...
def handle_event("actions.countInc", %{"newCount" => new_count}, socket) do
socket = assign(socket, count: new_count)
{:noreply, push_event(socket, "react.update_count", %{newCount: new_count})}
end
We no longer crash when we push the button now, as the event is now properly handled, but the count still doesn’t update! We need the component to… react to this event the LiveView process sends down the socket. This is easy to do, by tweaking our hook’s mounted()
function:
// assets/js/app.tsx
...
mounted() {
this.handleEvent("react.update_count", ({ newCount: newCount }) => {
mount(this.el.id, this.opts(newCount));
});
this.unmountComponent = mount(this.el.id, this.opts());
},
Now, our counter’s working fine! 💪
Separation of states
Sometimes it’s convenient to maintain state on our React component that is not 100% driven by our LiveView, and for that we can use React’s own useState
. As an example, let’s add a second counter that is not synced with our Phoenix app and just exists for the duration of the component’s lifetime.
// assets/js/greeter.tsx
import React from "react";
const { useState } = React;
export interface GreeterProps {
name: string;
count: number;
updateCount(newCount: number): any;
}
const Button = ({ increment, onClickFunction }) => {
const handleClick = () => {
onClickFunction(increment);
};
return <button onClick={handleClick}>+{increment}</button>;
};
export const Greeter: React.FC<GreeterProps> = (props: GreeterProps) => {
const { name, updateCount } = props;
const count = props.count || 0;
const [localCount, setLocalCount] = useState(0)
const incrementCount = (increment) => {
updateCount(count + increment);
};
const incrementLocalCount = (increment) => {
setLocalCount(localCount + increment);
};
return (
<section className="phx-hero">
<h1>Welcome to {name} with TypeScript and React!</h1>
<Button increment={1} onClickFunction={incrementCount} />
<br />
<span>{count}</span>
<br />
<hr />
<Button increment={1} onClickFunction={incrementLocalCount} />
<br />
<span>{localCount}</span>
</section>
);
};
With this change in place, we have two counters:
The top counter is synced with our LiveView’s state, the bottom one is strictly local to the component.
Wrap-up
The end result is fairly simple, but it took me a little while to figure all of the moving parts out, and it wasn’t helped by LiveView’s decision to name their interop layer with Javascript “hooks”, the same name as React uses for a change introduced in v16.8 aimed at simplifying a number of things.
I quite like the end-result’s separation between your React components and the Phoenix app, and it’s great to be able to tap into the gajillion components the React community has produced over the years.
Until next time! 🖖
Feel free to reply with comments or feedback to this tweet