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.

See the code

Read the docs

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

See the code

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.