Phoenix Channels: Real-time with WebSockets
A Channel creates a separate process for each open WebSocket connection. It utilizes the WebSocket’s heartbeat to monitor the health of the connection, automatically terminating the process when the WebSocket is closed.
Superhero Channel
defmodule GameAppWeb.SuperheroesChannel do
use Phoenix.Channel
alias GameApp.Superheroes
alias Phoenix.PubSub
def join("superheroes:lobby", _payload, socket) do
send(self(), :send_initial_superheroes)
new_socket =
socket
|> assign(:superheroes, Superheroes.list_superheroes())
{:ok, new_socket}
end
def handle_info(:send_initial_superheroes, socket) do
superheroes = socket.assigns[:superheroes]
message_id = Ecto.UUID.generate()
new_socket =
socket
|> assign(:superheroes, superheroes)
push(socket, "hydrate", %{
superheroes: format_superheroes(superheroes),
message_id: message_id
})
{:noreply, new_socket}
end
defp format_superheroes(superheroes) do
Enum.map(superheroes, &format_superhero/1)
end
defp format_superhero(superhero) do
%{
id: superhero.id,
name: superhero.name,
location: superhero.location,
power: superhero.power
}
end
end
Because each Phoenix Channel manages its own state, it can maintain the definitive state and actively push this state to the client. In this scenario, upon connection, the channel retrieves a list of superheroes, stores it in its internal state, and sends a message to itself, :send_initial_superheroes
. This triggers the channel to push a hydrate
message, containing the superheroes, down to the client.
Superhero React
The directory superheroes-react
contains a basic React application built with Vite.
setup.react:
@echo "Installing dependencies for React app..."
cd ./superheroes-react && npm install
serve.react:
@echo "Starting React app..."
cd ./superheroes-react && npm run dev
Superheroes
Component
First, use Socket
from the Phoenix package to connect to the Superhero development app’s WebSocket.
import { Socket } from "phoenix";
const WEBSOCKET_URL = 'ws://127.0.0.1:4080/socket';
const socket = new Socket(WEBSOCKET_URL, { params: { token: "YourTokenHere" }});
socket.connect();
export default socket;
Next, create a simple template to display a table of superheroes.
import React, { useEffect, useState } from 'react';
import socket from './socket';
function Superheroes() {
const [superheroes, setSuperheroes] = useState([]);
return (
<table>
<thead>
<tr>
<th>Name</th>
<th>Location</th>
<th>Power</th>
</tr>
</thead>
<tbody>
{superheroes.map(hero => (
<tr key={hero.id}>
<td>{hero.name}</td>
<td>{hero.location}</td>
<td>{hero.power}</td>
</tr>
))}
</tbody>
</table>
);
}
export default Superheroes;
Finally, add a useEffect
connecting to the superheroes
channel and listening for the hydrate
message. On hydrate, replace the component’s current state with the list of superheroes. When the Component is removed, leave the channel, which sends a message to terminate the process.
useEffect(() => {
const channel = socket.channel("superheroes:lobby", {});
channel.join()
.receive("ok", resp => { console.log("Joined successfully", resp); })
.receive("error", resp => { console.log("Unable to join", resp); });
channel.on("hydrate", payload => {
const {superheroes} = payload;
setSuperheroes(superheroes);
});
return () => {
channel.leave();
};
}, []);
Create Superhero
Controller create
We already have a controller for the Superheroes API; we just need to broadcast a “create superhero” event via our PubSub.
defp broadcast_update(action, superhero) do
Phoenix.PubSub.broadcast(
GameApp.PubSub,
@update_superhero_pub_topic,
{action, superhero}
)
end
Send the message upon successful creation:
def create(conn, %{"superhero" => superhero_params}) do
with {:ok, %Superhero{} = superhero} <- Superheroes.create_superhero(superhero_params) do
broadcast_update(:create, superhero)
conn
|> put_status(:created)
|> render(:show, superhero: superhero)
end
end
Channel create
Subscribe to the superhero controller topic:
def join("superheroes:lobby", _payload, socket) do
PubSub.subscribe(GameApp.PubSub, SuperheroController.topic())
send(self(), :send_initial_superheroes)
new_socket =
socket
|> assign(:superheroes, Superheroes.list_superheroes())
{:ok, new_socket}
end
Watch for the creation event, update the local state, and push the formatted change to the client:
def handle_info({:create, superhero}, socket) do
updated_superheroes = [superhero | socket.assigns.superheroes]
new_socket =
socket
|> assign(:superheroes, updated_superheroes)
push(new_socket, "create", %{
superhero: format_superhero(superhero),
})
{:noreply, new_socket}
end
React Client create
Update the React client to listen for the create
message and update the component’s state:
channel.on("create", payload => {
const { superhero } = payload;
setSuperheroes(currentHeroes => [...currentHeroes, superhero]);
});
Update Superhero
Handling an update
event can be similar to a create
event. However, it’s important to note that the order of messages is not guaranteed. It is possible for an earlier update to arrive after a more recent one. For superheroes
, a last-in-wins
strategy is adopted. This involves checking the updated_on
timestamp—if it is later than the current state, the update is passed along to the client; otherwise, it is ignored.
Channel update
def handle_info({:update, superhero}, socket) do
existing_superheroes = socket.assigns[:superheroes]
existing_hero = Enum.find(existing_superheroes, &(&1.id == superhero.id))
if existing_hero &&
NaiveDateTime.compare(superhero.updated_at, existing_hero.updated_at) == :gt do
message_id = Ecto.UUID.generate()
updated_superheroes =
Enum.map(existing_superheroes, fn
hero when hero.id == superhero.id -> superhero
hero -> hero
end)
new_socket =
socket
|> assign(:superheroes, updated_superheroes)
|> push_update(:update, message_id, superhero)
{:noreply, new_socket}
else
{:noreply, socket}
end
end
React Client update
Given that WebSockets also do not guarantee message order, upsert logic is necessary.
const handleUpsert = (superhero) => {
setSuperheroes(currentHeroes => {
const index = currentHeroes.findIndex(hero => hero.id === superhero.id);
if (index !== -1) {
return currentHeroes.map(hero => hero.id === superhero.id ? superhero : hero);
} else {
return [...currentHeroes, superhero];
}
});
};
This upsert logic is applied for both create
and update
events:
channel.on("create", payload => {
const { superhero } = payload;
handleUpsert(superhero);
});
channel on("update", payload => {
const { superhero, message_id } = payload;
handleUpsert(superhero);
});
Try it out
Import openapi.yaml
into Postman and ensure the environment is pointing to your development app at localhost:4001
. Use the Postman client to create, update, and delete superheroes. These changes will propagate in real-time to the Superhero React App running at http://localhost:5173.