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.
Boilerplates
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. 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
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
Phoenix settings
We consider several possible settings:
- full dev mode with HMR for both app: run
mix phx.server
to reach Phoenix on port 4000 andnpm run dev
to reach React via Vite on port 5173. - let Phoenix serve the SPA.
- 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: ```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.
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",
<%= 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:
<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.
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 };
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;
}
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>
);
}
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
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
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>
);
}
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>;
}
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"
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
}
}
#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:
Top comments (0)