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:

Default Phoenix page
Yep, all good

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.

Nothing much to see yet
Not much

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:

Huzzah
Success

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>;
};
Getting somewhere
Getting somewhere

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:

Separate states
Separate states

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