Tracking User State with Phoenix Presence, React and Redux

In any application, you may need to track user involvement in real-time features. In a gaming, chatting or otherwise real-time and interactive application, we want to track things like which users are present in a chat/game/live stream or who is typing a message/shooting aliens/streaming a thing.

For example, let's say you maintain RickChat, the internal chatting application used by the Council of Ricks. In a large chat room accessible to Ricks from every conceivable reality, you'd need to list the Ricks currently in the chat room, and even indicate which Rick is currently typing (perhaps identified by their dimension of origin).


source

Of course you're already leveraging the awesome power of Phoenix Channels to implement this cutting-edge real-time functionality (you're so good at this, btw). But how do you sync up and share this kind of stateful information across channels?

One option is to build your own module that use's Elixir's GenServer. Whenever a user joins/leaves or otherwise meaningfully interacts with a given channel, you could update data in your GenServer and broadcast out a centralized state to all of the appropriate channels. This is a great option for tracking complex state changes.

However, when it comes to tracking data regarding user engagement in a "room" and exposing that data to a set of clients, Phoenix provides a powerful tool.

What is Phoenix Presence?

The Phoenix Presence module provides an API for you to register information regarding a given topic and expose that information to all of that topic's channels.

The Phoenix Presence module uses a Conflict Free Replicated Data Type, or CRTDT to store and broadcast topic-specific information. Unlike centralized data stores like Redis, which many of us are familiar with using in Rails, Phoenix Presence...

gives you high availability and performance because we don't have to send all data through a central server. It also gives you resilience to failure because the system automatically recovers from failures.
–– Chris McCord, via Dockyard

Phoenix Presence excels at doing exactly what it sounds like––tracking the presence of users. We'll implement Phoenix Presence to track who is online in a given chat room-like environment and who is currently typing.

The App

We'll be building a user tracking feature for Phoenix Pair, a pair programming app with a React + Redux front-end and Phoenix API back-end. The app allows users to choose from a list of code challenges. From within a given challenge room, at /challenges/:id, users can collaborate on a code solution by typing together in a shared text-editor.

The functionality we'll use Phoenix Presence to build can be seen above:

  • The list of participants on the right hand side.
  • The "typing indicator" of the glow + "..." that surrounds the name of the user who is currently typing in the shared text editor.

Our app currently implements the real-time functionality of a shared typing experience with the use of Phoenix Channels. Each "challenge room", at /challenges/:id, represents a channel topic. When a user "joins" a room, a new channel connection is opened for them with this topic.

We'll start with the "user joins" functionality. When a user joins a room, we should not only connect them to a channel with the given topic, but track their presence in the room and broadcast this presence to all channels with this topic. Let's get started!

Setting Up Phoenix Presence

First things first, we need to set up Phoenix Presence in our Phoenix application.

Using the Phoenix Presence Module

First, we'll define a module, ChallengePresence, and tell it to use the PhoenixPresence module.

We also need to provide our module with:

  • Our otp_app, which holds our application config
  • Our PubSub server, PhoenixPair.PubSub, which is included in our Phoenix app for free.
# lib/phoenix_pair/challenge_presence.ex
defmodule PhoenixPair.ChallengePresence do
  use Phoenix.Presence, otp_app: :phoenix_pair,
                        pubsub_server: PhoenixPair.PubSub
end

Using the PhoenixPresence module gives us access to the Phoenix Presence behavior and API.

We need to do one more thing to get our ChallengePresence up and running––we have to add it to our supervisor tree.

# lib/phoenix_pair.ex

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

  children = [
    supervisor(PhoenixPair.Repo, []),
    supervisor(PhoenixPair.Endpoint, []),
    supervisor(PhoenixPair.ChallengePresence, [])
  ]
  ...
end

Now we're ready to use our ChallengePresence module to track user joins events.

User Joins

When does a user "join" a challenge room? When they visit /challenges/:id. In our React + Redux application, this will cause the challenge show component to mount.

We're already using the componentDidMount lifecycle method to dispatch an action that sends a message to join a channel with the given challenge's topic. We'll take advantage of this existing architecture. When the channel receives the user join event, it will track the new user's presence via our ChallengePresence module and broadcast that presence to all of the subscribing clients.

React will listen for this broadcast. When it receives data regarding user presence, we'll dispatch an action to our reducer to update our React application's state with the updated list of present users.

Dispatching the Action

We'll dispatch our action from the componentDidMount lifecycle method on our challenges show component:

import Actions from '../../actions/currentChallenge';

componentDidMount() {
  const {dispatch, socket, params} = this.props;
  dispatch(Actions.connectToChannel(socket, params.id))
}

Our action does a few things:

  • Triggers a join event on our channel
  • Listens for a positive response from that join event
  • Dispatches an action to update our React app's state with the currently selected challenge
  • Establishes an empty object, set to the presences variable, that we will use to track user presence on the front-end.
const Actions = {
  connectToChannel: (socket, challengeId) => {
    return dispatch => {
      const channel = socket.channel(`challenges:${challengeId}`);
      let presences = {};
      channel.join().receive('ok', (response) => {
        dispatch({
          type: Constants.SET_CURRENT_CHALLENGE,
          challenge: response.challenge,
          channel: channel
        })
      });
    }
  }
}

Now that we see how React sends the user join event to our challenge channel, identified via the challengeId, let's teach our channel to track this event with the help of Phoenix Presence.

The Challenge Channel

Our ChallengeChannel will respond to receiving the join message by fetching the challenge with the given ID from the database, sending an after_join message, and responding to the client with the :ok message and the challenge object.

The user-tracking magic happens in the after_join function. Here, we add our user to the ChallengePresence data store of present users for this channel. Then, we broadcast a "presence_state" event through all channels with this topic.

defmodule PhoenixPair.ChallengeChannel do
  use PhoenixPair.Web, :channel
  alias PhoenixPair.{Challenge, User, Message, Chat}
  alias PhoenixPair.ChallengePresence

  def join("challenges:" <> challenge_id, _params, socket) do
    challenge = get_challenge(challenge_id) # get challenge record from the DB with this helper method, definition not shown
    send(self, {:after_join, challenge})

    {:ok, %{challenge: challenge}, assign(socket, :challenge, challenge)}
  end

  def handle_info({:after_join, challenge}, socket) do
    ChallengePresence.track_user_join(socket, current_user(socket))
    push socket, "presence_state", ChallengePresence.list(socket)
  
    {:noreply, socket}
  end
...

Let's look at our user tracking code first.

Tracking User Presence

The use of the PhoenixPresence module within ChallengePresence gives us access to the track function. This function registers this channel’s process as a presence under a top-level key of the user's ID, pointing to a map of metadata.

I've wrapped up the usage of PhoenixPresence.track in a tidy function, track_user_join. Let's take a look:

# lib/phoenix_pair/challenge_presence.ex
...
def track_user_join(socket, user) do    
  ChallengePresence.track(socket, user.id, %{
    typing: false,
    first_name: user.first_name,
    user_id: user.id
  })
end
...

The track function adds the user to our Presence data store's collection of users for this topic. It maintains this data store in the following format:

%{
  "1" => %{
    metas: [%{
      typing: false, 
      first_name: "Sophie", 
      phx_ref: "xxxx"
    }]
  },
   "2" => %{
    metas: [%{
      typing: false, 
      first_name: "Antoin", 
      phx_ref: "xxxx"
    }]
  }
}

The Presence Diff Event

This new tracking of a user for the given challenge topic will automatically broadcast a "presence_diff" event to all relevant channels. The payload that is broadcast is divided into keys of joins and leaves.

 %{
    joins: %{
      "1" => %{
        metas: [%{
          typing: false, 
          phx_ref: "xxxx", 
          first_name: "Sophie"
        }]
      }
    },
    leaves: %{
      "2" => %{
        metas: [%{
          typing: false, 
          phx_ref: "xxxx", 
          first_name: "Antoin"
        }]
    }
  }

Each top-level key is a user ID, defined by the second argument we passed to track. Each collection of meta data is defined by the third argument we passed to track.

On the front-end, our React application will listen for this "presence_diff" event via channel.on("presence_diff", response), in which response is the diff payload described above.

 // actions/currentChallenge.js

import {Presence} from 'phoenix';

const Actions = {
  connectToChannel: (socket, challengeId) => {
    return dispatch => {
      const channel = socket.channel(`challenges:${challengeId}`);
      ...
      channel.on("presence_diff", (response) => {
        presences = Presence.syncDiff(presences, response);
        syncPresentUsers(dispatch, presences);
      })
    }
  }
}

We can use the Presence class provided to us by Phoenix to reconcile the current list of presences with the diff payload.

presences = Presence.syncDiff(presences, response);

Notice how we are re-setting our presences variable, which started out as an empty object, to the map of user presences. Our presences variable acts as a mini data store, holding on to the current list of presences. This allows us to reconcile previous and new presence state every time the "presence_diff" event is broadcast to the channel.

Once we get our updated list of presences, we'll pass it along to a helper function, syncPresentUsers.

const syncPresentUsers = (dispatch, presences) => {
  const participants = [];
  Presence.list(presences).map(p => {participants.push(p.metas[0])})
  dispatch({
    type: Constants.CURRENT_CHALLENGE_PARTICIPANTS,
    participants
  });
};

This helper function uses the Presence.list function to collect the list of present user metadata and dispatch an action containing that collection to the reducer.

The reducer will then update state with the new list of user presences:

[{1: {first_name: "Sophie", typing: "false", ...}]
// reducers/currentChallenge.js

import Constants from '../constants';

const initialState = {
  currentChallenge: {},
  participants: [],
  channel: null
};

export default function reducer(state = initialState, action = {}) {
  switch (action.type) {
    case Constants.CURRENT_CHALLENGE_PARTICIPANTS:
      return {...state, participants: action.participants}
    ...
  }
}

The reducer's state change will cause the challenges show component to re-render with the new list of participants.

The Presence State Event

The Presence Diff event will broadcast the presence diff payload to all subscribing clients. But what about the client that initiated the event? How will this client receive the current presence state when they arrive in the challenge room?

This is why we broadcast the Presence State event in our after_join channel function:

# challenge_channel.ex
...
def handle_info({:after_join, challenge}, socket) do
  ChallengePresence.track_user_join(socket, current_user(socket))
  push socket, "presence_state", ChallengePresence.list(socket)
  
  {:noreply, socket}
end

This line:

push socket, "presence_state", ChallengePresence.list(socket)

sends the "presence_state" event to the newly subscribed client with a payload of the current list of presences for the given channel topic.

On the front-end, React will listen for this event via channel.on("presence_state", state_payload).

// actions/currentChallenge.js

...
channel.on("presence_state", (response) => {
  presences = Presence.syncState(presences, response);
  syncPresentUsers(dispatch, presences);
})
...

Here we use the Presence class's syncState function. This function will reconcile the list of current presences with the list of presences included in the event payload. Then, we pass this updated list to our syncPresentUsers function, which will use the reducer to update state as shown in the previous section.

We've covered what happens when a user joins a challenge room. But what happens when a user leaves?

User Leaves

The amount of work we have to do to broadcast the user leaves event (hint: very little) really illustrates the power of the Phoenix Presence module.

We'll use the componentWillUnMount lifecycle method to dispatch an action, removeParticipant:

// challenges show component

componentWillUnmount() {
  const {channel, dispatch} = this.props;
  dispatch(Actions.removeParticipant(channel))
}

This action is simple. It sends a leave message to the given channel:

// actions/currentChallange.js

removeParticipant: (channel) => {
  return dispatch => {
    channel.leave();
  }
}

This automatically fires a Presence Diff event. Our channel already knows how to respond to presence diff events and reconcile user presence via the Presence.syncDiff function, so that's all we have to do to get React to correctly update its state when a user leaves a challenge room. So cool!

Before we go, let's look at using Phoenix Presence to track a custom event: user typing.

Custom Event: User Typing

We already discussed that we'd like the name of the user who is typing to highlight and append "..." to indicate this typing state to the other users. We'll track and broadcast whether or not the current user is typing with the help of Phoenix Presence.

Dispatching the Action

When a user is typing a code challenge solution into the shared text editor, our component will invoke a function, updateChallengeResponse, which will invoke an action creator function:

// challenge show component
updateChallengeResponse(text) {
    const {dispatch, channel, currentUser} = this.props;
    dispatch(Actions.updateResponse(channel, text, currentUser));
  }

Let's take a look at the updateResponse action creator function:

// actions/currentChallenge.js

updateResponse: (channel, codeResponse, currentUser) => {
  return dispatch => {
    channel.push("response:update", {response: codeResponse, user_id: currentUser.id})
  }
}

Here, we send a "response:update" message to our channel, with a payload of the new text for the code challenge response, along with the ID of the current user.

Updating User Presence Meta Data

Our ChallengeChannel will respond to the "response:update" message by updating the challenge's response attribute in the database and by updating the typing attribute of the current user in our ChallengePresence data store.

# challenge_channel.ex
...
def handle_in("response:update", %{"response" => response, "user_id" => user_id}, socket) do
    case
  Challenge.update(current_challenge(socket), response) do
    {:ok, challenge}
       ->
      ChallengePresence.do_user_update(socket, current_user(socket), %{typing: true})
      broadcast! socket, "response:updated", %{challenge: challenge}
      {:noreply, socket}
    {:error, changeset} ->
      {:reply, {:error, %{error: "Error updating challenge"}}, socket}
  end
end

The line we really care about is here:

ChallengePresence.do_user_update(socket, current_user(socket), %{typing: true})

We're calling on a helper function do_user_update, that we've defined on our ChallengePresence module. Let's take a look.

# challenge_presence.ex
...
def do_user_update(socket, user, %{typing: typing}) do
  ChallengePresence.update(socket, user.id, %{
    typing: typing,
    first_name: user.first_name,
    user_id: user.id
  })
end

The usage of the PhoenixPresence module gives us access to the update behavior. We pass in the key we want to update, our user ID, and the payload to which we want to update the meta key underneath that user ID.

Luckily for us, the update function triggers the Presence Diff event to be broadcast. So, we don't have to add any code on the front-end in order to have React become aware of the updated user state.

Conclusion

We've seen that Phoenix Presence is a really powerful tool for tracking user presence for a given channel topic. The front-end offering in particular makes it super easy to sync presence data after presence events have fired. All in all, Phoenix Presence plays really nicely with React + Redux, allowing us to sync channel state with the state of our React application without writing a ton of code.

Happy coding!


source

subscribe and never miss a post!

Blog Logo

Sophie DeBenedetto

comments powered by Disqus
comments powered by Disqus