Connecting my website to Phoenix Server for some real-time metrics

Using channels and presence for user count

Published on · 11 min read · 1 view · 1 reading right now

NEXTJS
PHOENIX
ELIXIR
Cover image with NextJS and Phoenix Framework logo
Table of Contents

Introduction

I love Elixir, and the Phoenix framework, especially Liveview. You can do all sorts of neat stuff with Liveview, from server-rendered pages to realtime page updates.

And I wanted to somehow integrate liveview with my website which is in NextJS. However, I wasn't able to integrate Liveview with NextJS, but Phoenix provides another way to integrate itself with other frontends.

Phoenix Socket & Phoenix Channels. Phoenix socket is a websocket connection from any client to the Phoenix server. And with the help of Phoenix channels you can do some neat stuff, and I'm here to talk about one of those neat stuff.

Connecting my website to phoenix for real-time user count.

Try it out yourself

Open up a new tab with this page link, and you should see the following metrics change.

For a blog, I have a count of people who are reading it right now:

Blog Count

For my website as a whole, I have the count of people who have it open at the moment:

Website Count

Here's how I did it.

Creating a Phoenix Server

One can generate a Phoenix application using mix phx.new <application_name> but it will generate a lot of code which I didn't need such as Liveview, some HTML/Heex files, dashboard, etc. But one can configure phx.new to not generate those files. That's what I did. I ran mix phx.new <application_name> --no-ecto --no-mailer --no-gettext --no-html --no-assets --no-dashboard --install, which generated minimal files to run the Phoenix server.

Generating Socket

In order to let any client connect to our server, we need to have a socket. I created a User socket using the following mix command:

# inside phoenix project

mix phx.gen.socket User

This will create some files such as: /lib/<project_name>_web/channels/user_socket.ex as the socket handler, and a JS file. The JS file contains code through which one can connect to the socket. I didn't need the JS file since I'm using react.

This command also hints that we need to add some lines in endpoint.ex file, which I did. Now I can connect to the socket through the following URL: ws://localhost:4000/socket(you need a websocket client).

Connecting to the Phoenix Server

Now that I have a Phoenix server up & running, I needed to connect to the server from my NextJS frontend. For that I needed a "phoenix" package to handle the websocket connection.

# inside nextjs project

npm install phoenix
npm install -D @types/phoenix # for types

Since it's a NextJS project, there's a different method for connecting to the server. I chose to connect to the server once my webpage loads. Here's the code:

Socket.tsx
import { Socket as PhxSocket } from "phoenix";
import { Dispatch, useEffect, useState, createContext } from "react";

// Socket context
export const SocketContext = createContext<PhxSocket>(null);

// function to connect to the socket
function setupSocket(socket: PhxSocket, setSocket: Dispatch<PhxSocket>) {
  if (!socket) {
    const URL = "ws://localhost:4000/socket";
    const phxSocket = new PhxSocket(URL);
    phxSocket.connect();
    setSocket(phxSocket);
  }
}

// function to disconnect the socket
function teardownSocket(socket: PhxSocket, setSocket: Dispatch<PhxSocket>) {
  if (socket) {
    socket.disconnect();
    setSocket(null);
  }
}

// The Socket component
export function Socket({ children }) {
  const [socket, setSocket] = useState<null | PhxSocket>(null); // holds onto Socket instance

  useEffect(() => {
    setupSocket(socket, setSocket); // on mount, connect
    return () => teardownSocket(socket, setSocket); // on dismount, disconnect
  }, [socket]);

  return (
    <SocketContext.Provider value={socket}>{children}</SocketContext.Provider>
  );
}

Let's breakdown the code.

First, we have a context.

export const SocketContext = createContext<PhxSocket>(null);

This context will hold the socket instance through which we connected to the server. This socket will allow us to join channels, and send messages. I'm passing socket as a context so that other components can have access to this socket.

Next, we have two functions:

// function to connect to the socket
function setupSocket(socket: PhxSocket, setSocket: Dispatch<PhxSocket>) {
  if (!socket) {
    const URL = "ws://localhost:4000/socket";
    const phxSocket = new PhxSocket(URL);
    phxSocket.connect();
    setSocket(phxSocket);
  }
}

// function to disconnect the socket
function teardownSocket(socket: PhxSocket, setSocket: Dispatch<PhxSocket>) {
  if (socket) {
    socket.disconnect();
    setSocket(null);
  }
}

As the name suggests, these functions are used to setup(connect()) a socket connection as well as destroy(disconnect()) that connection. These functions take a state and state changer function, I'll show how we state here.

And finally, we have the Socket component.

// The Socket component
export function Socket({ children }) {
  const [socket, setSocket] = useState<null | PhxSocket>(null); // holds onto Socket instance

  useEffect(() => {
    setupSocket(socket, setSocket); // on mount, connect
    return () => teardownSocket(socket, setSocket); // on dismount, disconnect
  }, [socket]);

  return (
    <SocketContext.Provider value={socket}>{children}</SocketContext.Provider>
  );
}

This component has a state which holds onto our socket. It takes in a child component and wraps it with the context provider. As soon as the component mounts, useEffect will run and a websocket connection to the server will be established.

Now that the Socket component is created, I use this component to wrap the whole application so that every component has access to the socket instance. It will allow child components to connect to their own channels and handle messages.

pages/_app.tsx
import { Socket } from "components/Phoenix/Socket"; // the Socket component

function MyApp({ Component, pageProps }) {
  return (
    // Socket component wraps everything
    <Socket>
      <Component {...pageProps} />
    </Socket>
  );
}

export default MyApp;

This means whenever a page loads for the first time, it will automatically connect to the phoenix server.

Phoenix Channels

Now that I have a socket connection established, I need to use phoenix channel for real-time updates.

What is Phoenix channel?

But what is Phoenix channel? Here's what ChatGPT says:

In the Phoenix framework, channels are a way to handle real-time communication over WebSockets. They allow client-server communication in both directions, allowing the server to push updates to the client and vice versa.

Channels are implemented using the Phoenix.Channel module and are defined in the web/channels directory of a Phoenix project. Each channel has a topic, which is the identifier used to subscribe to the channel from the client.

To use channels, the client first establishes a WebSocket connection with the server and then subscribes to a specific channel by providing the channel's topic. The client can then send messages to the server over the channel, and the server can send messages back to the client.

Perfect!

Creating a channel

Using channels we can subscribe to certain topics and receive updates on those topics. I didn't use mix phx.gen.channel <channel_name> to create a channel since it includes some functions I didn't need.

Instead, I manually created a channel file in lib/<project_name>_web/channels/user_join_channel.ex. The content of this file is:

user_join_channel.ex
defmodule ProjectWeb.UserJoinChannel do
  use Phoenix.Channel

  def join(_room_id, _params, socket) do
    {:ok, socket}
  end
end

It has a join/3 function which does nothing for now.

I also needed to add the following line inside the User socket file:

user_socket.ex
channel "user-join", ProjectWeb.UserJoinChannel

This means that whenever a client tries to subscribe to "user-join" channel, the ProjectWeb.UserJoinChannel.join/3 function will handle the connection.

Connecting to a channel(frontend)

Now that I have the channel ready on my server, I'll connect to the channel through NextJS. For this, I made a separate component:

UserJoin.tsx
import { useState, useContext, useEffect } from "react";
import { Channel } from "phoenix";

import { SocketContext } from "./Socket";

export default function UserJoin() {
  const socket = useContext(SocketContext);

  useEffect(() => {
    let phoenixChannel: Channel;
    if (socket) {
      phoenixChannel = socket.channel("user-join");

      phoenixChannel.join().receive("ok", () => {
        console.log("joined channel user-join");
      });
    }

    // leave the channel when the component unmounts
    return () => {
      if (socket && phoenixChannel) phoenixChannel.leave();
    };
  }, [socket]);

  return <div>Channel</div>;
}

Let's breakdown this component as well.

First, I'm using the Socket context to get the socket instance created earlier.

const socket = useContext(SocketContext);

Next, inside useEffect:

let phoenixChannel: Channel;
if (socket) {
  phoenixChannel = socket.channel("user-join");

  phoenixChannel.join().receive("ok", () => {
    console.log("joined channel user-join");
  });
}

// leave the channel when the component unmounts
return () => {
  if (socket && phoenixChannel) phoenixChannel.leave();
};

I'm creating a channel instance using socket.channel function with the topic which I want to join, i.e. "user-join". This channel instance has a join method which lets the socket join the given channel. Once the component unmounts, we leave the channel using leave() method.

and with that, the client can join the channel.

Tracking users count using Phoenix Presence

Now that I have a socket connection as well as a channel, I can get the count of users who have joined the same channel topic using Phoenix Presence.

What is Presence?

Asking ChatGPT again XD

Phoenix Presence is a feature of the Phoenix framework that allows you to track the online status and activity of users in real-time applications. It does this by using Phoenix channels and storing presence information in an ETS (Erlang Term Storage) table on the server.

Presence tells us about the users who joined a channel or left the channel in real-time. I can use this to detect how many users are online at a moment.

Handling presence in the server

Inside my UserJoinChannel module, I use Presence module to track a socket in a channel.

user_join_channel.ex
defmodule ProjectWeb.UserJoinChannel do
  alias ProjectWeb.Presence
  use Phoenix.Channel

  def join(_room_id, _params, socket) do
    send(self(), :after_join)
    {:ok, socket}
  end

  def handle_info(:after_join, socket) do
    {:ok, _} = Presence.track(socket, socket.id, %{})
    push(socket, "presence_state", Presence.list(socket))

    {:noreply, socket}
  end
end

I use push to send the presence data to connected client. The code for server is finished.

Showing presence count in the frontend

The code for presence is done in the server, now its a matter of showing the presence count on the client. I made changes in the same place where the code of joining a channel is present.

UserJoin.tsx
import { useState, useContext, useEffect } from "react";
import { Channel, Presence } from "phoenix";

import { SocketContext } from "./Socket";

export default function UsersOnline() {
  const [usersOnline, setUsersOnline] = useState(1);
  const socket = useContext(SocketContext);

  useEffect(() => {
    let phoenixChannel: Channel;
    if (socket) {
      phoenixChannel = socket.channel("user-join");

      // --------------------- New code
      let presence = new Presence(phoenixChannel);
      presence.onSync(() => {
        presence.list((_id, { metas: metas }) => {
          setUsersOnline(metas.length);
        });
      });
      // --------------------- New code

      phoenixChannel.join().receive("ok", () => {
        console.log("joined channel user-join");
      });
    }

    // leave the channel when the component unmounts
    return () => {
      if (socket && phoenixChannel) phoenixChannel.leave();
    };
  }, [socket]);

  return <div>Users online: {usersOnline}</div>;
}

Lets break this down.

I used state to store the users online count.

const [usersOnline, setUsersOnline] = useState(1);

and then instantiated presence, providing the channel connection as the class argument.

let presence = new Presence(phoenixChannel);

presence.onSync(() => {
  presence.list((_id, { metas: metas }) => {
    setUsersOnline(metas.length);
  });
});

The presence object has various methods, one of them is onSync which gets called whenever the presence data changes(user either leave or join).

I then use presence.list to get the list of users(or socket connections) that are online. The length of the list is the number of users who are online. I store this count in the state, and show it to the user.

That's it! Now, I have a functioning real-time user counter.

That was pretty easy. Thanks Phoenix!

Final step

The final step was to host the backend somewhere(I already use Vercel for my frontend), I chose fly.io since it's pretty easy to setup. I just needed to make sure that both frontend and backend are hosted on the same domain otherwise I'll get the scary CORS error.

So, my backend is hosted at: https://phoenix.aayushsahu.com/ and my frontend connects to wss://phoenix.aayushsahu.com/socket for socket connection.

Future possibilities

Connecting my website to a real-time phoenix server opens up a lot of opportunities such as Like counter, real-time commenting system, etc. Can't wait to explore these ideas in the future!

That's it for this blog. See you on the next one!

If you have any query/suggestions, feel free to reach out to me on my socials.

0 likes

Other articles