Socketio React Chat app
Tags: react, node, reactnative, socketio
Published: 2022-07-23 09:12:00





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 ](https://github.com/tigawanna/chat)
[client code init commit](https://github.com/tigawanna/sockets-client/tree/4cac547478905de92727d26948edbed0a1adcf3a)

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](https://tigawanna.github.io/sockets-client/)

to get started run, 

```js
npm init vite
```
and follow the instructions 

dependancies are 
```js
npm install axios react-icons socket.io-client
```


i used tailwindcss for this one feel free to skip and implement your own styles, [adding tailwind to a react app](https://tailwindcss.com/docs/guides/create-react-app) 

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

so exit the react folder and run 

```js 
npm init -y 
```

then 

```js
npm install -D typescript
```
you can use your own custom tsconfig file or use this one that works for me 

```js
{
  "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"
  ]

}
```

then add the scripts to your package.json 

```js
  "scripts": {
    "watch": "tsc -w",
    "start": "nodemon dist/index.js",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
```
create an src directory and add an index.ts file in it.

then install

```js
npm install cors express body-parser nodemon socket.io
```

then create a simple server in index.ts

```js
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)
)
```
there's also a user helper functions file


```js
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
```

```js
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
     }  
```


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 

```js 
npm run watch
npm start
```

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/b3wsgd9vmur6pwgzz4vy.png)
the common types will look something like this

```ts
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;
}
```


inside context.ts we create the UserContext 
```js
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;
```

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

```js
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
```

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 

```js
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;
```
for the custom tailwind classes add this to the index.css


```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
}

```

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

```js
npm install -D tailwind-scrollbar
```

then add it to your tailwind.config.js

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

the loading component
```jsx
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>
);
}
```

the JoinRoom.tsx which will be shown if no user found in local storage , 
```js
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>
);
}
``` 

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

```js
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>
);
}
```
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

```js
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>
  );
};
```
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](https://github.com/tigawanna/sockets-client)
[final server code ](https://github.com/tigawanna/sockets-server)
[react native code] (https://github.com/tigawanna/sockets-rn)