Full Stack Chat App with Socket.io
Tags: react, express, socket, postgres
Published: 2024-02-16 20:11:13

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](https://postgresapp.com/), 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/igvgcv2kodjd2vn7lhay.png)

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

![chat_app database exists in postgresql app](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/a98n2qg960xu869ccnsr.png)


### Server with Express.js
Let's make a server directory under the chat-app directory.

```
mkdir chat-app
cd chat-app
mkdir server
```

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

```
cd server
npm init -y 
npm install express cors dotenv bcrypt
```

Make `index.js` file and add codes.

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

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/0hrbicans0kp4je6pf5h.png)


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

In `pool.js` file, add this setup.

/server/src/db/pool.js
```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;

```

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

/server/.env
```env
PORT=3000
DB_USER=[You username] 
DB_PASSWORD=[Your password]
DB_HOST=localhost
DB_PORT=5432
DB_NAME=chat_app
```

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

```

Add database schema creator file as `schema.js`

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

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

Add script commands in package.json
```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
  },
```

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

```
npm run db:reset
npm run db:seed
```

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/0dw0al3k6lyw283i66mg.png) 

### React.js Client using vite
Let's make a client environment using vite.

```
npm create vite@latest
```

Then answer the questions
```
? Project name: client
? Select a framework: React
? Select a variant: TypeScript // This time we don't use SWC
```

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

```
npm install
npm run dev
```

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.

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

export default App;

```

![First Hello World page](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/4q5hff77wtx7s9tngdev.png)

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

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

Create `theme` directory and `index.ts` file.

/client/src/theme/index.ts
```js
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;
```

Wrap App with `ChakraProvider` in `App.tsx`

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

Now we can see the Lato font with dark mode!

![Hello World with ChakraUI](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/0f8ji9tknl7v4xucarih.png)


### Socket.io

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

First, install socket.io in the server.

```
npm install socket.io
```

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

/server/src/socket.js
```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,
};
```

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

```
npm install socket.io-client
```

Import `io` and use it in `App.tsx`.

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

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

![command line in terminal](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/26pnsilk8fzx3qrnurx5.png)

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

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/0cdc5o7mxtkq8eu6oj8h.png)

Install the libraries we will use.

```
npm install react-router-dom react-icons zustand react-error-boundary axios
```

* 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
```js
export type UserRoomType = {
  id: number;
  name: string;
  description: string;
  created_user_id: number;
  created_at: string;
  user_id: number;
  room_id: number;
};
```

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

/src/types/index.ts
```js
export type { UserRoomType } from './user';
```

Create API function to get user joined rooms.

First, let's create base setting for axious. 

/src/api/base.ts
```js
import axios from 'axios';

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

export default instance;
```

Add this variable to client `.env` file.

/client/env.ts
```js
VITE_API_URL="http://localhost:3000/api"
```

Then let's make the API function.

/src/api/user/get-user-rooms.ts
```js
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;
};
```

/src/api/user/index.ts
```js
export { getUserRoomsApi } from './get-user-rooms';
```

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

```

/src/utils/index.ts
```js
export { isErrorWithMessage } from './error-type-guard';
```

The error fallback component

/src/components/error/error-fallback.tsx
```js
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>
  );
};
```

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

```

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

/src/store/joined-rooms.ts
```js
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,
}));
```

Create `RoomList` component  to use in the `Sidebar` component.

/src/components/room/room-list.tsx
```js
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>
  );
};

```

Create `sidebar.tsx`.

/src/components/layout/sidebar.tsx
```js
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>
  );
};
```

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

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
```js
export type MessageType = {
  id: number;
  message: string;
  username: string;
  image_icon: string | null;
  user_id: number;
  room_id: number;
  created_at: string;
};
```

/client/src/types/socket.ts
```js
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>;
```

Now create socket store.

/client/src/store/socket.ts
```js
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 }),
}));
```

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


/client/src/store/current-room.ts
```js
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 }),
}));
```

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

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

/client/src/components/layout/private-layout.tsx
```js
// ...
export const PrivateLayout = () => {
  const getUserJoinedRooms = useJoinedRoomsStore(
    (state) => state.getUserJoinedRooms
  );

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

  useSetCurrentRoom();
// ...
```

### Display Messages and send a message

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

![Component 4 sections](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/z91hsdjhcnqz0s6ldhln.png)


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

```

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

```

`MessageInput` component.

For this component, let's install form libraries.

```
npm install react-hook-form zod @hookform/resolvers
```

First, let's create validation for the message.

/client/src/schema/message.ts
```js
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>;
```

Add types of `new_message`, `send_message_error` and `send_message`.

/chat-app/client/src/types/socket.ts
```js
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;

// ..
```

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

```

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

/client/src/components/message/message-input.tsx
```js
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>
  );
};

```

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

```

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

```

`MessagePanel` component.

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

/client/src/components/message/message-panel.tsx
```js
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>
    </>
  );
};

```

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 ](https://github.com/kanatagu/chat-app)for additional functionalities such as creating rooms, sending system messages, and viewing all members in a room, etc.