[React + Node.js]: Create Messenger App with MERN Stack + Socket.io
React
Node.js
04/22/2020
      
   
    
Overview
This post explains how to build a basic real-time messenger application with MERN (MongoDB, Express.js, React, Node.js) Stack + Socket.io.
Demo
Left and right screens are to demonstrate different clients by using differnet browsers.
Try it yourself here

Prerequisite
If you don't have a MERN Stack application set up, here is a starter that you can clone.
clone
git clone https://github.com/EllisMin/mern-custom.git ~/YOUR_FOLDER/messenger-appInstall dependencies (Server & Client)
cd ~/YOUR_FOLDER/messenger-app && npm run install-bothInstall Extra dependencies needed for this project
- Front-end: React Bootstrap | Socket.io Client | Fontawesome BASHnpm i react-bootstrap bootstrap socket.io-client react-timeago @fortawesome/fontawesome-svg-core @fortawesome/react-fontawesome @fortawesome/free-solid-svg-icons
- Back-end: Socket.io | GeoIP-lite | express-useragent | express-validator | express-rate-limit BASHnpm i socket.io geoip-lite express-useragent express-validator express-rate-limit
- Both at once BASHnpm i react-bootstrap bootstrap socket.io-client react-timeago @fortawesome/fontawesome-svg-core @fortawesome/react-fontawesome @fortawesome/free-solid-svg-icons socket.io geoip-lite express-useragent express-validator express-rate-limit
Server side
MongoDB URI
Update the MongoDB URI to yours
module.exports = {  MONGODB_URI: `mongodb+srv://<DB_USERNAME>:<PASSWORD>@cluster0-abcde.mongodb.net/test`,}Socket util
This fils is used to share socket io instance across different files.
let iomodule.exports = {  init: httpServer => {    io = require('socket.io')(httpServer)    return io  },  getIO: () => {    if (!io) {      throw new Error('Socket.io is not initialized')    }    return io  },}Create message schema
I'll allow a public user or a visitor to join the messenger. So, each message on client side will display partial IP address i.e. (11.222) as well as user's current time zone. i.e. America/Chicago with the help of geoip-lite package.
To limit the number of post request from the public users, ip, platform, and os will be used to check for the same device under same ip address. Checking the same ip may not be sufficient as multiple devices may be used under the same external ip addresses. Although this is not a perfect solution, I've decided to check for the platform, i.e. Apple Mac and os, i.e. macOS Mojave provided with express-useragent package.
const mongoose = require('mongoose')const Schema = mongoose.Schema
const messageSchema = new Schema(  {    ip: String,    agent: {      platform: String,      os: String,    },    partialIp: String,    timezone: String,    message: {      type: String,      required: true,    },  },  { timestamps: true })
module.exports = mongoose.model('Message', messageSchema)Create controller
Imports
const geoip = require('geoip-lite')const io = require('../utils/socket')const Message = require('../models/message')Get messages
This method retrieves the latest 10 messages and send to the client.
exports.getMessages = async (req, res, next) => {  try {    // Get last(latest) 10 messages    const messages = await Message.find()      .select('-updatedAt -__v -_id')      .sort({ createdAt: -1 })      .limit(10)
    if (!messages) {      const error = new Error('Failed to fetch messages')      error.statusCode = 404      throw error    }
    res.status(200).json({      message: 'Fetched messages',      messages: messages,    })  } catch (err) {    if (!err.statusCode) {      err.statusCode = 500    }    next(err)  }}Post message
When a client requests to post a message, this function looks up how many messages user has posted for the past 3 minutes to limit the request. Also it removes the oldest message from database when it reaches limit.
exports.postMessage = async (req, res, next) => {  // Retrieve IP from the header  let ip = req.headers['x-forwarded-for'] || req.connection.remoteAddress  if (ip.includes(',')) {    ip = ip.substring(0, ip.indexOf(','))  }
  // Retrieve partial IP from ip. i.e. 111.22  let partialIp = ''  const splittedIps = ip.split('.')  if (splittedIps.length < 1) {  } else if (splittedIps.length === 1) {    partialIp = splittedIps[0]  } else {    partialIp = `${splittedIps[0]}.${splittedIps[1]}`  }
  try {    // Get timezone from ip address. When not found, set default to prevent error    let geo = geoip.lookup(ip)    if (!geo) {      geo = { timezone: '', agent: { isBot: false } }    }
    const message = req.body.message
    if (!message) {      const error = new Error('Failed to get message')      error.statusCode = 404      throw error    }
    // Bot check    if (geo.agent && geo.agent.isBot) {      const error = new Error('Failed to post')      error.statusCode = 400      throw error    }
    // Query messages created last 3 minutes to check for # request limit    const recentMessages = await Message.find({      createdAt: { $gt: new Date(Date.now() - 3 * 60 * 1000) },    })
    let ipMatchCount = 0    let platformMatchCount = 0
    // Check if there are multiple entries with same ip & device    recentMessages.forEach(m => {      if (m.ip === ip) {        ipMatchCount++        if (          m.agent &&          m.agent.platform === req.useragent.platform &&          m.agent.os === req.useragent.os        ) {          platformMatchCount++        }      }    })
    // Limit requests with same ip and device to 10 or same ip to 20    if (      (ipMatchCount >= 10 && platformMatchCount >= 10) ||      ipMatchCount >= 20    ) {      const error = new Error('Message limit reached')      error.statusCode = 400      throw error    }
    /* Optional: When number of messages reaches the limit (50), it removes the oldest message */    const totalMessages = await Message.find().countDocuments()    if (totalMessages >= 50) {      const oldestMessage = await Message.find()        .sort({ createdAt: 1 })        .limit(1)
      if (!oldestMessage) {        const error = new Error("Couldn't find message to remove")        error.statusCode = 404        throw error      }
      await Message.findByIdAndRemove(oldestMessage[0]._id)    }
    const messageDoc = new Message({      ip: ip,      agent: {        isBot: req.useragent.isBot,        platform: req.useragent.platform,        os: req.useragent.os,      },      partialIp: partialIp,      timezone: geo.timezone,      message: message,    })    await messageDoc.save()
    // Object to pass to socket.io client    const message_ = new Message({      partialIp: messageDoc.partialIp,      timezone: geo.timezone,      message: message,      createdAt: messageDoc.createdAt,    })
    // Sends message to all connected users    io.getIO().emit('message event', {      action: 'add',      message: { ...message_._doc },    })
    res.status(201).json({      message: 'Message created',    })  } catch (err) {    if (!err.statusCode) {      err.statusCode = 500    }    next(err)  }}Create route
express-validator validates the input passed in req.body. Character limit is between 1 and 100.
const express = require('express')const { body } = require('express-validator')const router = express.Router()const messageController = require('../controllers/message')
// GET /messagesrouter.get('/messages', messageController.getMessages)
// POST /messagerouter.post(  '/message',  [    body('message')      .trim()      .isLength({ min: 1, max: 100 }),  ],  messageController.postMessage)
module.exports = routerCreate controller for socket.io
exports.setUpIOConnection = server => {  const io = require('../utils/socket').init(server)  const users = []
  io.on('connection', async socket => {    users.push({ id: socket.id })
    io.emit('init', { users: users })
    // Handle disconnect    socket.on('disconnect', reason => {      // Remove user      let index = -1      for (let i = 0; i < users.length; i++) {        const user = users[i]        if (user.id === socket.id) {          index = i        }      }      // Remove user      if (index !== -1) {        users.splice(index, 1)      }      io.emit('init', { users: users })    })  })}app.js
- useragent: used to parses user-agent inside header to retrieve- platformand- osinformation. It will be accessible in- req.useragentwhich is used in controller
- rateLimit: used to limit number of requests
require('dotenv').config()const path = require('path')const express = require('express')const mongoose = require('mongoose')const useragent = require('express-useragent') const rateLimit = require('express-rate-limit') const { MONGODB_URI } = require('./config')const messageRoutes = require('./routes/message') const { setUpIOConnection } = require('./controllers/socket') 
const app = express()app.use(express.json())
app.use(useragent.express()) // Set up rate limiter for entire app requestsconst appLimiter = rateLimit({  windowMs: 15 * 60 * 1000, // 15 minutes  max: 150, // limit each IP # requests per windowMs})app.use(appLimiter)
// Serves mern-demo/client/build staticallyapp.use(express.static(path.join('client', 'build')))
// Set CORS headerapp.use((req, res, next) => {  res.setHeader('Access-control-Allow-Origin', '*')  res.setHeader(    'Access-Control-Allow-Methods',    'GET, POST, DELETE, PUT, OPTIONS'  )  res.setHeader('Set-Cookie', 'HttpOnly;Secure;SameSite=Strict')  // Allow client to set headers with Content-Type  res.setHeader('Access-Control-Allow-Headers', 'Content-Type')  next()})
// Register routesapp.use(messageRoutes)
app.use((req, res, next) => {  res.sendFile(path.resolve(__dirname, 'client', 'build', 'index.html'))})
// Error Handlerapp.use((error, req, res, next) => {  const status = error.statusCode || 500  const message = error.message  const data = error.data // Passing original error data  res.status(status).json({ message: message, data: data })})
// DB connectionmongoose  .connect(MONGODB_URI, {    useNewUrlParser: true,    useUnifiedTopology: true,    useFindAndModify: false,  })  .then(result => {    const port = process.env.PORT || 8080    const server = app.listen(port, () => {      console.log(`Listening on port ${port}...`)    })    setUpIOConnection(server)  })  .catch(err => {    // Handle error  })Client side – Creating views
Create Chat directory under Components as well as children files:
.└── client    └── src        └── Components            ├── Chat            |   ├── ChatIcon            |   |   └── index.js            |   |       └── styles.scss            |   ├── ChatModal            |   |   ├── index.js            |   |   └── styles.scss            |   └── index.js            └── ...Set default exports for ChatIcon and ChatModal inside Chat/index.js
export { default as ChatIcon } from './ChatIcon'export { default as ChatModal } from './ChatModal'ChatIcon
In ChatIcon component, userCount and toggleShowChat will be passed down from App component later
import React from 'react'import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'import { faCommentDots } from '@fortawesome/free-solid-svg-icons'
import './styles.scss'
const ChatIcon = ({ userCount, toggleShowChat }) => {  return (    <div className="chat-icon-container" onClick={toggleShowChat}>      <FontAwesomeIcon        className="icon-fa icon-comment-dots"        icon={faCommentDots}      />      <span className="chat-icon-user-count disable-selection">        {`chat(${userCount})`}      </span>    </div>  )}
export default ChatIconAdd ChatIcon Styling
.chat-icon-container {  cursor: pointer;  position: fixed;  bottom: 0;  right: 0;  z-index: 1;  margin: 1rem;  padding: 1rem;  border-radius: 50%;  background: #fff;  box-shadow: 0 2px 6px #ccc;
  &:hover {    .icon-comment-dots {      opacity: 1;    }  }
  .icon-comment-dots {    color: #ea4334;    opacity: 0.8;    width: 25px;    height: 25px;  }
  .chat-icon-user-count {    position: absolute;    bottom: 3px;    left: 50%;    transform: translateX(-50%);    font-size: 0.7rem;    color: #333;  }}App
Import ChatIcon inside App and create state to toggle showing/hiding the chat
import React, { useState } from 'react' import Header from '../Header'import { ChatIcon, ChatModal } from '../Chat' 
import './styles.scss'
const App = () => {  const [showChat, setShowChat] = useState(false)  const toggleShowChat = () => {    setShowChat(!showChat)  }  return (    <div className="App">      <Header title="Messenger App" />      <ChatIcon toggleShowChat={toggleShowChat} />    </div>  )}export default AppIn App/styles.scss, add relative position and import bootstrap
@import '~bootstrap/scss/bootstrap'; @import '../Shared/config';@import '../Shared/util';
.App {  position: relative;   text-align: center;}At this point, your app should have a fixed positioned chat icon available on your app
ChatModal
import React, { useState, useRef, useEffect } from 'react'import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'import {  faWindowMinimize,  faEllipsisH,} from '@fortawesome/free-solid-svg-icons'import { InputGroup, FormControl, Button as Btn, Form } from 'react-bootstrap'import TimeAgo from 'react-timeago'import { MESSAGE } from '../../../constants'import Button from '../../Button'import Alert from '../../Alert'
import './styles.scss'
const ChatModal = ({ toggleShowChat, userCount, messages }) => {  const [sendLoading, setSendLoading] = useState(false)  const [message, setMessage] = useState('')  const [error, setError] = useState(undefined)  const scrollDivRef = useRef(null)  const inputRef = useRef(null)
  const scrollToBottom = () => {    scrollDivRef.current.scrollIntoView({ behavior: 'auto' })  }
  // Scroll to bottom when messages change  useEffect(scrollToBottom, [messages])
  const handleChange = e => {    const value = e.target.value    const rowCount = value.split('\n').length
    // Character limit & row limit    if (      value.length <= MESSAGE.CHAR_LIMIT &&      rowCount <= MESSAGE.TEXTAREA_MAX_ROWS &&      value !== '\n'    ) {      setMessage(value)    }  }
  // Submit on enter in textarea. Shift + enter still valid to type newline  const handleKeyPress = e => {    // Submit if enter was pressed without shift    if (e.key === `Enter` && !e.shiftKey) {      e.preventDefault() // Prevents newline      handleSubmit(e)    }  }
  const handleSubmit = async e => {    e.preventDefault()
    if (message.trim().length > 0 && message.length <= MESSAGE.CHAR_LIMIT) {      setSendLoading(true)      try {        const res = await fetch(process.env.REACT_APP_FETCH_URL + '/message', {          method: 'POST',          headers: {            'Content-Type': 'application/json',          },          body: JSON.stringify({            message: message,          }),        })        if (res.status === 400) {          throw new Error("You're typing too fast! Please try again soon")        }        if (res.status !== 200 && res.status !== 201) {          throw new Error('Failed to post')        }        await res.json()        setMessage('') // Clear input field        setSendLoading(false)      } catch (err) {        setError(err)        setMessage('')        setSendLoading(false)      }    }  }
  return (    <div className="chat-modal">      <div className="chat-main">        <div className="chat-delay-show">          <header className="chat-header">            <div className="icon-container">              <Button disabled iconBtn>                <FontAwesomeIcon                  className={`icon-ellipsis-h`}                  icon={faEllipsisH}                />              </Button>            </div>            <h1 className="chat-header-text">{`Chat with others! (${userCount})`}</h1>            <div className="icon-container">              <Button iconBtn onClick={toggleShowChat}>                <FontAwesomeIcon                  className={`icon-window-minimize`}                  icon={faWindowMinimize}                />              </Button>            </div>          </header>          <div className="chat-content">            <div className="chat-content-inner">              <Alert error={error} onClose={() => setError(undefined)} />              {messages.length > 0 ? (                messages.map((m, i) => (                  <div key={i} className="message-container">                    <div className="message-sender">                      <span className="message-sender-location">                        {`${m.partialIp}${                          m.timezone ? ` (${m.timezone})` : ``                        }`}                      </span>                      <span className="message-sender-time">                        <TimeAgo date={m.createdAt} />                      </span>                    </div>                    <div className="message-block">                      <span>{m.message}</span>                    </div>                  </div>                ))              ) : (                <Loader />              )}              <div ref={scrollDivRef} />            </div>          </div>          <div className="chat-input-container" onSubmit={handleSubmit}>            <Form>              <InputGroup className="mb-2" size="sm">                <FormControl                  required                  as="textarea"                  rows={MESSAGE.TEXTAREA_MAX_ROWS}                  maxLength={MESSAGE.CHAR_LIMIT}                  placeholder="Text Message"                  aria-label="Text Message"                  aria-describedby="basic-addon2"                  value={message}                  onChange={handleChange}                  onKeyPress={handleKeyPress}                  ref={inputRef}                />                <InputGroup.Append>                  <Btn                    variant="outline-secondary"                    type="submit"                    disabled={sendLoading}                  >                    Send                  </Btn>                </InputGroup.Append>              </InputGroup>            </Form>          </div>        </div>      </div>    </div>  )}
export default ChatModal{ MESSAGE } comes from the following file. It specifies the character limit (for the client side!) and the maximum number of rows for the textarea.
export const MESSAGE = {  CHAR_LIMIT: 100,  TEXTAREA_MAX_ROWS: 5,}Add styling for ChatModal
$chat-width: 320px;$chat-height: 620px;
.chat-modal {  position: fixed;  bottom: 0;  right: 0;  z-index: 2;  padding: 1rem;
  .chat-main {    width: $chat-width;    height: $chat-height;    background: #f7f7f7;    border-radius: 10px;    box-shadow: 0 2px 6px #aaa;    animation: expand 250ms ease-in;
    .chat-delay-show {      position: relative;      display: flex;      flex-direction: column;      overflow: hidden;      height: 100%;      opacity: 0;      animation: delayShow 200ms forwards 350ms;    }    .chat-header {      display: flex;      justify-content: space-between;      align-items: center;      background: #fff;      border-top-left-radius: 10px;      border-top-right-radius: 10px;      padding: 0.3rem 1rem;
      &-text {        font-size: 1rem;        color: #333;        margin: 0;      }
      .icon-container {        .icon-window-minimize {          color: #333;          opacity: 0.7;          &:hover {            opacity: 1;          }        }
        .icon-ellipsis-h {          color: #ddd;        }      }    }
    .chat-content {      display: block;      position: relative;      height: 100%;      white-space: pre-wrap;      word-wrap: break-word;
      .chat-content-inner {        position: absolute;        top: 0;        left: 0;        right: 0;        bottom: 0;        padding: 0.2rem;        height: 100%;        overflow-y: auto;        background: transparent;        overscroll-behavior: contain;
        .message-container {          display: flex;          flex-direction: column;          border-top: 1px solid #ddd;          padding: 0.5rem;          .message-sender {            display: flex;            justify-content: space-between;            span {              font-size: 0.7rem;              text-align: left;              margin-bottom: 0.1rem;              margin-left: 0.2rem;              color: #555;            }          }          .message-block {            padding: 0.3rem 0.6rem;            background: #fff;            border: 1px solid #eee;            color: #333;            border-radius: 13px;            font-size: 0.8rem;            text-align: left;            animation: fadeInMessage 800ms ease-in;          }        }
        .alert-custom {          position: sticky;          top: 0;        }      }    }
    .chat-input-container {      background: #fbfaff;      padding: 0.3rem;      .btn-outline-secondary {        border-color: #cbc7e6;      }      .btn:hover {        background: #e7f2ff;        color: #333;      }
      .form-control {        font-size: 16px !important;      }      textarea {        resize: none;        height: 40px;      }    }  }}
@keyframes expand {  from {    width: 0px;    height: 0px;  }  to {    width: $chat-width;    height: $chat-height;  }}
@keyframes delayShow {  to {    opacity: 1;  }}
@keyframes fadeInMessage {  from {    opacity: 0.2;    background: #aebaeb;  }  to {    opacity: 1;    background: #fff;  }}Back to App
import React, { useState, useEffect } from 'react'import openSocket from 'socket.io-client'import Alert from '../Alert'import Header from '../Header'import { ChatIcon, ChatModal } from '../Chat'
import './styles.scss'
const App = () => {  const [showChat, setShowChat] = useState(false)  const toggleShowChat = () => setShowChat(!showChat)  const [publicUsers, setPublicUsers] = useState([])  const [messages, setMessages] = useState([])  const [error, setError] = useState(null)
  useEffect(() => {    createSocketConnection()    fetchMessages()  }, [])
  const createSocketConnection = () => {    const socket = openSocket(process.env.REACT_APP_FETCH_URL)    // init    socket.on('init', data => {      setPublicUsers(data.users)    })    // Message events    socket.on('message event', data => {      if (data.action === 'add') {        setMessages(prevMessages => [...prevMessages, data.message])      }    })  }
  const fetchMessages = async () => {    try {      const res = await fetch(process.env.REACT_APP_FETCH_URL + '/messages')      if (res.status !== 200) {        throw new Error('Failed to fetch messages')      }      const resData = await res.json()      // Set reversed 10 messages      setMessages(resData.messages.reverse())    } catch (err) {      setError(err)    }  }
  return (    <div className="App">      <Header title="Messenger App" />      <Alert error={error} onClose={() => setError(null)} />      {showChat ? (        <ChatModal          toggleShowChat={toggleShowChat}          userCount={publicUsers.length}          messages={messages}        />      ) : (        <ChatIcon          toggleShowChat={toggleShowChat}          userCount={publicUsers.length}        />      )}    </div>  )}
export default AppPlaying sound when sending or receiving message
On desktop, setting const [audio] = useState(new Audio(popSoundSrc)) and audio.play() are just enough to play sound; however, iOS prevents JavaScript play() and load() methods. In other words, user must interact with the website first i.e. clicking a button before audio gets played. In this example, we'll play muted audio on clicking chat icon to work around this problem. Read more about how iOS handles HTML5 Audio and Video
// ...
import popSoundSrc from "../../assets/pop.mp3"let audioFirstPlayed = false
const App = () => {  // ...  const [audio] = useState(new Audio(popSoundSrc)) 
  // ...
  const createSocketConnection = () => {    const socket = openSocket(process.env.REACT_APP_FETCH_URL)    // init    socket.on("init", data => {      setPublicUsers(data.users)    })
    // Message events    socket.on("message event", data => {      if (data.action === "add") {        setMessages(prevMessages => [...prevMessages, data.message])        if (audio.muted) {          audio.muted = false        }        audio.play()      }    })  }
  const toggleShowChat = () => {    if (!audioFirstPlayed) {      audio.muted = true      audio.play()      audioFirstPlayed = true    }    setShowChat(!showChat)  }
  return ( ... )}
export default AppPossible enhancements from here
- Queue sending multiple messages
- [Git + cron]: Schedule your git commands on AWS EC2
- [React]: Use global state with React Hook and Context