DEV Community

Cover image for Socketio React Chat app
Dennis kinuthia
Dennis kinuthia

Posted on

Socketio React Chat app

I had a full stack chat app powered by graphql with react front end and express backend.
It worked beautifully but issues arose when i tried to host backend with heroku , something about them only supporting a specific type of websockets
chat app with graphql subscriptions
client code init commit

One of the listed supported ones was socketio , so i tried it out
for the front end we'll be using react with vite because create-react-app has some deprecated webpack related libraries that are causing vulnerability warnings

My objective with this was to make it as minimal as possible to make it easier to host , so no database will be used
live priview of the chat app

to get started run,

npm init vite
Enter fullscreen mode Exit fullscreen mode

and follow the instructions

dependancies are

npm install axios react-icons socket.io-client
Enter fullscreen mode Exit fullscreen mode

i used tailwindcss for this one feel free to skip and implement your own styles, adding tailwind to a react app

in oredr to get started we'll first need to mockup the server.

so exit the react folder and run

npm init -y 
Enter fullscreen mode Exit fullscreen mode

then

npm install -D typescript
Enter fullscreen mode Exit fullscreen mode

you can use your own custom tsconfig file or use this one that works for me

{
  "compilerOptions": {
    "target": "es2017",
    "module": "commonjs",
    "lib": ["dom", "es6", "es2017", "esnext.asynciterable"],
    "skipLibCheck": true,
    "sourceMap": true,
    "outDir": "./dist",
    "moduleResolution": "node",
    "removeComments": true,
    "noImplicitAny": false,
    "strictNullChecks": true,
    "strictFunctionTypes": true,
    "noImplicitThis": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "allowSyntheticDefaultImports": true,
    "esModuleInterop": true,
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "resolveJsonModule": true,
    "baseUrl": "."


  },
  "include": [
    "src/**/*"
  ],
  "exclude": [
    "node_modules"
  ]

}
Enter fullscreen mode Exit fullscreen mode

then add the scripts to your package.json

  "scripts": {
    "watch": "tsc -w",
    "start": "nodemon dist/index.js",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
Enter fullscreen mode Exit fullscreen mode

create an src directory and add an index.ts file in it.

then install

npm install cors express body-parser nodemon socket.io
Enter fullscreen mode Exit fullscreen mode

then create a simple server in index.ts

import express,{ Request,Response } from "express";
import { Server } from "socket.io";
import { createServer } from "http";
import { addUser, checkUserNameExists,removeUser} from './utils/usersutil';
import { makeTimeStamp } from './utils/utils';

// const express = require('express');

const cors = require('cors');
const bodyParser = require('body-parser')





const app = express();

const PORT = process.env.PORT||4000
const server = createServer(app);
// const httpServer = createServer(app);
var jsonParser = bodyParser.json()
 // create application/x-www-form-urlencoded parser
// var urlencodedParser = bodyParser.urlencoded({ extended: false })

const io = new Server(server,{ 
  cors: {
    origin: "http://localhost:3000",
    credentials: true,
    allowedHeaders: ["my-custom-header"],

}
});

(async () => {

app.use(cors())
app.options('*', cors());

app.get('/', (req:Request, res:Response) => {
  res.send({ message: "We did it!" })
});

app.get('/me', (req:Request, res:Response) => {
  res.send({ message: "smoosh" })
});

app.post('/users',jsonParser,(req:Request, res:Response) => {

  const user = req.body?.user.username
  // //console.log("looking for ===== ",user)
 const userExists = checkUserNameExists(user)
 //console.log("looking for ===== ",user, userExists)
  res.send({data:userExists})
});



io.on("connection", async(socket) => {



//console.log(`Client ${socket.id} connected`);


  // Join a conversation
  const { room,user } = socket.handshake.query;
  const room_id = room as string
 const user_count =  addUser({id:socket.id,name:user,room})
  // //console.log("room  id / user==== ",room_id,user,user_count)
  socket.join(room_id);
  io.in(room_id).emit('room_data', {room,users:user_count});
  const join_room_message={message:`${user} joined`, time:makeTimeStamp(), user:"server" }
  io.in(room_id).emit('new_message_added', { user,newMessage:join_room_message});




socket.on('new_message', (newMessage) => {
 //console.log("new message ",newMessage,room_id)
 const user = newMessage.user
      //@ts-ignore
  io.in(room_id).emit('new_message_added', { user: user?.name,newMessage});

 })




socket.on("disconnect", () => {
    //console.log("User Disconnected new user count ====", socket.id,user_count);
    removeUser(socket.id)
    io.in(room_id).emit('room_data', {room: room_id,users:user_count - 1 });
    const join_room_message={message:`${user} left`, time:makeTimeStamp(), user:"server" }
    io.in(room_id).emit('new_message_added', { user,newMessage:join_room_message});
  });
});




server.listen(PORT, () => {
  console.log(`listening on  http://localhost:${PORT}`)
});

})().catch(e=> console.log('error on server ====== ',e)
)
Enter fullscreen mode Exit fullscreen mode

there's also a user helper functions file

interface User{
 id:string
 name:string
 room:string   
}
const users:User[] = [];

const userExists=(users:User[],name:string)=>{
let status = false    
for(let i = 0;i<users.length;i++){
    if(users[i].name===name){
    status = true;
    break
    }
}
return status;
}

//console.log("all users in list=== ",users)


export const addUser = ({id, name, room}) => {
    name = name?.trim().toLowerCase();
    room = room?.trim().toLowerCase();


    //console.log("user to add ==== ",name)

   const existingUser = userExists(users,name)
    //console.log("existing user????====",existingUser)
    if(existingUser) {
    //console.log("existing user")
    return users.length;
    }else{
    const user = {id,name,room};
    //console.log("adding user === ",user)
    users.push(user);
    //console.log("all users === ",users)
    const count = getUsersInRoom(room).length
    return count
    }


}




const userExistsIndex=(users:User[],id:string)=>{
    let status = -1   
    for(let i = 0;i<users.length;i++){
        if(users[i].id === id){
        status = i;
        break
        }
    }
    return status;
    }

    export const checkUserNameExists=(name:string)=>{
        let status = false   
        for(let i = 0;i<users.length;i++){
            if(users[i].name === name){
            status = true
            break
            }
    }
     return status;
        }    

export const removeUser = (id:string) => {
    // const index = users.findIndex((user) => {
    //     user.id === id
    // });
    const index = userExistsIndex(users,id)
    //console.log(index)
    if(index !== -1) {
    //console.log("user ",users[index].name ,"disconected , removing them")
    return users.splice(index,1)[0];
    }

}


export const getUser = (id:string) => users .find((user) => user.id === id);

export const getUsersInRoom = (room:string) => users.filter((user) => user.room === room);

export const userCount =()=>users.length
Enter fullscreen mode Exit fullscreen mode
export const makeTimeStamp=()=>{

    const hour = new Date(Date.now()).getHours()    
    const min = new Date(Date.now()).getMinutes()
    const sec =  new Date(Date.now()).getSeconds()

    let mins=  min+''
    let secs=':'+sec
     if(min<10){
     mins = '0'+ min
     }

     if(sec<10){
     secs = '0' + sec
     }

     return hour+':'+ mins + secs
     }  
Enter fullscreen mode Exit fullscreen mode

It's a regular node js express server that connects and listens for a socket connection then it adds the user to a temporary list and returns a room name and number of users in it ,

there's a post /users rest endpoint which would normally be used to authenticate and add user to a database but in this case it’ll just check if the username provided is already in use which causes weird issues when two people use the same names in the same room.

The socket io instance will also be listening for new messages emitted by the clients and broadcasting them to everyone in the room.

finally it listens for disconnections in which case it removes the username from the temporary list and informs everyone in the room

To watch for changes in the index.ts folder and compile it to dist/index.js and run the server .
use the two commands below each in their own terminal instance

npm run watch
npm start
Enter fullscreen mode Exit fullscreen mode

and let's head back to the client,

Remember we're trying to keepthis minimal So
most of the stateful logic will be held in the front end .
For starters we need to ensure the user has a username and room before they enter the chats components which is where we bring in reactcontext and localStorage

my final folder structure looks like this

my folder structure
the common types will look something like this

export type Chat ={ newMessage:{message: string; time: string,user:string }};
export interface User{username:string ; room:string}
export interface Room{users:number ; room:string}
export interface Message{
  message: string;
  time: string;
  user: string;
}
Enter fullscreen mode Exit fullscreen mode

inside context.ts we create the UserContext

mport React from 'react';
import { User } from './../App';

const user_data = { user:{username:"",room:"general"}, updateUser:(user:User)=>{}}


const UserContext = React.createContext(user_data);
export default UserContext;
Enter fullscreen mode Exit fullscreen mode

then we'll make a custom hook to handle the socketio client

import { Room, User } from "./types"
import { useRef,useState,useEffect } from 'react';
import socketIOClient,{ Socket } from 'socket.io-client';

const NEW_MESSAGE_ADDAED = "new_message_added";
const ROOM_DATA = "room_data";

const devUrl="http://localhost:4000"



const useChats=(user:User)=>{

const socketRef = useRef<Socket>();
const [messages, setMessages] = useState<any>([]);
const [room, setRoom] = useState<Room>({users:0,room:""});


useEffect(() => {
socketRef.current = socketIOClient(devUrl, {
query: { room:user.room,user:user.username },
transports: ["websocket"],
withCredentials: true,
extraHeaders:{"my-custom-header": "abcd"}

})


socketRef.current?.on(NEW_MESSAGE_ADDAED, (msg:any) => {
// //console.log("new message  added==== ",msg)
setMessages((prev: any) => [msg,...prev]);
 });

socketRef.current?.on(ROOM_DATA, (msg:any) => {
//console.log("room data  ==== ",msg)
 setRoom(msg)});

return () => {socketRef.current?.disconnect()};
}, [])

const sendMessage = (message:any) => {
//console.log("sending message ..... === ",message)
 socketRef.current?.emit("new_message", message)
};




return {room,messages,sendMessage}
}
export default useChats
Enter fullscreen mode Exit fullscreen mode

we store the socket instance in a useRef because we don't want it re-initializing on every re-render , then we put it in a useEffect to only initialize on component mount we return the new messages,room data , and a sendmessage function

in order to keep this miniman i avoided routing and just did optional rendering because we'll only have two major components, we initially check if there's a user in the local storage and show the JoinRoom.tsx component if not

import { JoinRoom } from './components/JoinRoom';
import React from 'react'
import UserContext from "./utils/context";
import { Chats } from './components/Chats';


export interface User{username:string ; room:string}

let the_user:User
const user_room= localStorage.getItem("user-room");
if(user_room)
the_user = JSON.parse(user_room); 

function App() {

  const [user, setUser] = React.useState<User>(the_user);
  const updateUser = (new_user:User) => {setUser(new_user)};
  const user_exists = user && user.username !==""



return (
<div className="scroll-bar flex flex-col justify-between h-screen w-screen ">

<UserContext.Provider  value ={{user,updateUser}}>
{user_exists?<Chats />:<JoinRoom/>}  
</UserContext.Provider>

  </div>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

for the custom tailwind classes add this to the index.css

@tailwind base;
@tailwind components;
@tailwind utilities;


body {
  margin: 0;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
    'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
    sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

code {
  font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
    monospace;
}

.flex-center{
  @apply flex justify-center items-center
}

.flex-center-col{
  @apply flex flex-col justify-center items-center
}
.scroll-bar{
  @apply scrollbar-thin scrollbar-thumb-purple-900 scrollbar-track-gray-400
}

Enter fullscreen mode Exit fullscreen mode

the scrollbar class is a tailwind extension you can install and add to your tailwind config

npm install -D tailwind-scrollbar
Enter fullscreen mode Exit fullscreen mode

then add it to your tailwind.config.js

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    "./src/**/*.{js,jsx,ts,tsx}",
  ],
  theme: {
    extend: {},
  },
  plugins: [
    require('tailwind-scrollbar'),
  ],
}
Enter fullscreen mode Exit fullscreen mode

the loading component

import React from 'react'

interface LoadingProps {

}

export const Loading: React.FC<LoadingProps> = ({}) => {
return (
 <div className='h-full w-full flex-center bg-slate-300 text-lg'>
 loading ...
 </div>
);
}
Enter fullscreen mode Exit fullscreen mode

the JoinRoom.tsx which will be shown if no user found in local storage ,

import React, {useContext, useState} from "react";
import UserContext from './../utils/context';
import axios from 'axios';


interface JoinRoomProps {


}

export const JoinRoom: React.FC<JoinRoomProps> = () => {

const [input, setInput] = useState({ username: "", room: "general" });
const [error, setError] = useState({ name:"", message:"" });


const devUrl="http://localhost:4000"




const client = axios.create({ baseURL:devUrl});
const user = useContext(UserContext);
//console.log("JoinRoom.tsx  user ==== ",user.user)

const handleChange = async (e: any) => {
        const { value } = e.target;
        setInput({
          ...input,
          [e.target.id]: value,
        });

      };


  const handleSubmit = async (e: any) => {
  //console.log("inputon submit ==== ",input)
  e.preventDefault();


  if(input.username !== ""){
  const roomname = input.room.toLowerCase()
  const username = input.username.toLowerCase()
  const room_data = {username,room:roomname}
  // localStorage.setItem("user-room",JSON.stringify(room_data));
  // user.updateUser(room_data)

  client.post('/users', {user:room_data})
  .then( (response)=> {
  const user_exist =response.data.data
  //console.log("user exists? === ",user_exist)

  if(!user_exist){

  localStorage.setItem("user-room",JSON.stringify(room_data));
  user.updateUser(room_data)  
  }
  else{
    setError({name:"username",message:"username exists"})
  }
  })
  .catch(function (error) {
    //console.log("error logging in === ",error)
    setError({name:"username",message:"connection error"})
  });


  }
  else{
  setError({name:"username",message:"nick name needed"})
  }

  };   
  const isError=()=>{
  if(error.name === "") return false
  return true}


return (
 <div className="h-full w-full flex-center-col bg-gradient-to-l from-cyan-900 to-purple-900">
        <form className="w-[95%] md:w-[50%] p-3 bg-slate-700 rounded-lg text-white shadow-lg
         shadow-purple-500 ">
        <div className="flex-center-col">
            <label className="text-lg font-bold">Join</label>
          <input
            style={{borderColor:isError()?"red":""}}
            className="w-[80%] md:w-[80%] p-2 m-1 border-black border rounded-sm bg-black"
            id="username"
            placeholder="nick name"
            onChange={handleChange}
            value={input.username}
          />

         <input
            className="w-[80%] md:w-[80%] p-2 m-1 border-black border rounded-sm bg-black"
            id="room"
            placeholder="room name"
            onChange={handleChange}
            value={input.room}
          />
         {isError()?<div className="text-md p-1m-1 text-red-300">{error.message}</div>:null}
      <button 
      onClick={handleSubmit}
      className="p-2 m-1 w-[30%] bg-purple-800 shadow-md 
       hover:shadow-purple-400 rounded-md">Join</button>
      </div>
      </form>

 </div>
);
}
Enter fullscreen mode Exit fullscreen mode

on submit it will make a post request which will check if the username is currently in use and add the user to local torage and user context if not,

and finally for the chats component
first the toolbar component

import React from 'react'
import {BiExit} from 'react-icons/bi'
import { User } from './../App';

interface ToolbarProps {
room:any
updateUser: (user: User) => void
}

export const Toolbar: React.FC<ToolbarProps> = ({room,updateUser}) => {
return (
 <div className='bg-slate-600 text-white p-1 w-full flex justify-between items-center h-12'>
  <div className='p-2 m-1 text-xl font-bold flex'>
  <div className='p-2  text-xl font-bold '>  {room?.room}</div>

{room.room?<div className='p-1 m-1  text-base font-normal shadow shadow-white hover:shadow-red-400
   flex-center cursor-pointer'
   onClick={()=>{
    localStorage.removeItem('user-room') ;
    updateUser({username:"",room:""})
    }}><BiExit/></div>:null}

    </div>
  <div className='p-2 m-1 font-bold'>{room?.users}{room?.users?" online ":""}</div>
 </div>
);
}
Enter fullscreen mode Exit fullscreen mode

responsible for displaying the room name user count in the room,
it also has a leave room button which will set user to null causing a switch to the joinroom component.

Chats.tsx

import React, { useState, useRef,useEffect,useContext } from "react";
import {AiOutlineSend } from 'react-icons/ai';
import { IconContext } from "react-icons";
import { makeTimeStamp } from './../utils/utils';
import { Toolbar } from './Toolbar';
import { Chat, Message } from './../utils/types';
import UserContext from "../utils/context";
import useChats from "../utils/useChats";
import { Loading } from './Loading';



interface ChatsProps {

}


export const Chats: React.FC<ChatsProps> = ({}) => {


const user = useContext(UserContext);
//console.log("Text.tsx  user ==== ",user.user)

const {room,messages,sendMessage} = useChats(user.user)


const room_loaded = room.users>0 && user.user.username !== ""

console.log("Text.tsx  room ==== ",room)

const [input, setInput] = useState({ message: "", time:makeTimeStamp() });
const [error, setError] = useState({ name:"", message:"" });

 const [size, setSize] = useState({x: window.innerWidth,y: window.innerHeight});
 const updateSize = () =>setSize({x: window.innerWidth,y: window.innerHeight });

 useEffect(() => {
   window.onresize = updateSize
    })

  const handleChange = async (e: any) => {
    const { value } = e.target;
    setInput({
      ...input,
      [e.target.id]: value,
    });
  };

  const handleSubmit = async (e: any) => {
    e.preventDefault();
    // //console.log("submit in form ====",input.message)
    if (input.message !== "" && user.user.username !=="") {
      const message = { message: input.message, time:input.time,user:user.user.username };
      // //console.log("message ====",message)
      sendMessage(message)
      setError({name:"",message:""})
    }else{
      //console.log("error ====",input,user)
      setError({name:"username",message:"type something"})
    }
  };

  const isError=()=>{
    if(error.name === "") return false
    return true}

  if(!room_loaded){
      return <Loading/>
  }
  return (
    <div 
    style={{maxHeight:size.y}}
    className="h-full overflow-x-hidden overflow-y-hiddenflex flex-col justify-between ">

      <div className="fixed top-[0px] w-[100%] z-60">
      <Toolbar room={room}  updateUser={user.updateUser}/>
      </div>
      {/* <div className="fixed top-[10%] right-[40%] p-1 z-60 text-3xl font-bold">{size.y}</div> */}

      <div
        className="mt-10 w-full h-[55vh] md:h-[80vh] flex flex-col-reverse items-center overflow-y-scroll  p-2 scroll-bar"
      >
        {messages &&
          messages.map((chat: Chat, index: number) => {
            return <Chatcard  key={index} chat={chat} user={user.user}/>;
          })}
      </div>

      <form 
      onSubmit={handleSubmit}
      className="w-full p-1 fixed bottom-1 ">
        <div className="flex-center">
          <input
             style={{borderColor:isError()?"red":""}}
            className="w-[80%] md:w-[50%] p-2 m-1 border-black border-2 rounded-sm "
            id="message"
            placeholder="type.."
            onChange={handleChange}
            value={input.message}

            autoComplete={"off"}
          />
          <button type="submit">
            <IconContext.Provider value={{
              size: "30px",
              className: "mx-1",

            }}>
           <AiOutlineSend  /></IconContext.Provider>
               </button>
    </div>

      </form>
    </div>
  );
};

interface ChatCardProps {
  chat: Chat;
  user: { username: string; room: string;}

}

export const Chatcard: React.FC<ChatCardProps> = ({ chat,user }) => {

const isMe = chat.newMessage.user === user.username
  // //console.log("chat in chat card ==== ",chat)
  return (
    <div className="flex-center w-full m-2">

    <div 
    style={{backgroundColor:isMe?"purple":"white",color:isMe?"white":""}}
    className="capitalize p-5 h-6 w-6 text-xl font-bold mr-1 border-2 border-slate-400
    rounded-[50%] flex-center"> {chat?.newMessage.user[0]}</div>

      <div className="w-[80%] h-full border border-slate-800  rounded-md
     m-1 p-2 flex justify-between items-center">

      <div className="max-w-[80%] h-fit break-words whitespace-normal text-mdfont-normal">
        {chat?.newMessage.message}
      </div>

        <div className="w-fit font-medium h-full flex flex-col justify-end items-stretch text-sm ">
        <div className="w-full ">{chat?.newMessage.user}</div>
        <div className="w-full ">{chat?.newMessage.time}</div>
      </div>
    </div>

    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

and that's it .

in my opinion doing it with graphql subscriptions is neater and gives you more control over how the clients connect , so if anyone knows how to make it work on heroku i would love tohear from you.

during the writing of this article i've already noticed a possible bug that would cause a username collision.
if a user successfully joins with username "john" then leaves and closes the browser disconnecting the from the server , if he comes back and another user joined using the username john the client will grab the local storage value which is john and join a room with another user in the room with the same username .
i'll let you tackle that one please send me a pull request if you do or find any other bug .

final client code
final server code
react native code

Top comments (0)