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.
- Open the Postgres.app
- Click postgres
if you see "chat_app" database in your app, then it's made successfully.
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.
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).
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
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
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.
- user table
- room table
- user_rooms table -> Intermediate table for users and rooms
- 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
);
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;
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;
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
},
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.
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.
function App() {
return (
<>
<h1>Hello World!</h1>
</>
);
}
export default App;
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
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
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!
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
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
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
.
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.
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:
- Join room: Adding a room to the user's sidebar.
- 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);
});
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.
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
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
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
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
VITE_API_URL="http://localhost:3000/api"
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;
};
/src/api/user/index.ts
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
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
export { isErrorWithMessage } from './error-type-guard';
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>
);
};
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>
);
};
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,
}));
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>
);
};
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>
);
};
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);
});
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;
};
/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>;
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 }),
}));
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 }),
}));
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]);
};
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();
// ...
Display Messages and send a message
The components will be organized into four sections as outlined below.
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>
</>
);
};
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>
);
};
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
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
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
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
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
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
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
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 for additional functionalities such as creating rooms, sending system messages, and viewing all members in a room, etc.
Top comments (0)