Hashrocket.com / blog

Large circuits

Integrating React Components with a Phoenix Application

posted on and written by Chad Brading in

Image 100x100 chad brading

In this blog post we’re going to integrate React components into a Phoenix application. For this example we will implement a simple chat interface with multiple rooms for users to join.

To get started, let’s create a new Phoenix application. (Note we will be using Phoenix version 1.0.2. For installation instructions visit https://www.phoenixframework.org/docs/installation.)

mix phoenix.new chat

Then cd into the directory and run the following commands to include React.

bower init
bower install react --save

Before we start adding React code we need to set up the Phoenix application to render our template. Let’s first create the controller to handle requests.

// web/controllers/room_controller.ex

defmodule Chat.RoomController do
  use Chat.Web, :controller

  def index(conn, _params) do
    render conn, "index.html"
  end
end

And the view to render our template.

// web/views/room_view.ex

defmodule Chat.RoomView do
  use Chat.Web, :view
end

Next let’s add the basic template which our React code can use to render its components into.

<!-- web/templates/room/index.html.eex -->

<div id="dashboard"></div>

Then update the router to direct root requests to our room controller.

// web/router.ex

get "/", RoomController, :index

At this point we can check that our template is rendering correctly by running mix phoenix.server. But before we go any further, let’s go ahead and add our room channel, which we will use to send messages between clients.

// web/channels/room_channel.ex

defmodule Chat.RoomChannel do
  use Phoenix.Channel

  def join("topic:" <> _topic_name, _auth_msg, socket) do
    {:ok, socket}
  end

  def handle_in("message", %{"body" => body}, socket) do
    broadcast! socket, "message", %{body: body}
    {:noreply, socket}
  end
end

Notice that we are allowing users to join a channel with any topic, which will let us have any number of chat rooms. Next let’s define this channel in the user socket.

// web/channels/user_socket.ex

channel "topic:*", Chat.RoomChannel

For this example we will add our JavaScript code to the app.js file. First add the following line so we will have access to our socket.

// web/static/js/app.js

import socket from "./socket"

Now we are ready to start adding our React code. Let’s begin by adding a Dashboard component which will be the parent of the rest of our components.

// web/static/js/app.js

let Dashboard = React.createClass({
  render() {
    return(
      <div>
        <RoomList />
        <ActiveRoom />
      </div>
    )
  }
})

React.render(<Dashboard />, document.getElementById("dashboard"))

Here you can see that Dashboard has two child components, RoomList, which will hold a list of the possible chat rooms users can join, and ActiveRoom, which will include the name of the active chat room and a list of its current messages. The last line is where we are rendering our React components into the template we previously created.

We will also need to pass a list of rooms to RoomList and an active room to ActiveRoom, so let’s update the Dashboard component to take care of this.

// web/static/js/app.js

let rooms = ["general", "mix", "ecto", "plug", "elixir", "erlang"]

let Dashboard = React.createClass({
  getInitialState() {
    return {activeRoom: "general"}
  },
  render() {
    return(
      <div>
        <RoomList rooms={rooms}/>
        <ActiveRoom room={this.state.activeRoom}/>
      </div>
    )
  }
})

Next let’s create the RoomList component.

// web/static/js/app.js

let RoomList = React.createClass({
  render() {
    return (
      <div>
        {this.props.rooms.map(room => {
          return <span><RoomLink name={room} /> | </span>
        })}
      </div>
    )
  }
})

Here we are looping over the list of rooms and creating subsequent RoomLink components, which will act as triggers to change the active room. The RoomLink components will look like this.

// web/static/js/app.js

let RoomLink = React.createClass({
  render() {
    return(
      <a style={{cursor: "pointer"}}>{this.props.name}</a>
    )
  }
})

You may have noticed that we haven’t added an event handler for when our link is clicked. This will include a callback function that is passed down from the parent Dashboard component. Let’s add the handler to RoomLink and then see how it is passed down from its parent components.

// web/static/js/app.js

let RoomLink = React.createClass({
  handleClick() {
    this.props.onClick(this.props.name)
  },
  render() {
    return(
      <a style={{ cursor: "pointer"}} onClick={this.handleClick}>{this.props.name}</a>
    )
  }
})

The onClick property will be passed down from the RoomList component.

// web/static/js/app.js

let RoomList = React.createClass({
  render() {
    return (
      <div>
        {this.props.rooms.map(room => {
          return <span><RoomLink onClick={this.props.onRoomLinkClick} name={room} /> | </span>
        })}
      </div>
    )
  }
})

And the onRoomLinkClick property of RoomList is being passed down from the Dashboard component, where it is also being defined.

// web/static/js/app.js

handleRoomLinkClick(room) {
  let channel = socket.channel(`topic:${room}`)
  this.setState({activeRoom: room, channel: channel})
},
render() {
  return(
    <div>
      <RoomList onRoomLinkClick={this.handleRoomLinkClick} rooms={rooms}/>
      <ActiveRoom room={this.state.activeRoom} />
    </div>
  )
}

Here you can see that we have passed the name of the link that was clicked up to the Dashboard component, where we are using it to change the active room to the link that was clicked and to change the channel to reflect the active room. We haven’t created a channel yet up to this point, but we will be using a separate channel for each room a user can join. Now is a good time to show how we will initially set up the channel when our Dashboard is first loaded and how we will update it upon joining a new room. Add a channel attribute to our getInitialState function and include the following componentDidMount function, which will execute when the Dashboard component is first loaded.

// web/static/js/app.js

getInitialState() {
  return {activeRoom: "general", channel: socket.channel("topic:general")}
},
componentDidMount() {
  this.configureChannel(this.state.channel)
},

Notice we are calling a configureChannel function, which will describe how we join a channel. Let’s define it now.

// web/static/js/app.js

configureChannel(channel) {
  channel.join()
    .receive("ok", () => { console.log(`Succesfully joined the ${this.state.activeRoom} chat room.`) })
    .receive("error", () => { console.log(`Unable to join the ${this.state.activeRoom} chat room.`) })
},

We will also need to call this function when a new room is joined, so let’s add the following line to our handleRoomLinkClick function.

// web/static/js/app.js

handleRoomLinkClick(room) {
  let channel = socket.channel(`topic:${room}`)
  this.setState({activeRoom: room, channel: channel})
  this.configureChannel(channel)
},

Next add the following placeholder for our ActiveRoom component.

// web/static/js/app.js

let ActiveRoom = React.createClass({
  render() {
    return (
      <div>
      </div>
    )
  }

Now if you navigate back to the browser and open up the console, you can see that we are able to successfully join different chat rooms. However, we still haven’t implemented our ActiveRoom component, so there is no way to submit or view messages. Let’s do that now.

// web/static/js/app.js

let ActiveRoom = React.createClass({
  render() {
    return (
      <div>
        <span>Welcome to the {this.props.room} chat room!</span>
        <MessageInput />
        <MessageList messages={this.props.messages}/>
      </div>
    )
  }
})

Here we have the child components MessageInput, where new messages will be submitted, and MessageList, where the current messages for the active chat room will be displayed. Notice we are passing MessageList a list of messages from an ActiveRoom property, which we haven’t yet defined. This property will be passed to ActiveRoom from the Dashboard component, so let’s add that now.

// web/static/js/app.js

getInitialState() {
  return {activeRoom: "general", messages: [], channel: socket.channel("topic:general")}
},
render() {
  return(
    <div>
      <RoomList onRoomLinkClick={this.handleRoomLinkClick} rooms={rooms}/>
      <ActiveRoom room={this.state.activeRoom} messages={this.state.messages} />
    </div>
  )
}

We are setting the initial state of the messages to an empty array and will update it whenever a new message is submitted. But before switching to how messages will be submitted, let’s finish implementing how messages are displayed.

// web/static/js/app.js

let MessageList = React.createClass({
  render() {
    return (
      <div>
        {this.props.messages.map(message => {
          return <Message data={message} />
        })}
      </div>
    )
  }
})

Here we are looping over our messages and returning a Message component, which will look like this.

// web/static/js/app.js

let Message = React.createClass({
  render() {
    return (
      <div>
        <div>{this.props.data.text}</div>
        <div>{this.props.data.date}</div>
      </div>
    )
  }
})

Also, when we switch rooms we want to reset our message list, so update the handleRoomClick function in the Dashboard component.

// web/static/js/app.js

handleRoomLinkClick(room) {
  let channel = socket.channel(`topic:${room}`)
  this.setState({activeRoom: room, messages: [], channel: channel})
  this.configureChannel(channel)
},

To allow users to actually submit messages we need to implement our MessageInput component.

// web/static/js/app.js

let MessageInput = React.createClass({
  handleSubmit(e) {
    e.preventDefault()
    let text = React.findDOMNode(this.refs.text).value.trim()
    let date = (new Date()).toLocaleTimeString()
    React.findDOMNode(this.refs.text).value = ""
    this.props.onMessageSubmit({text: text, date: date})
  },
  render() {
    return (
      <form onSubmit={this.handleSubmit}>
        <input type="text" ref="text"/>
      </form>
    )
  }
})

Here you can see that we have added an input text box for users to enter their messages, as well as a handleSubmit event handler that is resetting the text box and calling an onMessageSubmit function. We haven’t yet defined the onMessageSubmit function. It is a callback from the Dashboard component that is passed down through the ActiveRoom component. Let’s first add it to ActiveRoom.

// web/static/js/app.js

let ActiveRoom = React.createClass({
  render() {
    return (
      <div>
        <span>Welcome to the {this.props.room} chat room!</span>
        <MessageInput onMessageSubmit={this.props.onMessageSubmit}/>
        <MessageList messages={this.props.messages}/>
      </div>
    )
  }
})

And then let’s define it in Dashboard and pass it to ActiveRoom.

// web/static/js/app.js

handleMessageSubmit(message) {
  this.state.channel.push("message", {body: message})
},
render() {
  return(
    <div>
      <RoomList onRoomLinkClick={this.handleRoomLinkClick} rooms={rooms}/>
      <ActiveRoom room={this.state.activeRoom} messages={this.state.messages} onMessageSubmit={this.handleMessageSubmit}/>
    </div>
  )
}

Now that we are pushing messages through our channel, we need to add a handler for when our channel receives a message. Add the following to our configureChannel function in the Dashboard component.

// web/static/js/ap

configureChannel(channel) {
  channel.join()
    .receive("ok", () => { console.log(`Succesfully joined the ${this.state.activeRoom} chat room.`) })
    .receive("error", () => { console.log(`Unable to join the ${this.state.activeRoom} chat room.`) })
  channel.on("message", payload => {
    this.setState({messages: this.state.messages.concat([payload.body])})
  })
},

If you navigate back to the browser and open up a few tabs you should see that your messages are being pushed to other clients in the same chat room. To take a look at the sample repository visit https://github.com/chadbrading/phoenix-react-chat.

Posted in Elixir