Use Phoenix to run React
Tags: todayilearned, elixir, phoenix, react
Published: 2022-09-07 17:59:56


React components are added easily to any Phoenix page or LiveView component with so-called `hooks` in the `LiveSocket` object. We are interested in running a React SPA as part of a Phoenix app. We want Phoenix to serve the landing page, thus provide a fast and SEO meaningful page. The login will be managed here. Then we want navigate back and forth to a fully functional and authenticated SPA (which can be anything, even with Javascript for native mobile rendering).
The SPA may communicate with the Elixir/Phoenix app through authenticated WSS or HTTP requests. This kind of app can benefit from using a reverse proxy to serve the SPA.
The transition back and forth from SPA to SSR is a full page reload. It means the internal states of the React components are lost, thus any meaningful state must be kept in the Phoenix app. 


These are notes from the documentation. Two points to notice:
- use the channel topic to communicate between a GenServer and a channel,
- adapt the [documentation](https://hexdocs.pm/phoenix/channels.html#using-token-authentication) to pass signed tokens from Phoenix to React to authenticate the WS.

## The toy app

The idea here is to experiment Phoenix rendering React. We use a React/[Vite](https://vitejs.dev/guide/why.htm) boilerplate and add a Github login - borrowed [from dwyl](https://github.com/dwyl/elixir-auth-github) - to the landing page. We get quickly an authenticated user. From this page, you can navigate to the SPA. The main component is a simple counter (from the Vite/React boilerplate). We also added another counter to experiment WS: the sum of the total number of clicks from any user; this counter will be managed by Phoenix with a GenServer who broadcasts back to the SPA through a channel to get realtime updates. Any component can access it.

![React](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/2ywcq7uwmnq2wvbd6ol0.png)

## Boilerplates

```bash
mix phx.new phoenix_react --no-mailer --no-dashboard

mkdir react && npm create vite@latest react --template react
```

## Landing page - Github authentication

The Github login is nicely described [here](https://github.com/dwyl/elixir-auth-github). You need Github credentials and can use [dotenv](https://github.com/avdi/dotenv_elixir). You adapt slightly the `GithubAuthController.index()` to generate a Phoenix signed token (JWT better) from the credentials and save it in the session:

```elixir
use PhoenixReactWeb, :controller
def index(conn, %{"code" => code}) do
    {:ok, profile} = ElixirAuthGithub.github_auth(code)

    user_token = Phoenix.Token.sign(PhoenixReactWeb.Endpoint, "user token", profile.id)

  conn
  |> put_session(:user_token, user_token)
  |> put_session(:profile, profile)
  |> redirect(external: "http://localhost:4000")
  # change to "http://localhost" when proxied
  |> halt()
end
```

and the landing page controller is:

```elixir
use PhoenixReactWeb, :controller
def index(conn, _params) do
    oauth_github_url = ElixirAuthGithub.login_url(%{scopes: ["user:email"]})

    case get_session(conn) do
      %{"profile" => profile} ->
        conn
        |> put_view(PhoenixReactWeb.PageView)
        |> render(:welcome, profile: profile)

      _ ->
        render(conn, "index.html", oauth_github_url: oauth_github_url)
    end
  end
```

![Landing page](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/zarji6qlvcx9crs01lmk.png)
 
## Phoenix settings

We consider several possible settings:
1. full dev mode with HMR for both app: run `mix phx.server` to reach Phoenix on port 4000 and `npm run dev` to reach React via Vite on port 5173.
2. let Phoenix serve the SPA.
3. deploy a Docker image from a Phoenix release and use Caddy as a reverse-proxy to serve the SPA static files.

For 1), nothing to do, just two apps connected via WS, though no WS authentication is possible.

For 2), we want Phoenix to render the React app when we navigate to say the "/react" route. We bundle a production release and sync it into a subfolder of "priv/static". Let "priv/static/react" be this subfolder. For this to work, we need to:
- namespace all the locations of the static assets in the SPA "index.html" file with "/react" for the production release; change the **Vite** build script in "package.json" (cf [base path](https://vitejs.dev/guide/build.html#public-base-path)) to:
```json
#react/package.json
"scripts": { "build": "vite build --base=/react/",...}
                                      ^^^
```
and run:  `npm run build`.

- synchronise the production release folder "/dist" of the SPA files with a subfolder "/priv/static/react". These steps should be done with a bash file or a [custom mix task](https://hexdocs.pm/phoenix/mix_tasks.html#creating-our-own-mix-task).
- inform `Plug.Static` about this location in the "Endpoint.ex" module (Phoenix renders them):

```elixir
# endpoint.ex
plug Plug.Static,
    at: "/",
    from: :phoenix_react,
    encodings: [{"gzip", ".gz"}],
    cache_control_for_etags: "public, max-age = 31_536_00",
    only: ~w(assets fonts images react favicon.ico robots.txt)
                                  ^^^
```

If you want to run the `MIX_ENV=prod` mode, you need to set `check_origin: ["http://localhost:4000"]` in "config/runtime.exs" for the WS to work.



### Display the SPA and pass credentials

You add an endpoint say "/react" in the router and add its controller to render the html string of the SPA. We can't use a Phoenix layout but instead we send the static "index.html" file that contains the links (".js", .css") specific to the SPA. One solution is write the token programmatically in the "index.html" file and add a script that appends the value to the `window` object. A `Phoenix.Token` is used here.

```elixir
use PhoenixReactWeb, :controller
  @react_dir "./priv/static/react/"
  @title "<title>React</title>"
  

  def index(conn, _params) do
    case get_user_token(conn) do
      nil ->
        send_unauthorized_response(conn, :unauthorized)
      user_token ->
        token = ~s(<script>window.userToken ="#{user_token}"</script>)

        html_content = read_html_content(token)

        if html_content != nil do
          Phoenix.Controller.html(conn, html_content)
        else
          send_unauthorized_response(conn, :file_error)
        end
    end
  end

  defp get_user_token(conn) do
    conn
    |> get_session()
    |> Map.get("user_token")
  end

  defp read_html_content(token) do
    try do
      Path.join([
        Application.app_dir(:phx_react),
        @react_dir,
        "index.html"
      ])
      |> File.stream!([], :line)
      |> Enum.reduce("", &read_line(&1, &2, token))
    rescue
      e in File.Error ->
        Logger.error("#{__MODULE__}: Error reading HTML file: #{inspect(e)}")
        nil
    end
  end

  defp read_line(line, file, token) do
    case String.trim(line) do
      @title ->
        file <> @title <> token
      line ->
        file <> line
    end
  end

  defp send_unauthorized_response(conn, reason) do
    conn
    |> clear_flash()
    |> put_flash(:error, reason)
    |> redirect(to: ~p"/")
  end
end
```

### Navigation

We add two links:

- a link in "/templates/layout/root.html.heex",

```html
<%= link to: Routes.react_path(@conn, :index) do %>
  <img scr={Routes.static_path(@conn,"/images/react.svg")}.../>
<% end %>
```

- a "traditional" link in the nav component of the SPA:

```html
<a href="http://localhost:4000" title="phoenix"> 
   <img src={phoenixLogo}.../>
</a>
```

### Socket and Channel

To manage WS client side, you can just use the "official" client library [phoenix.js](https://hexdocs.pm/phoenix/js/).
The client creates a socket, adds the token that the browser reads from the DOM - it will be appended as a query string to the WS URI - and opens the WS connection.

```js
const socket = new Socket("ws://localhost:4000/socket", {
  params: { token: window.userToken },
});
socket.connect();
export { socket };
```

The `UserSocket` module responds with an authentication function `Module.connect(%{"token"=>token}, socket,_)`. It is well described in the [documentation](https://hexdocs.pm/phoenix/channels.html#using-token-authentication): you can check the validity of the token with `Phoenix.Token.verify` - this doesn't need the `conn` - and set the client ID in the socket on success.

You can then append a channel to this socket from the client side. You can use a custom hook for this. The `useEffect` hook is used to remove the channel from the array once the component is unmounted.

```js
import React from "react";
import { socket } from "./main";

function useChannel(socket, topic, event, callback) {
  const [channel, setChannel] = React.useState(null);
  React.useEffect(() => {
    if (!socket) return null;
    // attach a channel and pass user credentials against channel authorisation
    const myChannel = socket.channel(topic, {});
    myChannel.join().receive("ok", () => {
      console.log("Joined successfully");
      setChannel(myChannel);
    });

    myChannel.on(event, callback);

    return () => {
      console.log("closing channel");
      myChannel.leave();
    };
  }, []);
  return channel;
}
```

You can then use it in any component. We have a button that increments its own state ( and renders) and we have a general counter that sums every click from every connected client (SSR).

```js
import { socket } from './main.js'

export function Home() {
  const [count, setCount] = useState(0);
  const [msg, setMsg] = useState(0);

  // triggered by the channel on message received
  function updateMsg(resp) {
    setMsg(resp.count);
  }
  // is null if no socket
  const channel = useChannel("counter:lobby", "shout", updateMsg);

  function handleClick() {
    setCount((count) => count + 1);
    // message sent to the server if authorised
    if (channel) channel.push("count", { count: count + 1 });
  }

  return (
    <div className="App">
      <div className="card">Total clicks received: {msg}</div>
      <div className="card">
        <button onClick={handleClick}>count is {count}</button>
      </div>
    </div>
  );
}
```

Server side, use the generator `mix phx.gen.channel counter`. This is where the authorisation check for this user/channel should happen. The module implements three methods here:
- sends to total counter to a joining client on the channel "counter:lobby. This needs to be done with a callback `after_join`. 
- propagate a client "click" event to the GS to update the general counter. The message passing chains as follows: `(JS) channel.push('count'..) -> (SVR-CH) handle_in("count"..) -> send(GenServer, {:shout, message}) -> (SVR-GS) handle_info(:shout..)`.
- propagate to the client a message received from the GS. The GS broadcasts on the **channel topic** an event `:shout` with a payload - the state. The chain is: `(SVR-GS) -> (SVR-CH) handle_in(:shout, payload,socket) -> broadcast(socket, "shout", payload) -> (JS) channel.on("shout", function(message){..})`.


```elixir
defmodule PhoenixReactWeb.CounterChannel do
  use PhoenixReactWeb, :channel
  require Logger
  alias PhoenixReact.Counter

  @impl true
  def join("counter:lobby", _p, socket) do
    send(self(), :after_join)

    {:ok, socket}
  end

  @impl true
  def handle_info(:after_join, socket) do
    broadcast!(socket, "shout", %{count: Counter.current()})
    {:noreply, socket}
  end

  @impl true
  def handle_in("count", %{"count" => _count} = _payload, socket) do
    send(PhoenixReact.Counter, {:shout, 1})
    {:noreply, socket}
  end

  def handle_in("shout", %{count: count}, socket) do
    broadcast!(socket, "shout", %{count: count})
    {:noreply, socket}
  end
end
```

The only noticeable point is the usage of the **channel topic** to broadcast a message from the GenServer to the Channel.

```elixir
defmodule PhoenixReact.Counter do
  use GenServer

  def start_link(_opts) do
    GenServer.start_link(__MODULE__, 0, name: __MODULE__)
  end

  def current(), do: GenServer.call(__MODULE__, :current)

  @impl true
  def init(state), do: {:ok, state}

  @impl true
  def handle_call(:current, _from, state), do: {:reply, state, state}

  # to communicate with channels, use a channel topic
  @impl true
  def handle_info({:shout, 1}, state) do
    state = state + 1
    PhoenixReactWeb.Endpoint.broadcast_from(self(), "counter:lobby", "shout", %{count: state})
    {:noreply, state}
  end
end
```

### Check navigation in the SPA

We can add React.Router6 with `npm install react-router-dom@6`. It navigates between two components

```jsx
function App() {
  return (
    <BrowserRouter basename="react">
      <nav style={navStyle}>
        <ul style={ulStyle}>
          <li>
            <Link style={liStyle} to="/">
              SPA Home
            </Link>
          </li>
          <li>
            <Link style={liStyle} to="/p">
              Page
            </Link>
          </li>
         <li>
            <a href="http://localhost:4000" style={liStyle}>
              Phoenix API
            </a>
          </li>
        </ul>
        <br />
      </nav>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="p" element={<Page />} />
      </Routes>
    </BrowserRouter>
  );
}
```

and the other component would be for example:

```jsx
function Page() {
  const [msg, setMsg] = useState(0);

  function callback(resp) {
    setMsg(resp.count);
  }

  // is null if no socket ie not authorised
  useChannel( "counter:lobby", "shout", callback);

  return <h1>Page: {msg}</h1>;
}
```

![navigation](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/gvmqw8xt3ral3ijgbk33.png)

### Docker things

Since you need an image to deploy, the idea is to run a Phoenix container reverse-proxied with Caddy to serve the SPA files (and Cowboy the rest). The app is accessible from "http://localhost:80" so we map 80 -> 4000.
You build a Phoenix image from a release, and run with docker-compose three containers: Phoenix, Postgres and Caddy to reverse-proxy.

Most of the server config is done in "runtimes.exs" in the Endpoint. You need to pass the correct `check_origin`:
- an entry for the WS (to match the hard-coded location in the Javascript `new Socket`)
- an entry for the reverse-proxy Caddy


```elixir
config :phoenix_react, PhoenixReactWeb.Endpoint,
    url: [host: host, port: port, scheme: "http"],
    http: [ip: {0, 0, 0, 0, 0, 0, 0, 0}],
    check_origin: [
      "http://localhost:4000",
                ^^^ws
      "http://localhost"
               ^^^ for Caddy/reverse-proxy 
    ],
    secret_key_base: secret_key_base,
    cache_static_manifest: "priv/static/cache_manifest.json"
```

> we can remove the WS ref here when you change the WS uri in the constructor to :80

> Note that only `url: [path: "/"]` needs to set/kept in "config.prod.exs".

Postgres (alpine - 207MB) and Caddy (44MB) already use a built image. We can use a boilerplate for an Alpine (almost) production ready image from this [author](https://blog.miguelcoba.com/deploying-a-phoenix-16-app-with-docker-and-elixir-releases). We get a tiny 24MB!
The keys below should be passed as ARG to the second stage of the Dockerfile and set in an ".env" file for the Dockerfile
- SECRET_KEY_BASE, PHX_SERVER=true,
- DATABASE_URL=ecto://postgres:postgres@db/phoenix_react_prod,

.

To setup the database, you can follow the blog and "psql" into the Postgres container or add an "init.db" with a mount bind to Postgres' "/docker-entrypoint-initdb.d/init.sh" file.

We also need to define the location of the React app in the release. To get the correct full path ("_build/prod/rel..."), use:

 `Application.app_dir(:phoenix_react)` 




Finally, for the new navigation to work, you need to remove the port (ie 80) from the hard-coded locations "http://localhost(/react)".


```json
# Caddyfile
:80 {
	log
	encode gzip

	handle_path /react/* {
		root * /srv
		file_server
	}

	handle {
		reverse_proxy phx:4000
	}
}
```

```yml
#docker-compose.yml
version: '3.8'
services:
  phx:
    build: .
    ports:
      - "4000:4000" #<- needed for Github login?!
    depends_on:
      - db
    env_file:
      - .env-docker
    environment:
      - MIX_ENV=prod

  db:
    image: postgres:14.5-alpine 
    environment:
      - POSTGRES_PASSWORD=postgres
    restart: always
    volumes:
      - ./postgres-initdb.sh:/docker-entrypoint-initdb.d/init.sh
      - pg-data:/var/lib/postgresql/data
    ports:
      - "5432"

  caddy:
    image: caddy:2.5.2
    restart: unless-stopped
    ports:
      - "80:80"
    volumes:
      - $PWD/Caddyfile:/etc/caddy/Caddyfile:ro
      - $PWD/priv/static/react/:/srv:ro
      - caddy_data:/data
      - caddy_config:/config

volumes:
  pg-data:
  caddy_data:
  caddy_config:
```