Improving UX with Phoenix Channels & React Hooks

This is a guest post by Alex Griffith, originally published on the Flatiron Labs blog. Alex is an engineer at The Flatiron School. He is also an incredibly talented teacher who happens to have a hilarious twitter handle

Integrating Learn.co with a Partner Organization

As part of a project we're working on with a partner organization, we have been building a new workflow for curriculum authors to create curriculum that will be hosted on our Learn.co platform. The lessons they create will be accessed through their own learning management system, but served through Learn. This will allow students taking a course through the partner organization to use all of the features that come bundled with Learn.

Some background on the Learn curriculum system: all of the content that makes up the curriculum at Flatiron School is stored in GitHub repositories. For a student to complete a lesson, they must fork the repository, write code to complete the lesson, run their code against any tests the repository contains, and finally open a pull request to signal they are ready to move on the next lesson. The Learn.co platform facilitates this test-driven-learning, and thanks to features like the in-browser IDE, a student can do all of this without leaving the browser.

For this new curriculum authoring workflow, we created a new Phoenix application. When a curriculum author creates a new piece of curriculum, it's the job of this application to orchestrate a sequence of steps behind the scenes:

A new empty repository is created on GitHub where the lesson will be added.
We retrieve the lesson starter code (aka the lesson "template") from the appropriate template repository and clone it into a temporary git directory on our server. We then set the temp directory's remote to the newly created empty lesson repo on GitHub, commit the code, and push it up to the remote.
Learn.co is notified about this new content and syncs its database.
The partner organization is notified that the lesson was successfully created.
This process can take a bit of time. Some of the steps encompass a significant amount of work, and each is dependent upon the previous step completing successfully, so they have to be performed synchronously in sequence. The user is left seeing a spinner spinning away for enough seconds to get very bored, or worse, worried something has gone wrong — and we definitely don't want that!

GIF

This blog post will cover how we leveraged Phoenix Channels, a Websocket client that comes out-of-the-box in a Phoenix app, as well as React Hooks and Context API on the frontend allowed us to improve UX by providing some feedback and transparency to a user as this process completes. The backend will broadcast a message over a lesson-specific channel after each step is completed, and the frontend will update state to reflect the progress to the user in real-time.

Note: Throughout this post we'll implement functionality using React Hooks. To use Hooks you must have the 16.7.0-alpha.0 version of both react and react-dom installed. Hooks are an "experimental proposal to React" and not production ready at the time of this writing.

Frontend: Using the Phoenix Channel Client in a React App

The Phoenix JavaScript client provides an API to establish a Websocket connection with a Phoenix application.

import { Socket } from 'phoenix'  
const socket = new Socket(webSocketUrl, {params: options})  
socket.connect()  

Within a React application, this socket object will need to be accessible at any arbitrary depth in the component tree wherever we need to broadcast or receive messages over a channel. At the same time, the Websocket connection is a pretty distinct concern from the business or presentational logic of many of the individual components. This is a perfect use case for React's Context API. Rather than passing the socket object as a prop all the way down through various middle level components, it can be put into context and consumed from the context as needed.

In the following steps, we will:

  • create the context;
  • create a SocketProvider component that's job is to stick the socket connection into context;
  • and construct a useChannel custom hook that will grab the socket object from the context, join the specified channel, and respond to any messages being pushed over that channel.

Creating the Context

React's normal mechanism for passing data to a component is from parent down to child via props. "Context provides a way to pass data through the component tree without having to pass props down manually at every level". Creating a new context is pretty straightforward:

import { createContext } from 'react'  
const SocketContext = createContext()  
export default SocketContext  

The SocketProvider Component

The SocketProvider component will expect to be rendered one time at a high level in the component tree, much like the Redux Provider, wrapping our entire application. Here's an example of how it is intended to be used:

<SocketProvider wsUrl={"/socket"} options={{ token }}>  
  <App {…props} />
</SocketProvider>  

It should receive two props. First, the url over which it will make an HTTP request to the server as the first step in the Websocket handshake. Then it can receive any additional options, such as a token for authentication. The default route for a Phoenix application is "/socket".

Internally, the component will initialize the socket object, set it into the SocketContext, render out any child components and fire off the handshake request once it has been mounted. Traditionally, the last requirement would force this to be implemented as a class component to access the component lifecycle methods such as componentDidMount. Using the new Hooks API will allow us to define the component as a function component; no need for this and lifecycle methods.

"The Effect Hook lets you perform side effects in function components". It receives a callback that will, by default, run when the component mounts and on each subsequent re-render. In this case, firing once after mounting is sufficient to establish the connection to the server so we can specify to re-invoke the callback only if the websocket url or options ever change by passing those values as the second argument:

useEffect(() => { socket.connect() }, [options, wsUrl])  

After creating the socket object, we need to be sure it is available to child components, as it encapsulates all the behavior we'll need to join and communicate over a channel. To expose the socket object for use by child components, we can use the Context.Provider component to set it into the context:

<SocketContext.Provider value={socket}>  
 { children }
</SocketContext.Provider>  

Here's the full SocketProvider:

import React, { useEffect } from 'react'  
import PropTypes from 'prop-types'  
import { Socket } from 'phoenix'

import SocketContext from '../contexts/SocketContext'

const SocketProvider = ({wsUrl, options, children}) => {  
  const socket = new Socket(wsUrl, { params: options })
  useEffect(() => { socket.connect() }, [options, wsUrl])

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

SocketProvider.defaultProps = {  
 options: {}
}

SocketProvider.propTypes = {  
  wsUrl: PropTypes.string.isRequired,
  options: PropTypes.object.isRequired,
}

export default SocketProvider  

Next, we'll use the value we set into the context in the useChannel hook.

The useChannel custom Hook

A component whose functionality is dependent on a Websocket connection will need a way to respond to the messages it receives and update its state accordingly. Hooks allow any React component to hook into all the features from React including component state. In terms of updating an internal store of data in response to discrete messages, the reducer function pattern from Redux is really helpful to think about here.

A reducer is a function that receives the current representation of state plus an action — an action being a message describing how state is intended to be updated along with a payload of any additional data — and returns the new value of the state based on the message.

The component that implements the hook will pass in a reducer function that defines the messages it expects to receive and how to update state in response to each. Using the useChannel hook should look something like…

const state = useChannel('channelName', reducer, initialState)  

…thus enabling the component to maintain state that will always be in sync with the latest messages as they are received over the channel.

We can break this down into two distinct steps: handling messages and updating state in response to those messages.

Joining the Channel and Handling Messages

The Context Hook is very easy to use. It returns whatever we previously passed as the value prop to the Context.Provider component:

const socket = useContext(SocketContext)  

Next, if we have not already joined the channel, we need to create it and join it:

const channel = socket.channel(channelName, {client: 'browser'})  
channel.join()  
  .receive("ok", ({messages}) => console.log('successfully joined channel', messages || ''))
  .receive("error", ({reason}) => console.error('failed to join channel', reason))

The Phoenix JS client provides a way to handle a specific message broadcast on this channel, using the name of the message event and a callback handler function:

channel.on("some_msg", msg => console.log("Message Received"))  

Unfortunately, if we want to make the useChannel hook reusable, it should not have explicit knowledge of what messages to listen for in advance. It is the responsibility of the component implementing the hook to specify what messages it expects to receive in the reducer function.

As a workaround, there is an overridable channel.onMessage method we can define that will allow us to handle all events sent on the channel:

channel.onMessage = (event, payload) => {  
  console.log(`the event "${event}" just happened`)
  return payload
}

At this point we can successfully respond to the messages received and are ready to update state.

Updating state with useReducer

The Reducer Hook works pretty much as you would expect if you are familiar with Redux:

const [state, dispatch] = useReducer(reducer, initialState)  

It receives a reducer function and some initial state. It returns the state and the dispatch function which we can capture with some array destructuring. The dispatch function can be used to send an action (a message) to the reducer. When a message is received we will invoke dispatch with the message's event and payload.

Here's the full useChannel hook. Some things to notice are that:

  • it returns the state from the useReducer hook;
  • just as useEffect was used in the SocketProvider component in place of a lifecycle method, we can do the same thing here for joining the channel;
  • useEffect can return a function that will get invoked during a cleanup step similar to componentWillUnmount. We can invoke channel.leave() here.
import { useContext, useReducer, useEffect } from 'react'  
import SocketContext from './sockets/SocketContext'

const useChannel = (channelTopic, reducer, initialState) => {  
  const socket = useContext(SocketContext)
  const [state, dispatch] = useReducer(reducer, initialState)

  useEffect(() => {
    const channel = socket.channel(channelTopic, {client: 'browser'})

    channel.onMessage = (event, payload) => {
      dispatch({ event, payload })
      return payload
    }

    channel.join()
      .receive("ok", ({messages}) =>  console.log('successfully joined channel', messages || ''))
      .receive("error", ({reason}) => console.error('failed to join channel', reason))

    return () => {
      channel.leave()
    }
  }, [channelTopic])

  return state
}


export default useChannel  

Now we have a hook that can be included in any component enabling that component to connect to a channel and act on communications being sent over that channel. Internally, it gets the socket object from the context, joins the channel, and listens to messages sent over the channel.

When a message is received it invokes dispatch with the message; dispatch invokes the provided reducer function with the current state and the message then returns the new state. The hook returns this state which will then always be up to date with the latest messages.

Remember that in a component we can plan to use the hook like…

const state = useChannel('channelName', reducer, initialState)  

…thereby giving that component access to the value of state. The magic of hooks is that when that value changes the component will re-render, causing the UI to always be in sync with the latest data.

Before putting it all together and using the hook in a component, we'll cover setting up the backend to broadcast messages.

Backend: Broadcasting Messages with Phoenix

The backend is an Elixir umbrella project, a series of related Elixir applications. The Phoenix web app is called "deployer." Internally, the process of creating a lesson is referred to as "deploying" the lesson.

Phoenix makes it really simple to set up the Websocket connection, and the docs are a great resource. The steps we'll take are:

  • adding the channel route;
  • creating the channel module;
  • broadcasting messages from other apps in the umbrella.

Add the Channel Route

The route for the initial handshake is already set up for us in the lib/deployer_web/endpoint.ex file generated during the creation of the Phoenix app.

socket "/socket", DeployerWeb.UserSocket  

Inside of the UserSocket module is where we'll add the specific channel route. Uncomment the example route Phoenix provides and update it to the code below:

channel "deployment:*", DeployerWeb.DeploymentChannel  

The "*" here indicates a wildcard route. "deployment:" followed by anything will be handled by the DeploymentChannel that we'll create in the next step. In this example, we'll append the lesson uuid to ensure a unique channel name for each lesson.

Create the Channel

In the lib/channels directory create a new file called deployment_channel.ex and add the following code:

defmodule DeployerWeb.DeploymentChannel do  
  use Phoenix.Channel

  def join("deployment:" <> _lesson_uuid , _message, socket) do
   {:ok, socket}
  end
end  

This is the point where the frontend and backend establish their connection. The join function handles the request sent by invoking channel.join() on the frontend; and the {:ok, socket} response is what gets sent to the client to be acted on there.

The feature we are building doesn't require much special set up. The client will not be broadcasting any messages to the server, but here is where you would add handlers for incoming messages sent over the channel if that was required.

Broadcasting Messages from the Server

When our Phoenix app receives the POST /lesson request, it calls on another child app, LessonBuilder, in our umbrella to enact the lesson creation process. This child app contains the code that needs to broadcast messages to the client regarding the state of the lesson creation. This poses a problem since the LessonBuilder app is not a Phoenix application and doesn't know how to broadcast messages or otherwise communicate with the web app's channels. To solve this, we pass the broadcast function from the web app as a callback with some bound arguments so it can be invoked whenever and wherever each step is complete.

The library function we want is the Endpoint module's broadcast/3. We'll create an anonymous function that wraps the broadcast/3 function and passes in the channel name, event name, and receives the payload later whenever it is invoked. Using a closure here allows us to keep the logic around channels in the web app and not leak it across the boundaries of the applications. Functional Programming for the win!

Here's a simplified example of the controller and subsequent invocation:

# in the deployer web app
defmodule DeployerWeb.LessonController do  
  use DeployerWeb, :controller

  def create(conn, %{"lesson_uuid" => lesson_uuid} = lesson_params}) do
    broadcast = fn (payload) ->
      DeployerWeb.Endpoint.broadcast("deployment:#{lesson_uuid}", "step_complete", %{step: payload})
    end

    LessonBuilder.build_lesson(lesson_params, broadcast)
    # eventually invokes code in other apps
    # ...
  end
end

# later, in the lesson_builder app
def handle_step_success(step, broadcast) do  
  broadcast.(step.type)
  Logger.log_info("#{step.type} successful")
  {:ok, step} = mark_successful_step(step)
  step
end  

As shown here, whenever a step is completed we broadcast a "step_complete" event on the channel for this particular lesson deployment. The step's type is sent along in the payload. These correspond to the lesson creation bullet points listed in the introductory section. They are one of

  • 'create_lesson_repo'
  • 'create_lesson_contents'
  • 'update_learn_create_curriculum'
  • 'update_partner_create_curriculum'

Responding to these broadcasts on the frontend is the last piece needed for us to complete this feature.

Putting it All Together: Building the React Component

The CreationStatus component will render out a series of CreationStatusItem components. These will each receive a status prop, having a value of "pending", "active", or "complete", and a label prop describing the step. The CreationStatusItem is a purely presentational component that's main job is to render the different markup and styling associated with the different statuses.

const CreationStatus = ({ lessonUuid }) => {  
  const initialState = {
    createRepo: 'pending',
    createContents: 'pending',
    updateLearn: 'pending',
    updatePartner: 'pending'
  }

  return (
    <div className='segment'>
      <div className='list'>
        <CreationStatusItem
          status={initialState.createRepo}
          label='Creating Remote Repository'
        />
        <CreationStatusItem
          status={initialState.createContents}
          label='Pushing Template Contents'
        />
        <CreationStatusItem
          status={initialState.updateLearn}
          label='Updating Learn.co'
        />
        <CreationStatusItem
          status={initialState.updatePartner}
          label='Updating Partner Organization'
        />
      </div>
    </div>
  )
}

The status of each item will need to update in real-time as messages are sent over the channel. This is where the useChannel hook will become useful. We implemented it to return the state object that is returned by the reducer function. This object will have the keys createRepo, createContents, updateLearn, and updatePartner and the values of those keys will change when a message is received on the channel and from there dispatched to the reducer. Starting as "pending", each step's status should next become "active", and finally "complete".

We can use ES6 destructuring to grab these status values off of the object returned by the hook. When the value changes the component will re-render thereby ensuring the UI is always up to date:

import React from 'react'  
import PropTypes from 'prop-types'

import CreationStatusItem from './CreationStatusItem'  
import useChannel from '../hooks/useChannel'  
import eventReducer from '../reducers/eventReducer'

const CreationStatus = ({ lessonUuid }) => {  
  const initialState = {
    createRepo: 'pending',
    createContents: 'pending',
    updateLearn: 'pending',
    updatePartner: 'pending'
  }

  const {
    createRepo,
    createContents,
    updateLearn,
    updatePartner
  } = useChannel(`deployment:${lessonUuid}`, eventReducer, initialState)


  return (
    <div className='segment'>
      <div className='list'>
        <CreationStatusItem status={createRepo}     label='Creating Remote Repository' />
        <CreationStatusItem status={createContents} label='Pushing Template Contents' />
        <CreationStatusItem status={updateLearn}    label='Updating Learn.co' />
        <CreationStatusItem status={updatePartner}  label='Updating Partner Organization' />
      </div>
    </div>
  )
}

CreationStatus.propTypes = {  
  lessonUuid: PropTypes.string.isRequired
}

export default CreationStatus  

There's a lot going on in this relatively simple looking code, let's take a moment to review all the steps.

Higher up in the component tree the SocketProvider reaches out and establishes a persistent Websocket connection with the server. It makes the socket object, the client through which we interact with the connection, available using React's Context API.

Next, when the CreationStatus component is rendered the useChannel hook is called. The code in the hook uses the socket connection from Context, creates a channel specific to the current lesson being created, and joins it using channel.join(). It also defines a channel.onMessage method that will get invoked whenever a message is sent over channel with the Endpoint.broadcast/3 function on the backend.

When a message is received, the dispatch function returned from the useReducer hook is called. dispatch sends a message to the reducer and updates the state accordingly. The useChanel hook returns the updated state, making it accessible to the component and, with the power of hooks, triggers a re-render when it changes.

Hooks allow for a super clean implementation here. It's easy to grok the presentational concerns of the component; the responsibility of the Websocket configuration is encapsulated in the useChannel hook, and it's generic enough to be reused because it doesn't know any information about the specific logic of this feature; the logic around how to update state is delegated to the reducer function, which is by definition a pure function that is very easy to unit test. Let's take a look at the reducer as the final piece of code:

The Reducer Function(s)

const eventReducer = (state, {event, payload}) => {  
  switch (event) {
    case 'phx_reply':
      return {...state, createRepo: 'active'}
    case 'step_complete':
      return stepReducer(state, payload)
    default:
      return state
  }
}

const stepReducer = (state, {step}) => {  
  switch (step) {
    case 'create_lesson_repo':
      return {...state, createRepo: 'complete', createContents: 'active'}
    case 'create_lesson_contents':
      return {...state, createContents: 'complete', updateLearn: 'active'}
    case 'update_learn_create_curriculum':
      return {...state, updateLearn: 'complete', updatePartner: 'active'}
    case 'update_partner_create_curriculum':
      return {...state, updatePartner: 'complete'}
    default:
      return state
  }
}

The eventReducer handles the initial 'phx_reply' event signaling the channel was successfully joined and marks the first step as 'active'. From there, each 'step_complete' event invokes the stepReducer which marks the current step 'complete' and next step as 'active' until all are finished.

All that's left to do is sprinkle in some CSS animations and…

PIC GIF

Going Further: Creating a 'use-phoenix-channel' package

Wouldn't it be great if you could import { useChannel } into your React projects? Well you can! We wrapped up the useChannel hook and SocketProvider component into a package. Check it out on Github or npm.

The package contains one additional piece of functionality which we will briefly introduce below:

Broadcasting Messages from the Client

The implementation of the useChannel hook we built works great for our use case where the client is only ever receiving messages and the server is always in charge of sending them. What this does not demonstrate, though, is the full power of the Websocket protocol as a full duplex communication method.

In the traditional request response cycle, there is an implicit requirement that the client is always the one initiating the whole process. The client makes the request and the server responds. We saw sockets allow for a flow in the opposite direction. Upon an event happening on the server a message was broadcast to the client. Sockets allow this flow in both directions. Either the client or the server can kick off the cycle and send data to the other and even to multiple clients subscribed to the same channel.

There's two small pieces to go over that will allow useChannel to become a fully functional, fully duplexed hook.

First, a channel object created by the Phoenix JS client has a push method that allows broadcasting client side. It is the JavaScript equivalent to Phoenix's Endpoint.broadcast/3 function. It can be used like:

channel.push("eventName", payload)  

Secondly, there are no specific rules around the return values of custom hooks. Where before useChannel returned the state object, we can set it up to return both the state object and the push method.

This is the exact pattern we saw in the useReducer hook and is also found in other common hooks such as useState:

[state, setState] = useState(initialValue)

Here, an array is returned containing the state as the first element and setState as the second. setState is a function that updates the value of the state. Inside of the useChannel hook the dispatch function has this role.

We can follow a similar pattern and return the channel.push method as the second element from the hook. Recall that dispatch is invoked whenever a message is received on the channel. Invoking push from a component would therefore broadcast the message in such a way that it would update the state of any other components or clients subscribed to that channel. Here's an example of using this functionality in a component:

import React from 'react'  
import { useChannel } from 'use-phoenix-channel'

const BroadcasterButton = (props) => {  
  const [state, broadcast] = useChannel(props.channel, reducer, initialState)

  return (
    <button onClick={() => broadcast("event", props) }>
      Click Me
    <button />
  )
}

And finally, let's take a look at the new and improved useChannel hook:

import { useContext, useEffect, useReducer, useState } from 'react'  
import SocketContext from '../sockets/SocketContext'

const useChannel = (channelTopic, reducer, initialState) => {  
  const socket = useContext(SocketContext)
  const [state, dispatch] = useReducer(reducer, initialState)
  const [broadcast, setBroadcast] = useState(mustJoinChannelWarning)

  useEffect(() => (
    joinChannel(socket, channelTopic, dispatch, setBroadcast)
  ), [channelTopic])

  return [state, broadcast]
}

const joinChannel = (socket, channelTopic, dispatch, setBroadcast) => {  
  const channel = socket.channel(channelTopic, {client: 'browser'})

  channel.onMessage = (event, payload) => {
    dispatch({ event, payload })
    return payload
  }

  channel.join()
    .receive("ok", ({messages}) =>  console.log('successfully joined channel', messages || ''))
    .receive("error", ({reason}) => console.error('failed to join channel', reason))

  setBroadcast(() => channel.push.bind(channel))

  return () => {
    channel.leave()
  }
}

const mustJoinChannelWarning = () => (  
  () => console.error(`useChannel broadcast function cannot be invoked before the channel has been joined`)
)

export default useChannel  

subscribe and never miss a post!

Blog Logo

Sophie DeBenedetto

comments powered by Disqus
comments powered by Disqus