Display channel users count with Elixir/Phoenix + React/Redux

Implementing an anonymized and fast users count feature that can scale using Phoenix Presence.

Tue, 21 Aug 2018

A few months ago, we decided to implement a users count feature on CaptainFact’s videos pages. It had to be reliable, light and simple. We also wanted to differentiate between logged-in and anonymous users.

Banner

The most interesting read I had at the time on this subject was a medium article which has one major flaw: the full users list is returned from the backend and the count occurs on the frontend (which actually respects the default behaviour of Phoenix.Presence).

Depending on your business, it can represent a privacy issue (you don’t especially want anyone to be able to know who’s connected to your channel) as well as a performance issue.

In the following article, I’ll show you how I implemented Phoenix.Presence in such a way that it only returns the total of connected / anonymous users. We’ll then see how to bind it to Redux.

Backend

Implementing Phoenix.Presence

The first thing you want is to implement your own tracker:

lib/my_app_web/presence.ex

defmodule MyAppWeb.Presence do
  @moduledoc """
  Provides presence tracking to channels and processes.

  See the [`Phoenix.Presence`](http://hexdocs.pm/phoenix/Phoenix.Presence.html)
  docs for more details.
  """

  # Replace the values below with your app's values
  use Phoenix.Presence, otp_app: :my_app, pubsub_server: MyApp.PubSub
end

This implementation is enough if you want to return the classic users list (for example in a chat). In our case we want to override the default fetch to return only the users count, and not the full users list:

@doc """
Overrides the default fetch. Instead of returning the full users list,
we only return the count of open sockets.
This map is what will be returned to the frontend.
"""
def fetch(_topic, entries) do
  %{
    "viewers" => %{"count" => count_presences(entries, "viewers")},
    "users" => %{"count" => count_presences(entries, "users")}
  }
end

defp count_presences(entries, key) do
  case get_in(entries, [key, :metas]) do
    nil -> 0
    metas -> length(metas)
  end
end

Binding the presence module

Add your new module to your app’s supervisor

lib/my_app/application.ex

defmodule MyApp.Application do
  use Application

  def start(_type, _args) do
    import Supervisor.Spec

    # Define workers and child supervisors to be supervised
    children = [
      # Presence to track number of connected users to a channel
      supervisor(CaptainFactWeb.Presence, []),
      ...
    ]
  end
end

Plugging Presence on the channel

Last thing you need to do is to plug your new Presence module to your channel. This part depends of how you’ve implemented channels authentication, in our case we detect an authenticated channel by setting a user_id in the channel attributes.

Add the two following functions to the channel you want to track:

lib/my_app_web/channels/my_channel.ex

@doc """
Register a public connection in presence tracker
"""
def handle_info(:after_join, socket = %{assigns: %{user_id: nil}}) do
  {:ok, _} = Presence.track(socket, :viewers, %{})
  push_presence_state(socket)
  {:noreply, socket}
end

@doc """
Register a user connection in presence tracker
"""
def handle_info(:after_join, socket = %{assigns: %{user_id: user_id}}) do
  {:ok, _} = Presence.track(socket, :users, %{user_id: user_id})
  push_presence_state(socket)
  {:noreply, socket}
end

defp push_presence_state(socket) do
  push(socket, "presence_state", Presence.list(socket))
end

Then call these in you join/3 function when it succeeds:

def join("my_channel", _payload, socket) do
  send(self(), :after_join)
  {:ok, %{}, socket}
end

And that’s it for the backend !

Frontend

We now need to plug the channel presence messages to update our frontend. This example uses Redux, but you can easily transpose it for another store system.

app/state/presence/reducer.js

import { handleActions, createAction } from 'redux-actions'
import { Record } from 'immutable'


export const setPresence = createAction('PRESENCE/SET')
export const presenceDiff = createAction('PRESENCE/DIFF')


const INITIAL_STATE = new Record({
  viewers: new Record({count: 0})(),
  users: new Record({count: 0})(),
})

const PresenceReducer = handleActions({
  [setPresence]: (state, {payload}) => state.merge(payload),
  [presenceDiff]: (state, {payload: {leaves, joins}}) => {
    return state.withMutations(record => record
      .updateIn(['viewers', 'count'], x => {
        return (x + joins.viewers.count) - leaves.viewers.count
      })
      .updateIn(['users', 'count'], x => {
        return (x + joins.users.count) - leaves.users.count
      })
    )
  }
}, INITIAL_STATE())

export default PresenceReducer

And the final binding on channel:

app/state/my_channel/effects.js

import { Socket } from 'phoenix'
import { setPresence, presenceDiff } from '../my_channel/reducer'

/**
 * Effect to dispatch. Joins a channel 
 */
export const joinChannel = () => (dispatch) => {
  const socket = new Socket(SOCKET_URL)
  const channel = socket.channel("my_channel")
  channel.on('presence_state', presenceState => dispatch(setPresence(presenceState)))
  channel.on('presence_diff', diff => dispatch(presenceDiff(diff)))
  channel.join()
  return channel
}

And voilà! Add a component to render this stuff:

import React from 'react'
import PropTypes from 'prop-types'
import { connect } from 'react-redux'

const mapStateToProps = {presence: {nbUsers, nbViewers}} => ({nbUsers, nbViewers})

const Presence = ({nbUsers, nbViewers}) => (
  <div className="presence">
    <div type="primary">
      <span>{nbUsers} users</span>
    </div>
    <div className="viewers">
      <span>{nbViewers} viewers</span>
    </div>
  </div>
)

Presence.propTypes = {
  nbUsers: PropTypes.number.isRequired,
  nbViewers: PropTypes.number.isRequired,
}

export default connect(mapStateToProps)(Presence)

And you’ll end up with a nice anonimized and well-optimized counter:

Final Counter


All CaptainFact’s code is open-source (AGPL3). You can find the API code on https://github.com/CaptainFact/captain-fact-api and the frontend code on https://github.com/CaptainFact/captain-fact-frontend