Building a Messaging Feature in Rails & React

Allison Hill
9 min readMay 27, 2022

--

First things first, this tutorial assumes you already have a Rails API backend and a React frontend. If you don’t, you can refer to tutorials like this one by Lilian Kerubo. This tutorial also assumes you are reasonably familiar with react hooks (such as useState, useEffect) and establishing routes through react-router-dom.

STEP 1: Generate Models, Establish Relationships

OK. So first things first, let’s get our backend framework fleshed out. To get our messaging feature up and running, we’ll need to set up 3 models: a User model, a Conversation model, and a Message model. We can accomplish this easily using the rails -g command:

rails -g resource User name username password {...whatever else you want to include...}rails -g resource Conversation user_a:references user_b:referencesrails -g resource Message conversation:references user:references

The relationship between our models will be laid out as follows:

A User has many conversations and whatever attributes are relevant for your app (such as name, username, password, etc.)**

  • *Although a user technically also has_many messages, we will not be utilizing this relationship since it makes more sense to organize messages in conversations (rather than accessing every message a user has sent).
/* models/user.rb */has_many :conversations, :foreign_key => :user_a_id

A Conversation has many messages and belongs to 2 different users. There’s a few more steps to establish this belongs_to relationship since we need to make sure our Conversation knows that it belongs to 2 different users; however Active Record makes it relatively easy to handle. We just need to: 1) give each user an alias/nickname (ex; user_a), 2) specify what class each alias belongs to (ex; User), and 3) specify the foreign key that will be present in our table (ex; :user_a_id). We’ll also add a statement that validates that user_a_id is unique from user_b_id, to ensure that user_a & user_b refers to two distinct users. The syntax to accomplish this is outlined below.

/* models/conversation.rb */belongs_to :user_a, class_name: 'User', :foreign_key => :user_a_id
belongs_to :user_b, class_name: 'User', :foreign_key => :user_b_id
has_many :messages, dependent: :destroy
validates_uniqueness_of :user_a_id, :scope => :user_b_id

A Message belongs to a conversation and belongs to a user (who is the “sender’ of the message). Similar to our Conversation model, we could use an alias of “sender” in place of “user” to make the relationship more clear; however, that’s not absolutely necessary.

/* models/user.rb */belongs_to :conversation
belongs_to :user

STEP 2: Database Migrations

If you use the rails -g resource generator, 90% of the work here will be done for you. The only piece is modifying the syntax slightly in your CreateConversations migration file, changing the syntax of foreign_key: true to index: true since we do not have a “user_a” or “user_b” table (code example below)

/* db/migrate/[TIMESTAMP]_create_conversations.rb */ class CreateConversations < ActiveRecord::Migration[7.0]def changecreate_table :conversations do |t|t.references :user_a, null: false, index: truet.references :user_b, null: false, index: truet.timestampsendend

STEP 3: Controller Actions & Routes

Next up, we’ll create the controller actions to handle our index/create/routes. These will be located in the Conversation and Message controller files.

Conversation Controller

Our conversation will have 1 action associated with a route established through the resources :conversations statement in our routes folder (create) and one action established by a custom route (user_index). We will not be creating any delete or update functionality for our Conversation model since we do not want users to be able to delete or update conversations themselves.

To make our lives slightly easier, we will establish a scope called “between” in our Conversation model that identifies which 2 users a conversation belongs to (code below).

/* models/user.rb */ scope :between, -> (user_a_id,user_b_id) dowhere(“(conversations.user_a_id = ? AND conversations.user_b_id =?) OR (conversations.user_a_id = ? AND conversations.user_b_id =?)”, user_a_id,user_b_id, user_a_id, user_b_id)end

We’ll use this scope in our “create” function to check if a conversation between 2 users already exists. If it does, we’ll return the pre-existing conversation. If a conversation between the 2 users does not exist yet, we’ll create one and return that new conversation using the permitted conversation_params (as defined in the private method below)

/* controllers/conversations_controller.rb */ def createif Conversation.between(params[:user_a_id],params[:user_b_id]).present?@conversation = Conversation.between(params[:user_a_id],params[:user_b_id]).firstelse@conversation = Conversation.create!(conversation_params)endrender json: @conversation, status: :okend privatedef conversation_paramsparams.permit(:user_a_id,:user_b_id)end

To index the conversations associated with a particular user, there are 2 approaches we could take in routing to our controller options: nesting the Conversation routes underneath the User routes or creating a custom route. Either case would allow us to access a :user_id parameter in our controller function; however, an important thing to keep in mind is that the Messages routes will need to be nested under our Conversation routes. Since the best practice is to avoid nesting resources deeper than two levels, I recommend creating a custom index route to a “user_index” function. This “user_index” function, will query the database for Conversations where the current user_id matches either the user_a_id or user_b_id and return that conversation.

/* config/routes.rb */get ‘/conversations/userindex/:user_id’, to: “conversations#user_index”/* controllers/conversations_controller.rb */def user_indexuser_conversations = Conversation.where(user_a_id:params[:user_id]).or(Conversation.where(user_b_id:params[:user_id]))render json: user_conversations, status: :okend

Messages Controller

As I alluded to earlier, the Messages Controller routes will be nested underneath the Conversation routes (as shown below). While this will modify the URL we send our fetch request to from our front end, it will not change the name of our controller functions. For more information about nesting resources, refer to the Rails documentation.

/* config/routes.rb */ resources :conversations, shallow: true doresources :messagesend

To make our lives slightly easier, we will add a “before_action” to find the Conversation we are fetching messages for and assign it to a global variable, @conversations (which we will then be able to access within all of our Messages Controller functions).

before_action do@conversation = Conversation.find(params[:conversation_id])end

Next, we will walk through building out create functions to handle creating, updating, deleting, and indexing messages. We will also write a function to specifically update whether a particular message has been “read” or not. As with the Conversation Controller, we will implement controlled parameters with a private method called message_params.

def index@messages = @conversation.messagesrender json: @messages, status: :okend
def createnew_message = Message.create!(message_params)render json: new_message, status: :createdenddef delete delete_message = Message.find(params[:id])delete_message.destroyrender json: delete_message, status: :okend def readmessage = Message.find(params[:id])message.read = truerender json: message, status: :okend
privatedef message_paramsparams.require(:message).permit(:body, :user_id,:conversation_id)end

STEP 4: Serializers

To make things smoother in the front end, I modified both the Conversations Serializer and Messages Serializer to include a few additional attributes.

Conversation Serializer

For the Conversation Serializer, I added 3 custom attributes: user_a_name (which returns the name of a conversation’s user_a), user_b_name (which returns the name of a conversation’s user_b), and last_message (which returns a hash that includes the body of the last message & the sender’s name)

/* serializers/conversation_serializer.rb */ attributes :id, :user_a, :user_b, :last_messagedef user_a_namereturn User.find(self.object.user_a_id).nameend
def user_b_namereturn User.find(self.object.user_b_id).nameend
def last_messagemsg_body = self.object.messages.last.bodymsg_sender = User.find(self.object.messages.last.user_id).namereturn {:body => msg_body,:sender => msg_sender}end

Message Serializer

For the Message Serializer, I added the attribute :sender, which includes the name of the message’s sender. I also included the belongs_to: :conversation relationship (since we will need to pass conversation_id as a parameter when we create a new message, thanks to our nested resources)

/* serializers/message_serializer.rb */attributes :id, :body, :sender
def senderreturn User.find(self.object.user_id).nameend
belongs_to :conversationend

STEP 5: Front-End React Components

For our front-end, we will create 3 React components specific to our messaging feature: an Inbox (which will be responsible for displaying a messages summary for each conversation), a ChatSummary (which will show the username of the current user’s conversational partner, which when clicked will redirect to…), a Chatbox (which will show all of that conversations’ messages as well as a textbox where the user can type/send a message).

Inbox

screenshot of the Inbox and ChatSummary components that will be created in the tutorial below
screenshot of the Inbox and ChatSummary components that will be created in the tutorial below

Our inbox has 2 main responsibilities: fetching a list of all of a users’ conversations and rendering a ChatSummary component for each conversation.

We will fetch all of our users’ conversations by sending a get request to ‘/conversations/userindex/:user_id’, which will map to the conversations#user_index controller function we created earlier. We will then store the array of conversations we get back in a piece of state called “conversations”.

/* client/components/Inbox.js */useEffect(() => {fetch(`/conversations/userindex/${currentUser.id}`).then(res => res.json()).then(res_conversations => setConversations(res_conversations))},[])

Once we set our “conversations” state, we will use .map to create a ChatSummary component for each conversation (which includes a setCurrentConvo function, which will be relevant when we redirect to a Chatbox later), all of which is wrapped in an “Inbox” div.

/* client/components/Inbox.js */return (<div className="Inbox"><h1>{`${currentUser.name}'s Inbox`}</h1>{conversations.map(conversation => <ChatSummary id= {conversation.id} conversation={conversation} currentUser={currentUser} setCurrentConvo={setCurrentConvo}/>)}</div>)

ChatSummary

The ChatSummary component has 2 responsibilities: displaying a summary of the chat, and redirecting the user to a Chatbox component specific to that conversation.

For my summary, I decided that I wanted to display the name of the other user that a conversation is shared with and the last message sent (including the name of whoever sent it). To handle displaying the summary information, I created a variable called “me” that represents whether or not a conversations’ user_a is equal to the current user. I used this to craft a series of ternary statements that basically displays the conversational partner’s name where appropriate.

/* client/components/ChatSummary.js */let me = conversation.user_a_name === currentUser.namereturn(<div className="chatsummary" onClick={renderChatbox}><h2>{me ? conversation.recipient : conversation.sender}</h2> <p>{me ? `${conversation.last_message.sender} said: ${conversation.last_message.body}`:`you said: ${conversation.last_message.body}`}</p></div>

To handle redirecting the user to a Chatbox associated with that conversation, I elected to use the useNavigate hook from the react-router-dom library; however, this does add a slight complication. Since ChatSummary is not rendering the Chatbox directly, we need to find a way to communicate to Chatbox which conversation it should be fetching messages for. To solve this, I elected to store a piece of state called “currentConvo” in my App” component (the highest level parent of both ChatSummary and Chatbox) and pass the setter function (setCurrentConvo) down from App, through Inbox to ChatSummary. When a particular ChatSummary component is clicked, the onClick function sets currentConvo to the conversation associated with the clicked component, then redirects the page to `/inbox/${conversation.id}` (which is the BrowserRouter Route responsible for rendering the Chatbox).

/* client/components/ChatSummary.js */let navigate= useNavigate();
function renderChatbox(){setCurrentConvo(conversation)navigate(`/inbox/${conversation.id}`)}

Chatbox

screenshot of the Chatbox component that will be created in the tutorial below
screenshot of the Chatbox component that will be created in the tutorial below

The Chatbox component has 3 responsibilities: fetching all of the messages in a conversation, rendering those messages, and sending (creating) new messages.

We will fetch all of our users’ conversations by sending a get request to ‘/conversations/:conversation_id/messages’, which will map to the messages#index controller function we created earlier. We will then store the array of conversations we get back in a piece of state called “messages”.

const [messages, setMessages]=useState([])useEffect(() => {fetch(`/conversations/${currentConvo.id}/messages`).then(res => res.json()).then(msgs => setMessages(msgs))},[])

To render each of these messages, we will again use a .map function; however, this time we will simply create a <p> tag including the message body and sender for each message (rather than a unique React element). Again, we will create a local variable called “me” that represents whether or not a conversations’ user_a_name is equal to the current user’s name, and will be used to display the appropriate name at the top of the conversational window.

let me = currentConvo.user_a_name === currentUser.namereturn (<div className="container-sm chatbox"><h1>{me ? currentConvo.user_a_name : currentConvo.user_b_name}</h1>{messages.map(message => <p className={messageClass(message)}>{`${message.sender} said: ${message.body}`}</p>)}<form onSubmit={handleSend}><input type="text" value={newMessage} onChange={handleChange}></input><button type="submit">send</button></form></div>);

The last responsibility of the Chatbox is to send new messages. We will accomplish this by sending a post request to `conversations/${currentConvo.id}/messages`, which will map to the messages#create function we created earlier. We will collect the new message by turning our Chatbox into a controlled form. Once the new message is saved, and a copy of the message is returned from the database, we will add the messages to the messages state, and reset the text entry box.

const [newMessage, setNewMessage]=useState(“”)
function handleChange(e){setNewMessage(e.target.value)function handleSend(e){e.preventDefault()fetch(`conversations/${currentConvo.id}/messages`,{method: 'POST',headers: {'Content-Type': 'application/json'},body: JSON.stringify({body:newMessage,user_id:currentUser.id,conversation_id:currentConvo.id})}).then(res => res.json()).then(newMessage => {setMessages([...messages,newMessage])setNewMessage("")})e.target.reset();}
}

And congratulations! You’ve created a messaging feature using a Rails API back-end and a React front-end!

A brief recap of the broad steps we walked through:

  • Generate models (User, Conversation, Messages) & establish relationships
  • modifying “foreign_key:true” -> “index:true” in migration file
  • Create appropriate show/create/delete actions in controllers
  • Create front-end components (Inbox, ChatSummary, Chatbox)
  • Connect front-end and backend components (through fetch requests to appropriate routes)

Additional Resources:

--

--