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:
For my website as a whole, I have the count of people who have it open at the moment:
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:
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.
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:
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:
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:
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.
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.
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.