DEV Community

Kana
Kana

Posted on

Full Stack Chat App with Socket.io

Last week, I created a real chat app with PERN Stack, which is PostgreSQL, Express, React and Node.js. In this article, I will explain how to make a full-stack chat app using Socket.io step by step.

This application is similar to Slack or Discord. I'll just explain the essential features and codes in this article. Otherwise, it will be a super long article. You can see all the code from here.
https://github.com/kanatagu/chat-app

Setup

Ensure you installed Node on your local machine. We will use version 20.8.0 or above.

Database

We'll use PostgreSQL, and first of all, you need to install PostgreSQL if you haven't installed it yet.
https://www.postgresql.org/

I was using Postgres.app, but of course, you can download it in any way as long as it works.

Then, let's make a database for this chat app.

  1. Open the Postgres.app
  2. Click postgres
  3. Run this command CREATE DATABASE chat_pp; in the terminal.
    terminal of postgresql

  4. if you see "chat_app" database in your app, then it's made successfully.

chat_app database exists in postgresql app

Server with Express.js

Let's make a server directory under the chat-app directory.

mkdir chat-app
cd chat-app
mkdir server
Enter fullscreen mode Exit fullscreen mode

For now, Install the basic libraries that we will use to set up.

cd server
npm init -y 
npm install express cors dotenv bcrypt
Enter fullscreen mode Exit fullscreen mode

Make index.js file and add codes.

const express = require('express');
const http = require('http');
const cors = require('cors');
const dotenv = require('dotenv');

dotenv.config();

const app = express();
const server = http.createServer(app);
const PORT = process.env.PORT || 3000;

app.use(
  cors({
    origin: 'http://localhost:5173',
    credentials: true,
    allowedHeaders: [
      'set-cookie',
      'Content-Type',
      'Access-Control-Allow-Origin',
      'Access-Control-Allow-Credentials',
    ],
  })
);

app.use(express.json());

app.get('/', async (req, res) => {
  res.send('Hello World');
});

server.listen(PORT, () => console.log(`Server running on port ${PORT}`));
Enter fullscreen mode Exit fullscreen mode

Run the server with npm run dev.
If you access http://localhost:3000 and get Hello World as a response, it's perfect. For Cors, we need to add it to fetch later from the client side (React).

Response of Hello World in Postman

Connect the database and create tables

Now let's connect the database and the server.

Make a pool.js inside of the db directory.
So, the folder structure is like this.

/server
  /src
    /db
       pool.js
Enter fullscreen mode Exit fullscreen mode

In pool.js file, add this setup.

/server/src/db/pool.js

const Pool = require('pg').Pool;
require('dotenv').config();

const pool = new Pool({
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  host: process.env.DB_HOST,
  port: process.env.DB_PORT,
  database: process.env.DB_NAME,
});

module.exports = pool;

Enter fullscreen mode Exit fullscreen mode

So, we need an .env file under the "server" directory.

/server/.env

PORT=3000
DB_USER=[You username] 
DB_PASSWORD=[Your password]
DB_HOST=localhost
DB_PORT=5432
DB_NAME=chat_app
Enter fullscreen mode Exit fullscreen mode

Let's make tables. In total, we need 4 tables.

  1. user table
  2. room table
  3. user_rooms table -> Intermediate table for users and rooms
  4. message table

Add schema.sql under the db directory. If you run this file, the database will be reset and these tables will be created.

/server/src/db/schema.sql

DROP TABLE IF EXISTS users cascade;
DROP TABLE IF EXISTS rooms cascade;
DROP TABLE IF EXISTS user_rooms cascade;
DROP TABLE IF EXISTS messages cascade;

CREATE TABLE users (
  id SERIAL PRIMARY KEY,
  username VARCHAR(30) NOT NULL UNIQUE,
  hashed_password VARCHAR(80) NOT NULL,
  image_icon VARCHAR(30),
  created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE rooms (
  id SERIAL PRIMARY KEY,
  name VARCHAR(30) NOT NULL,
  description VARCHAR(100),
  created_user_id INTEGER REFERENCES users(id),
  created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE user_rooms (
  user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
  room_id INTEGER REFERENCES rooms(id) ON DELETE CASCADE,
  PRIMARY KEY (user_id, room_id),
  created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE messages (
  id SERIAL PRIMARY KEY,
  message VARCHAR(1000) NOT NULL,
  user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
  room_id INTEGER REFERENCES rooms(id) ON DELETE CASCADE,
  created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

Enter fullscreen mode Exit fullscreen mode

Add database schema creator file as schema.js

const fs = require('fs');
const path = require('path');
const pool = require('./pool');

// read schema.sql
const schema = fs.readFileSync(path.resolve(__dirname, './schema.sql'), {
  encoding: 'utf8',
});

pool.query(schema, (err, res) => {
  if (err) {
    console.error(err, res);
  } else {
    console.log('Reset Database!');
  }
  pool.end();
});

module.exports;
Enter fullscreen mode Exit fullscreen mode

It's beneficial to have a seed file so that we don't have to create items one by one. Additionally, we'd like to create two user accounts: ‘admin’, which will be designated for sending system messages to users, and ‘test_user’, which will be accessible for anyone to use for logging in. Furthermore, we’ll initialise a few default rooms.

Let's make the seed.js.

/server/src/db/seed.js

const pool = require('./pool');
const bcrypt = require('bcrypt');

const admin = {
  username: 'admin',
  password: 'password',
  image_icon: null,
};

const testUser = {
  username: 'test_user',
  password: 'password',
  image_icon: null,
};

const roomsData = [
  {
    name: 'JavaScript',
    description: 'You can discuss anything about JavaScript here',
  },
  {
    name: 'TypeScript',
    description: 'You can discuss anything about TypeScript here',
  },
  {
    name: 'Python',
    description: 'You can discuss anything about Python here',
  },
  {
    name: 'Java',
    description: 'You can discuss anything about Java here',
  },
  {
    name: 'PHP',
    description: 'You can discuss anything about PHP here',
  },
  {
    name: 'React',
    description: 'You can discuss anything about React here',
  },
  {
    name: 'General',
    description: 'This is a general room for anything you want to discuss',
  },
];

const insertData = async () => {
  try {
    const salt = await bcrypt.genSalt();
    const adminHashedPassword = await bcrypt.hash(admin.password, salt);
    const testUserHashedPassword = await bcrypt.hash(testUser.password, salt);

    admin.password = adminHashedPassword;
    testUser.password = testUserHashedPassword;

    const createdAdmin = await pool.query(
      'INSERT INTO users (username, hashed_password, image_icon) VALUES ($1, $2, $3) RETURNING *',
      [admin.username, admin.password, null]
    );

    const createdTestUser = await pool.query(
      'INSERT INTO users (username, hashed_password, image_icon) VALUES ($1, $2, $3) RETURNING *',
      [testUser.username, testUser.password, null]
    );

    // Insert rooms
    await Promise.all(
      roomsData.map(async (room) => {
        // Create room by admin
        const createdRoom = await pool.query(
          'INSERT INTO rooms (name, description, created_user_id) VALUES ($1, $2, $3) RETURNING *',
          [room.name, room.description, createdAdmin.rows[0].id]
        );

        // Insert user_rooms table
        await pool.query(
          'INSERT INTO user_rooms (user_id, room_id) VALUES ($1, $2)',
          [createdAdmin.rows[0].id, createdRoom.rows[0].id]
        );

        await pool.query(
          'INSERT INTO user_rooms (user_id, room_id) VALUES ($1, $2)',
          [createdTestUser.rows[0].id, createdRoom.rows[0].id]
        );
      })
    );

    console.log('Seeding Completed!');
    pool.end();
  } catch (err) {
    console.error('Failed seeding data', err);
  }
};

insertData();

module.exports;
Enter fullscreen mode Exit fullscreen mode

Add script commands in package.json

  "scripts": {
    "start": "node src/index.js",
    "dev": "nodemon src/index.js",
    "db:reset": "node src/db/schema.js", // Add this
    "db:seed": "node src/db/seed.js" // Add this
  },
Enter fullscreen mode Exit fullscreen mode

Now we can make tables and seed data using these commands.

npm run db:reset
npm run db:seed
Enter fullscreen mode Exit fullscreen mode

If you see the tables in the database after running these commands, it's all done perfectly.

I'm using TablePlus by the way to see the database.
Created tables

React.js Client using vite

Let's make a client environment using vite.

npm create vite@latest
Enter fullscreen mode Exit fullscreen mode

Then answer the questions

? Project name: client
? Select a framework: React
? Select a variant: TypeScript // This time we don't use SWC
Enter fullscreen mode Exit fullscreen mode

After that, move to the client directory and run the commands.

npm install
npm run dev
Enter fullscreen mode Exit fullscreen mode

You'll see the default webpage at http://localhost:5173/.

Delete the following files since we won't use them.

  • /src/App.css
  • /src/index.css
  • index.html
  • /assets/react.svg

And change App.tsx file to the empty Hello World.

function App() {
  return (
    <>
      <h1>Hello World!</h1>
    </>
  );
}

export default App;

Enter fullscreen mode Exit fullscreen mode

First Hello World page

We set up the very basics. Now, we need to add a UI library. I used ChakraUI for this project so let's add this one. Additionally, we will customize the font using Google Fonts Lato.

Install ChakraUI

npm i @chakra-ui/react @emotion/react @emotion/styled framer-motion
Enter fullscreen mode Exit fullscreen mode

For ChakraUI, there are two ways to use web fonts. This time, let's try with Option 1: Install with NPM.

https://chakra-ui.com/community/recipes/using-fonts#option-1-install-with-npm

npm install @fontsource/lato
Enter fullscreen mode Exit fullscreen mode

Create theme directory and index.ts file.

/client/src/theme/index.ts

import { extendTheme } from '@chakra-ui/react';
import '@fontsource/lato';
import '@fontsource/lato/700.css';
import '@fontsource/lato/400.css';

const customTheme = extendTheme({
  config: {
    initialColorMode: 'dark',
    useSystemColorMode: false,
  },
  fonts: {
    heading: `'Lato', sans-serif`,
    body: `'Lato', sans-serif`,
  },
  styles: {},
  colors: {},
  components: {},
});

export default customTheme;
Enter fullscreen mode Exit fullscreen mode

Wrap App with ChakraProvider in App.tsx

import { ChakraProvider, Heading } from '@chakra-ui/react';
import customTheme from './theme';

function App() {
  return (
    <ChakraProvider theme={customTheme}>
      <Heading>Hello World!</Heading>
    </ChakraProvider>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

Now we can see the Lato font with dark mode!

Hello World with ChakraUI

Socket.io

It's time to connect socket.io between server and client.

First, install socket.io in the server.

npm install socket.io
Enter fullscreen mode Exit fullscreen mode

Add small code to index.js and separate the socket.io handling into a socket.js file. This is beneficial in terms of separating concerns.

/server/src/index.js

const { Server } = require('socket.io');

// Socket.io 
const io = new Server(server, {
  cors: {
    origin: 'http://localhost:5173',
    credentials: true,
  },
});

socketIoHandler(io); // Function from socket.js

server.listen(PORT, () => console.log(`Server running on port ${PORT}`));
Enter fullscreen mode Exit fullscreen mode

/server/src/socket.js

const socketIoHandler = (io) => {
  // Run socketIo when client connects
  io.on('connect', (socket) => {
    console.log(`Client connected with id: ${socket.id}`);

    socket.on('disconnect', () =>
      console.log(`Client disconnected with id: ${socket.id}`)
    );
  });
};

module.exports = {
  socketIoHandler,
};
Enter fullscreen mode Exit fullscreen mode

Now move to client directory and install the library to use the socket.io from the client.

npm install socket.io-client
Enter fullscreen mode Exit fullscreen mode

Import io and use it in App.tsx.

import { ChakraProvider, Heading } from '@chakra-ui/react';
import customTheme from './theme';
import { io } from 'socket.io-client';

io('http://localhost:3000');

function App() {
  return (
    <ChakraProvider theme={customTheme}>
      <Heading>Hello World!</Heading>
    </ChakraProvider>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

If you refresh http://localhost:5173/, you see the command line in your terminal like this.

command line in terminal

We're successfully connecting between server and client.

Room

Let's proceed with creating a private room. But before that, let's clarify the two definitions related to joining rooms:

  1. Join room: Adding a room to the user's sidebar.
  2. Join chat: Participating in a real-time chat when a user clicks on the room in the sidebar.

For No.1: Join room, we also utilize socket.io to display a system message such as ${username} has joined the room!. However, let's focus on No.2: Join chat, as this is a vital feature.

First, let's add this socket.io listener to the server-side.

/server/src/socket.js

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

    // Join the chat --> Add this
    socket.on('join_chat', async (data) => {
      const { roomId } = data;

      socket.join(roomId);
    });
Enter fullscreen mode Exit fullscreen mode

For the client-side, we want to emit an event when a user clicks on a room in the sidebar.

Let's begin by creating the sidebar.

Sidebar UI

Install the libraries we will use.

npm install react-router-dom react-icons zustand react-error-boundary axios
Enter fullscreen mode Exit fullscreen mode
  • react-router-dom : For React routing
  • react-icons : To use icons
  • zustand : State management
  • react-error-boundary : Error handling
  • axios : Fetching API

Let's create a type to fetch the joined rooms.

/src/types/user.ts

export type UserRoomType = {
  id: number;
  name: string;
  description: string;
  created_user_id: number;
  created_at: string;
  user_id: number;
  room_id: number;
};
Enter fullscreen mode Exit fullscreen mode

Export the type through the index.ts file to import easily.

/src/types/index.ts

export type { UserRoomType } from './user';
Enter fullscreen mode Exit fullscreen mode

Create API function to get user joined rooms.

First, let's create base setting for axious.

/src/api/base.ts

import axios from 'axios';

const instance = axios.create({
  baseURL: import.meta.env.VITE_API_URL,
  withCredentials: true,
});

export default instance;
Enter fullscreen mode Exit fullscreen mode

Add this variable to client .env file.

/client/env.ts

VITE_API_URL="http://localhost:3000/api"
Enter fullscreen mode Exit fullscreen mode

Then let's make the API function.

/src/api/user/get-user-rooms.ts

import { AxiosResponse } from 'axios';
import baseServer from '../base';
import { UserRoomType } from '../../types';

export const getUserRoomsApi: () => Promise<
  AxiosResponse<UserRoomType[]>
> = async () => {
  const res = await baseServer.get('/user/rooms');
  return res;
};
Enter fullscreen mode Exit fullscreen mode

/src/api/user/index.ts

export { getUserRoomsApi } from './get-user-rooms';
Enter fullscreen mode Exit fullscreen mode

Create Error handling

The react-error-boundary library provides a reusable React ErrorBoundary, which is more effective than the native error boundary for handling fetching errors.

We need to create a fallback component to catch any errors, such as fetching API errors, component errors, or JavaScript runtime errors.

Below is a type guard to return whether the unknown error has a message:

/src/utils/error-type-guard.ts

import axios, { AxiosError } from 'axios';

export const isErrorWithMessage = (
  error: unknown
): error is AxiosError<{ message: string }> => {
  if (axios.isAxiosError(error)) {
    return (
      error.response?.data?.message !== undefined &&
      typeof error.response?.data.message === 'string'
    );
  }

  return false;
};

Enter fullscreen mode Exit fullscreen mode

/src/utils/index.ts

export { isErrorWithMessage } from './error-type-guard';
Enter fullscreen mode Exit fullscreen mode

The error fallback component

/src/components/error/error-fallback.tsx

import { Link as ReactRouterLink } from 'react-router-dom';
import {
  Container,
  VStack,
  Heading,
  Text,
  Link as ChakraLink,
} from '@chakra-ui/react';
import { FallbackProps } from 'react-error-boundary';
import { isErrorWithMessage } from '../../utils';

export const ErrorFallback = ({ error }: FallbackProps) => {
  const message = isErrorWithMessage(error)
    ? error.response?.data.message
    : 'An error occurred';

  return (
    <Container maxW={{ base: '100%', lg: 'container.lg' }} h='100vh'>
      <VStack justify='center' align='center' gap='30px' h='100%'>
        <Heading as='h1'>Oops!</Heading>
        <Text fontSize='xl'>Sorry.. {message}</Text>
        <ChakraLink
          as={ReactRouterLink}
          to={'/'}
          fontSize='xl'
          color='purple.400'
          _hover={{ color: 'purple.300' }}
        >
          Go To Top
        </ChakraLink>
      </VStack>
    </Container>
  );
};
Enter fullscreen mode Exit fullscreen mode

Wrap your components with ErrorBoundary in App.tsx or any higher component. In case you create routes, it will be PrivateLayout component.

If you want to create routes, refer to this.
https://github.com/kanatagu/chat-app/tree/main/client/src/routes

/src/components/layout/private-layout.tsx

import { ErrorInfo, useEffect } from 'react';
import { Outlet } from 'react-router-dom';
import { ErrorBoundary } from 'react-error-boundary';
import { ErrorFallback } from '../error';

export const PrivateLayout = () => {
  const onError = (error: Error, info: ErrorInfo) => {
    console.error('error.message', error.message);
    console.error('info.componentStack:', info.componentStack);
  };

  return (
    <ErrorBoundary FallbackComponent={ErrorFallback} onError={onError}>
      <Outlet />
    </ErrorBoundary>
  );
};

Enter fullscreen mode Exit fullscreen mode

We also need to create stores to access the joined rooms from any components using Zustand.

/src/store/joined-rooms.ts

import { create } from 'zustand';
import { UserRoomType } from '../types';
import { getUserRoomsApi } from '../api/user';
import { useErrorBoundary } from 'react-error-boundary';

type JoinedRoomsStore = {
  joinedRooms: UserRoomType[];
  setJoinedRooms: (joinedRooms: UserRoomType[]) => void;
  getUserJoinedRooms: () => Promise<void>;
  isLoading: boolean;
};

export const useJoinedRoomsStore = create<JoinedRoomsStore>()((set) => ({
  joinedRooms: [],
  setJoinedRooms: (joinedRooms) => set({ joinedRooms: joinedRooms }),
  getUserJoinedRooms: async () => {
    await getUserRoomsApi()
      .then((res) => set({ joinedRooms: res.data }))
      .catch((error) => {
        useErrorBoundary().showBoundary(error);
      })
      .finally(() => set({ isLoading: false }));
  },
  isLoading: true,
}));
Enter fullscreen mode Exit fullscreen mode

Create RoomList component to use in the Sidebar component.

/src/components/room/room-list.tsx

import {
  Box,
  VStack,
  Button,
  Flex,
  Skeleton,
} from '@chakra-ui/react';
import { useNavigate, useParams } from 'react-router-dom';
import { FiHash } from 'react-icons/fi';
import { MdManageSearch } from 'react-icons/md';
import { useJoinedRoomsStore } from '../../store';

type RoomListProps = {
  onDrawerClose?: () => void;
};

export const RoomList = ({ onDrawerClose }: RoomListProps) => {
  const navigate = useNavigate();
  const { roomId } = useParams();
  const roomIdParam = Number(roomId);

  const isLoading = useJoinedRoomsStore((state) => state.isLoading);
  const joinedRooms = useJoinedRoomsStore((state) => state.joinedRooms);

  const roomClickHandler = (clickedRoomId: number) => {
    // For SP drawer
    onDrawerClose && onDrawerClose();

    navigate(`/chat/${clickedRoomId}`);
  };

  return (
    <Box>
      <Box mt={{ base: '0px', md: '16px' }} px='10px'>
        <Button
          gap='8px'
          size='sm'
          w='140px'
          justifyContent='flex-start'
          onClick={() => {
            navigate('/all-rooms');
            onDrawerClose && onDrawerClose();
          }}
        >
          <MdManageSearch />
          Browse Rooms
        </Button>
      </Box>

      <VStack
        align='start'
        gap={0}
        mt='10px'
        maxH={{ base: 'auto', md: 'calc(100vh - 240px)' }}
        overflow={'auto'}
      >
        {isLoading ? (
          <>
            {Array.from({ length: 5 }).map((_, index) => (
              <Skeleton
                key={index}
                w='full'
                h='44px'
                borderRadius='md'
                mb='14px'
              />
            ))}
          </>
        ) : (
          <>
            {joinedRooms.map((room) => (
              <Flex
                key={room.id}
                role='button'
                tabIndex={0}
                onClick={() => roomClickHandler(room.id)}
                py='10px'
                px='10px'
                display='flex'
                alignItems='center'
                fontWeight='bold'
                fontSize='lg'
                gap='8px'
                color={room.id === roomIdParam ? 'gray.300' : 'gray.400'}
                bg={room.id === roomIdParam ? 'purple.800' : 'transparent'}
                w='full'
                _hover={{
                  textDecoration: 'none',
                  bg: room.id === roomIdParam ? 'purple.800' : 'gray.800',
                }}
              >
                <FiHash size={20} />
                {room.name}
              </Flex>
            ))}
          </>
        )}
      </VStack>
    </Box>
  );
};

Enter fullscreen mode Exit fullscreen mode

Create sidebar.tsx.

/src/components/layout/sidebar.tsx

import { Box, Link as ChakraLink, Flex } from '@chakra-ui/react';
import { Link as ReactRouterLink } from 'react-router-dom';
import { FiCoffee } from 'react-icons/fi';
import { RoomList } from '../room';

export const Sidebar = () => {
  return (
    <Flex
      flexDir='column'
      w='240px'
      pr='10px'
      flexShrink={0}
      justifyContent='space-between'
    >
      <Box>
        <ChakraLink
          as={ReactRouterLink}
          to='/'
          fontSize='3xl'
          fontWeight='700'
          color='purple.400'
          display='flex'
          alignItems='center'
          gap='8px'
          _hover={{ textDecoration: 'none', opacity: '.8' }}
        >
          <FiCoffee />
          Dev Chat
        </ChakraLink>

        <RoomList />
      </Box>
    </Flex>
  );
};
Enter fullscreen mode Exit fullscreen mode

NOTE
This app requires users to create an account and log in with their account. All functionalities are private. Authentication implementation is essential. Due to its complexity, this part will not be covered in this article. However, I might cover it in another article soon. Please check it if you need assistance with authentication.

Message

Now, we will create functionality for sending and receiving messages using socket.io.

Join Chat

When a user clicks on one of the rooms in the sidebar, the event join_chat will be emitted.

Let's start by creating a listener on the server-side.

/server/src/socket.js


const socketIoHandler = (io) => {
  // Run socketIo when client connects
  io.on('connect', (socket) => {
    console.log(`Client connected with id: ${socket.id}`);

    // Join the chat
    socket.on('join_chat', async (data) => { // Add This
      const { roomId } = data;

      socket.join(roomId);
    });

    // Leave the chat
    socket.on('leave_chat', async (data) => { // Add This
      const { roomId } = data;

      socket.leave(roomId);
    });
Enter fullscreen mode Exit fullscreen mode

That's it!

Let's move to the client side. We'll create a store for the socket since we want to utilize socket.io from multiple components. To achieve this, we'll need to define a type first.

/client/src/types/message.ts

export type MessageType = {
  id: number;
  message: string;
  username: string;
  image_icon: string | null;
  user_id: number;
  room_id: number;
  created_at: string;
};
Enter fullscreen mode Exit fullscreen mode

/client/src/types/socket.ts

import { Socket } from 'socket.io-client';
import { MessageType } from './index';

type ServerToClientEvents = {};

type ClientToServerEvents = {
  join_chat: ({
    userId,
    roomId,
    username,
  }: {
    userId: number;
    roomId: number;
    username: string;
  }) => void;

  leave_chat: ({ userId, roomId }: { userId: number; roomId: number }) => void;

export type CustomSocket = Socket<ServerToClientEvents, ClientToServerEvents>;
Enter fullscreen mode Exit fullscreen mode

Now create socket store.

/client/src/store/socket.ts

import { create } from 'zustand';
import { io } from 'socket.io-client';
import { CustomSocket } from '../types';

type SocketStore = {
  socket: CustomSocket;
  setSocket: (socket: CustomSocket) => void;
};
export const useSocketStore = create<SocketStore>()((set) => ({
  socket: io('http://localhost:3000'),
  setSocket: (socket) => set({ socket: socket }),
}));
Enter fullscreen mode Exit fullscreen mode

Also creat the current room store to share the current room information throughout components.

/client/src/store/current-room.ts

import { create } from 'zustand';
import { UserRoomType } from '../types';

type CurrentRoomStore = {
  currentRoom: UserRoomType | null;
  setCurrentRoom: (user: UserRoomType | null) => void;
};

export const useCurrentRoomStore = create<CurrentRoomStore>()((set) => ({
  currentRoom: null,
  setCurrentRoom: (user) => set({ currentRoom: user }),
}));
Enter fullscreen mode Exit fullscreen mode

Now, let's create a custom hook to listen to navigation changes and emit socket.io. Whenever a user clicks on a room in the sidebar, the roomId parameter will change.

/client/src/hooks/room/use-set-current-room.ts

import { useEffect } from 'react';
import { useParams } from 'react-router-dom';
import {
  // useAuthStore,
  useCurrentRoomStore,
  useJoinedRoomsStore,
  useSocketStore,
} from '../../store';

export const useSetCurrentRoom = () => {
  const { roomId } = useParams();

  const socket = useSocketStore((state) => state.socket);
  const currentUser = {
    id: 1,
    username: 'test_user',
    image_icon: null
  }; // For now, temp is valuable but this must be dynamic.

  const joinedRooms = useJoinedRoomsStore((state) => state.joinedRooms);
  const setCurrentRoom = useCurrentRoomStore((state) => state.setCurrentRoom);

  // Set the current room and join the room in the socket
  useEffect(() => {
    if (roomId) {
      const selectedRoomId = parseInt(roomId, 10);
      const room = joinedRooms.find(
        (joinedRoom) => joinedRoom.id === selectedRoomId
      );

      if (room) {
        setCurrentRoom(room);
      }

      if (currentUser) {
        socket.emit('join_chat', {
          userId: currentUser.id,
          roomId: Number(roomId),
          username: currentUser.username,
        });
      }
    }

    // Leave chat when the component is unmounted
    return () => {
      if (socket && currentUser && roomId) {
        socket.emit('leave_chat', {
          userId: currentUser.id,
          roomId: Number(roomId),
        });
      }
    };
  }, [roomId, setCurrentRoom, joinedRooms, currentUser, socket]);
};
Enter fullscreen mode Exit fullscreen mode

Next, import the useSetCurrentRoom hook into a higher component, such as PrivateLayout or App.tsx.

/client/src/components/layout/private-layout.tsx

// ...
export const PrivateLayout = () => {
  const getUserJoinedRooms = useJoinedRoomsStore(
    (state) => state.getUserJoinedRooms
  );

  useEffect(() => {
    getUserJoinedRooms();
  }, [getUserJoinedRooms]);

  useSetCurrentRoom();
// ...
Enter fullscreen mode Exit fullscreen mode

Display Messages and send a message

The components will be organized into four sections as outlined below.

Component 4 sections

Let's creat one by one.

MessageHeader component.
In this component, we want to show the title and description of current room so import the useCurrentRoomStore.

/client/src/components/message/message-header.tsx

import { useRef } from 'react';
import { Flex, Heading, Text, Button } from '@chakra-ui/react';
import { FiHash, FiUsers } from 'react-icons/fi';
import { useCurrentRoomStore } from '../../store';

export const MessageHeader = () => {
  const btnRef = useRef(null);

  const currentRoom = useCurrentRoomStore((state) => state.currentRoom);

  return (
    <>
      <Flex
        borderBottom='1px solid'
        borderColor='gray.600'
        pb='10px'
        align='center'
        justify='space-between'
        gap='10px'
      >
        <Flex
          align='end'
          gap='10px'
          onClick={() => {}}
          cursor='pointer'
          _hover={{
            opacity: '.8',
          }}
        >
          <Heading
            as='h1'
            display='flex'
            alignItems='center'
            fontSize={{ base: 'lg', md: '2xl' }}
            gap='4px'
          >
            <Text as='span' fontSize='sm' w={{ base: '16px', md: '20px' }}>
              <FiHash size={'100%'} />
            </Text>
            {currentRoom?.name}
          </Heading>
          <Text
            color='gray.400'
            display={'-webkit-box'}
            overflow={'hidden'}
            sx={{
              WebkitBoxOrient: 'vertical',
              WebkitLineClamp: '1',
            }}
            fontSize={'sm'}
          >
            {currentRoom?.description}
          </Text>
        </Flex>

        <Button
          gap='10px'
          onClick={() => {}}
          aria-label='See room members'
          ref={btnRef}
          size={{ base: 'sm', md: 'md' }}
        >
          <FiUsers />
          <Text as='span' display={{ base: 'none', md: 'inline-block' }}>
            Members
          </Text>
        </Button>
      </Flex>
    </>
  );
};

Enter fullscreen mode Exit fullscreen mode

MessageItem component.

For user icons, you can place fixed random icon images in assets/profile-icon. To avoid importing all icon images, we import them dynamically. We will also display the message date using date-fns library.

/client/src/components/message/message-item.tsx

import { useState, useEffect } from 'react';
import { format, isToday, isYesterday } from 'date-fns';
import { Flex, Text, Avatar, Box } from '@chakra-ui/react';
import defaultIcon from '../../assets/profile-icon/default.svg';

type MessageItemProps = {
  username: string;
  imageIcon: string | null;
  time: string;
  message: string;
};

export const MessageItem = ({
  username,
  imageIcon,
  time,
  message,
}: MessageItemProps) => {
  const [iconSrc, setIconSrc] = useState(defaultIcon);

  useEffect(() => {
    const fetchIcon = async () => {
      if (!imageIcon) return;

      import(`../../assets/profile-icon/${imageIcon}.jpg`).then((module) => {
        setIconSrc(module.default);
      }); // dynamic import
    };

    fetchIcon();
  });

  // To show the message date
  const messageDate = new Date(time);
  let formattedTime;

  if (isToday(messageDate)) {
    formattedTime = `Today ${format(messageDate, 'HH:mm')}`;
  } else if (isYesterday(messageDate)) {
    formattedTime = `Yesterday ${format(messageDate, 'HH:mm')}`;
  } else {
    formattedTime = format(new Date(time), 'MM/dd/yyy HH:mm');
  }

  return (
    <Flex gap='10px'>
      <Avatar
        name={'John Doe'}
        src={iconSrc}
        bg={'gray.300'}
        size={{ base: 'sm', md: 'md' }}
      />
      <Box>
        <Flex gap='10px' align='center'>
          <Box fontWeight='bold' color={'purple.400'}>
            {username}
          </Box>
          <Text as='span' color={'gray.300'} fontSize='sm'>
            {formattedTime}
          </Text>
        </Flex>
        <Text>{message}</Text>
      </Box>
    </Flex>
  );
};

Enter fullscreen mode Exit fullscreen mode

MessageInput component.

For this component, let's install form libraries.

npm install react-hook-form zod @hookform/resolvers
Enter fullscreen mode Exit fullscreen mode

First, let's create validation for the message.

/client/src/schema/message.ts

import * as z from 'zod';

export const messageSchema = z.object({
  message: z
    .string()
    .min(1, { message: 'Please enter a message' })
    .max(1000, { message: 'Message must be less than 30 characters' }),
});

export type MessageSchemaType = z.infer<typeof messageSchema>;
Enter fullscreen mode Exit fullscreen mode

Add types of new_message, send_message_error and send_message.

/chat-app/client/src/types/socket.ts

import { Socket } from 'socket.io-client';
import { MessageType } from './index';

type ServerToClientEvents = {
  new_message: (message: MessageType) => void;
  send_message_error: (error: { message: string }) => void;
};

type ClientToServerEvents = {
// ..
  send_message: ({
    message,
    userId,
    roomId,
  }: {
    message: string;
    userId: number;
    roomId: number;
  }) => void;

// ..
Enter fullscreen mode Exit fullscreen mode

Create useSendMessage custom hook for sending a message. Emit the send_message event when the submit button is clicked.

/client/src/hooks/message/use-send-message.ts

import { KeyboardEvent } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { messageSchema, MessageSchemaType } from '../../schema';
import { useCurrentRoomStore, useSocketStore } from '../../store';

export const useSendMessage = () => {
  const socket = useSocketStore((state) => state.socket);
  const currentRoom = useCurrentRoomStore((state) => state.currentRoom);
  const currentUser = {
    id: 1,
    username: 'test_user',
    image_icon: null
  }; // For now, temp is valuable but this must be dynamic.

  const {
    register,
    handleSubmit,
    reset,
    formState: { errors },
  } = useForm<MessageSchemaType>({
    resolver: zodResolver(messageSchema),
  });

  const sendMessage = (data: MessageSchemaType) => {
    if (currentRoom && currentUser) {
      socket.emit('send_message', {
        message: data.message,
        userId: currentUser.id,
        roomId: currentRoom.id,
      });

      reset();
    }
  };

  const sendMessageEnterHandler = (e: KeyboardEvent<HTMLTextAreaElement>) => {
    if (e.key === 'Enter') {
      e.preventDefault();
      handleSubmit(sendMessage)();
    }
  };

  return {
    register,
    sendMessageEnterHandler,
    onSendMessageSubmit: handleSubmit((d) => sendMessage(d)),
    errors,
    reset,
  };
};

Enter fullscreen mode Exit fullscreen mode

MessageInput component. Import the useSendMessage custom hook and use it.

/client/src/components/message/message-input.tsx

import {
  Flex,
  Textarea,
  Button,
  FormControl,
  FormErrorMessage,
} from '@chakra-ui/react';
import { FiSend } from 'react-icons/fi';
import { useSendMessage } from '../../hooks/message';

export const MessageInput = () => {
  const {
    register,
    sendMessageEnterHandler,
    onSendMessageSubmit,
    errors,
    reset,
  } = useSendMessage();

  return (
    <Flex
      as='form'
      onSubmit={onSendMessageSubmit}
      bgColor='gray.900'
      pt='14px'
      pb={{ base: '16px', md: '0px' }}
      px={{ base: '16px', md: '0px' }}
      height='auto'
      gap='12px'
      align='flex-start'
    >
      <FormControl isInvalid={!!errors.message}>
        <Textarea
          placeholder='Type a message'
          minH={'44px'}
          bgColor='gray.800'
          colorScheme='purple'
          aria-label='message'
          onKeyDown={(e) => sendMessageEnterHandler(e)}
          {...register('message', {
            onBlur: () => {
              reset();
            },
          })}
        />
        <FormErrorMessage>{errors?.message?.message}</FormErrorMessage>
      </FormControl>
      <Button
        h='44px'
        w='44px'
        colorScheme='purple'
        bgColor='purple.500'
        borderRadius='full'
        p='0px'
        flexShrink={0}
        aria-label='Edit message'
        type='submit'
      >
        <FiSend size={24} />
      </Button>
    </Flex>
  );
};

Enter fullscreen mode Exit fullscreen mode

Finally, let's display messages. We also want to show the chat history before starting new chats. The history is fetched from the database using an axios fetch API function to get all messages.

Create axios fetch API function.

/client/src/api/message/get-messages.ts

import { AxiosResponse } from 'axios';
import baseServer from '../base';
import { MessageType } from '../../types';

export const getMessagesApi: (
  roomId: string
) => Promise<AxiosResponse<MessageType[]>> = async (roomId) => {
  const res = await baseServer.get(`/messages?roomId=${roomId}`);
  return res;
};

Enter fullscreen mode Exit fullscreen mode

Create the useDisplayMessages custom hook. In this hook, we fetch the message history and listen for new_message using socket.io. If a new message is created, it will be added to the messages array state.

/client/src/hooks/message/use-display-messages.ts

import { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { useErrorBoundary } from 'react-error-boundary';
import { useToast } from '@chakra-ui/react';
import { getMessagesApi } from '../../api/message';
import { useSocketStore } from '../../store';

type MessageDisplayType = {
  username: string;
  imageIcon: string | null;
  time: string;
  message: string;
};

export const useDisplayMessages = (
  messagesPanelRef: React.RefObject<HTMLDivElement>
) => {
  const { roomId } = useParams();
  const { showBoundary } = useErrorBoundary();
  const toast = useToast();
  const socket = useSocketStore((state) => state.socket);

  const [messages, setMessages] = useState<MessageDisplayType[]>([]);
  const [isLoading, setIsLoading] = useState(false);

  useEffect(() => {
    const fetchMessages = async () => {
      try {
        if (roomId) {
          setIsLoading(true);
          const { data } = await getMessagesApi(roomId);
          const displayMessages = data.map((message) => {
            return {
              username: message.username,
              imageIcon: message.image_icon,
              time: message.created_at,
              message: message.message,
            };
          });

          // Order by time
          displayMessages.sort((a, b) => {
            return new Date(a.time).getTime() - new Date(b.time).getTime();
          });

          setMessages(displayMessages);
          setIsLoading(false);
        }
      } catch (error) {
        showBoundary(error);
      }
    };

    fetchMessages();
  }, [showBoundary, roomId]);

  useEffect(() => {
    if (!socket) return;

    // Listening for new messages
    socket.on('new_message', (message) => {
      setMessages((prevMessages) => {
        return [
          ...prevMessages,
          {
            username: message.username,
            imageIcon: message.image_icon,
            time: message.created_at,
            message: message.message,
          },
        ];
      });
    });

    return () => {
      socket.off('new_message');
    };
  }, [socket, roomId]);

  useEffect(() => {
    // Error handling
    socket.on('send_message_error', (error) => {
      toast({
        position: 'top',
        title: 'Error',
        description: error.message,
        status: 'error',
        duration: 5000,
        isClosable: true,
      });
    });

    return () => {
      socket.off('send_message_error');
    };
  }, [socket, toast]);

  // Scroll to the most recent message
  useEffect(() => {
    if (!messagesPanelRef.current) return;
    messagesPanelRef.current.scrollTop = messagesPanelRef.current.scrollHeight;
  }, [messages, messagesPanelRef]);

  return {
    messages,
    isLoading,
  };
};

Enter fullscreen mode Exit fullscreen mode

MessagePanel component.

Import the useDisplayMessages custom hook and the previously created components into MessagePanel.

/client/src/components/message/message-panel.tsx

import { useRef } from 'react';
import { Box, Flex, VStack, SkeletonCircle, Skeleton } from '@chakra-ui/react';
import { MessageItem, MessageInput } from './index';
import { useDisplayMessages } from '../../hooks/message';
import { MessageHeader } from './index';

export const MessagePanel = () => {
  const messagePanelRef = useRef<HTMLDivElement>(null);
  const { messages, isLoading } = useDisplayMessages(messagePanelRef);

  return (
    <>
      <VStack
        w='100%'
        bgColor='gray.800'
        h={{ base: 'calc(100vh - 62px)', md: 'calc(100vh - 32px)' }}
        align='stretch'
        justify='space-between'
      >
        <Box
          py={{ base: '10px', md: '10px' }}
          px={{ base: '16px', md: '20px' }}
          flexShrink={1}
          overflow='hidden'
        >
          <MessageHeader />

          <VStack
            align='start'
            gap='28px'
            mt={{ base: '10px', md: '16px' }}
            overflowY='auto'
            maxH={{ base: 'calc(100vh - 210px)', md: 'calc(100vh - 180px)' }}
            ref={messagePanelRef}
          >
            {isLoading ? (
              <>
                {Array.from({ length: 8 }).map((_, index) => (
                  <Flex gap='10px' key={index}>
                    <SkeletonCircle size='10' />
                    <Box>
                      <Skeleton h='16px' w='200px' />
                      <Skeleton h='20px' w='300px' mt='4px' />
                    </Box>
                  </Flex>
                ))}
              </>
            ) : (
              <>
                {messages.map((message, index) => {
                  return (
                    <MessageItem
                      key={index}
                      username={message.username}
                      imageIcon={message.imageIcon}
                      time={message.time}
                      message={message.message}
                    />
                  );
                })}
              </>
            )}
          </VStack>
        </Box>

        <MessageInput />
      </VStack>
    </>
  );
};

Enter fullscreen mode Exit fullscreen mode

Congratulations! We've successfully developed the full-stack chat application using socket.io. While we couldn't cover all the features in this tutorial, please feel free to clone and refer to my repository for additional functionalities such as creating rooms, sending system messages, and viewing all members in a room, etc.

Top comments (0)