DEV Community

Cover image for Use Phoenix to run React
NDREAN
NDREAN

Posted on • Updated on

Use Phoenix to run React

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 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 boilerplate and add a Github login - borrowed from dwyl - 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

Boilerplates

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

mkdir react && npm create vite@latest react --template react
Enter fullscreen mode Exit fullscreen mode

Landing page - Github authentication

The Github login is nicely described here. You need Github credentials and can use dotenv. You adapt slightly the GithubAuthController.index() to generate a Phoenix signed token (JWT better) from the credentials and save it in the session:

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
Enter fullscreen mode Exit fullscreen mode

and the landing page controller is:

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
Enter fullscreen mode Exit fullscreen mode

Landing page

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) to:
#react/package.json
"scripts": { "build": "vite build --base=/react/",...}
                                      ^^^
Enter fullscreen mode Exit fullscreen mode

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.
  • inform Plug.Static about this location in the "Endpoint.ex" module (Phoenix renders them):
# 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)
                                  ^^^
Enter fullscreen mode Exit fullscreen mode

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.

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
Enter fullscreen mode Exit fullscreen mode

Navigation

We add two links:

  • a link in "/templates/layout/root.html.heex",
<%= link to: Routes.react_path(@conn, :index) do %>
  <img scr={Routes.static_path(@conn,"/images/react.svg")}.../>
<% end %>
Enter fullscreen mode Exit fullscreen mode
  • a "traditional" link in the nav component of the SPA:
<a href="http://localhost:4000" title="phoenix"> 
   <img src={phoenixLogo}.../>
</a>
Enter fullscreen mode Exit fullscreen mode

Socket and Channel

To manage WS client side, you can just use the "official" client library 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.

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

The UserSocket module responds with an authentication function Module.connect(%{"token"=>token}, socket,_). It is well described in the documentation: 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.

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;
}
Enter fullscreen mode Exit fullscreen mode

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).

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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){..}).
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
Enter fullscreen mode Exit fullscreen mode

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

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
Enter fullscreen mode Exit fullscreen mode

Check navigation in the SPA

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

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

and the other component would be for example:

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>;
}
Enter fullscreen mode Exit fullscreen mode

navigation

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
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"
Enter fullscreen mode Exit fullscreen mode

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. 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)".

# Caddyfile
:80 {
    log
    encode gzip

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

    handle {
        reverse_proxy phx:4000
    }
}
Enter fullscreen mode Exit fullscreen mode
#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:
Enter fullscreen mode Exit fullscreen mode

Top comments (0)