Below is the entire tutorial (Parts 1, 2, and 3) combined into a single post with a unified Table of Contents, exactly as previously provided—nothing is omitted or changed, especially the code explanations.
Table of Contents
- 1. Introduction & Project Overview
- 2. Part 1: Node.js, Express, and Mongoose Setup
- 3. Part 2: Socket.io, Real-Time Chat & More Backend
- 4. Part 3: Redux Toolkit, RTK Query, & Frontend Integration
- 1. Setting up the Redux Store
- 2. Creating the Auth Slice
- 3. Building a Miscellaneous (UI) Slice
- 4. Checking Auth on the Frontend (Using Axios or RTK Query)
- 5. Login / Signup (Dispatching Redux actions)
- 6. Chat List with RTK Query
- 7. Search Users
- 8. Sending Friend Requests (Mutation)
- 9. Sockets and Real-Time Messages
- 10. Handling File Uploads (Attachments)
- 11. Additional Redux Patterns: createAsyncThunk
- 12. Putting it All Together
1. Introduction & Project Overview
In this tutorial series (Parts 1, 2, and 3), we will build a full-stack chat application using:
- Node.js + Express (for REST APIs),
- Mongoose (for MongoDB interactions),
- JWT & Cookies (for authentication),
- Socket.io (for real-time messaging),
- React on the frontend (with TypeScript if desired),
- Redux Toolkit (to manage global state),
- RTK Query (for data fetching/caching),
- and optional file uploads (images, videos, etc.) to enhance the chat experience.
By the end of the tutorial, you will have:
- A fully functional backend with user authentication (login, signup), chat endpoints, and real-time messaging.
- A React frontend integrated with Redux Toolkit for state management, plus Socket.io for real-time events.
- A basic admin system or advanced UI toggles if needed, including group chats, search functionality, friend requests, etc.
2. Part 1: Node.js, Express, and Mongoose Setup
1. Project Initialization
- Initialize the project:
mkdir chat-app cd chat-app npm init -y - Install dependencies:
npm install express mongoose cors cookie-parser jsonwebtoken bcrypt npm install --save-dev typescript ts-node-dev @types/express @types/node @types/cookie-parserexpress: our Node.js frameworkmongoose: to interact with MongoDBcookie-parser: for reading cookies from requestsjsonwebtoken: for creating and verifying JWTsbcrypt: for hashing passwordstypescript,ts-node-dev, etc., if you want a TS setup
- Create a basic structure:
mkdir src touch src/index.ts
Explanation:
- We created a new directory
chat-app, initializednpm, and installed the necessary dependencies for Express, Mongoose, and authentication (jsonwebtoken,bcrypt,cookie-parserfor cookies). - Also, we installed dev dependencies like
typescript,ts-node-dev, and type declarations for Node and Express if we are using TypeScript. - Finally, we made our main
srcdirectory and anindex.tsfile where our server code will live.
2. Basic Express Server
// src/index.ts
import express from "express";
import cors from "cors";
import cookieParser from "cookie-parser";
const app = express();
app.use(express.json());
app.use(cookieParser());
app.use(cors({
origin: ["http://localhost:3000"], // your React app
credentials: true
}));
app.get("/", (req, res) => {
res.send("API is running...");
});
// Start server
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`);
});
Explanation of code (line by line):
- We import
express,cors, andcookie-parser. - We create an instance of
express()namedapp. - We add middleware:
express.json()to parse JSON in request bodies,cookieParser()to parse cookies, andcors()with a config that allows our React app onlocalhost:3000to talk to the server and send credentials (cookies). - We set up a test route (
"/") to check if the API is up. - We define a
PORT(default 5000) and start listening usingapp.listen.
3. Connecting MongoDB with Mongoose
import mongoose from "mongoose";
mongoose.connect(process.env.MONGODB_URI || "mongodb://127.0.0.1:27017/chat-app")
.then(() => console.log("MongoDB connected"))
.catch((err) => console.log(err));
Now your server is connected to MongoDB.
Explanation:
- We use
mongoose.connectwith a fallback local URI. thenandcatchlet us log success or errors.- Make sure your local MongoDB is running on port
27017or your remote URI is correct.
4. Basic User Model
// src/models/User.ts
import mongoose, { Schema, Document } from "mongoose";
import bcrypt from "bcrypt";
interface UserDocument extends Document {
username: string;
password: string;
comparePassword(password: string): Promise;
}
const userSchema = new Schema({
username: { type: String, required: true, unique: true },
password: { type: String, required: true },
});
// Before saving, hash the password
userSchema.pre("save", async function(next) {
if (!this.isModified("password")) return next();
this.password = await bcrypt.hash(this.password, 10);
next();
});
// Compare password method
userSchema.methods.comparePassword = function (password: string) {
return bcrypt.compare(password, this.password);
};
export default mongoose.model("User", userSchema);
Explanation (step by step):
- We import
mongooseandbcrypt. - We define a
UserDocumentinterface that extendsDocumentfrom Mongoose, specifying our user fields and a method signaturecomparePassword(). - We create a
userSchemawith fieldsusername(unique) andpassword. - We define a
pre("save")hook that will hash the user’s password before saving if it’s new or modified. - We define a
comparePasswordmethod that checks the given password against the stored (hashed) password usingbcrypt.compare(). - Finally, we export the model named
"User".
5. Authentication with JWT & Cookies
- Generate JWT (in a helper function):
import jwt from "jsonwebtoken"; export const generateToken = (id: string) => { return jwt.sign({ id }, process.env.JWT_SECRET || "secretkey", { expiresIn: "7d", }); }; - Auth routes:
// src/routes/auth.ts import express from "express"; import User from "../models/User"; import { generateToken } from "../utils/jwt"; const router = express.Router(); // Signup router.post("/signup", async (req, res) => { try { const { username, password } = req.body; if (!username || !password) { return res.status(400).json({ message: "Missing fields" }); } const user = new User({ username, password }); await user.save(); // create token const token = generateToken(user._id.toString()); // set cookie res.cookie("token", token, { httpOnly: true }); res.json({ message: "Signup successful", user }); } catch (err) { res.status(500).json({ message: err.message }); } }); // Login router.post("/login", async (req, res) => { try { const { username, password } = req.body; const user = await User.findOne({ username }); if (!user) return res.status(401).json({ message: "Invalid credentials" }); const isMatch = await user.comparePassword(password); if (!isMatch) return res.status(401).json({ message: "Invalid credentials" }); const token = generateToken(user._id.toString()); res.cookie("token", token, { httpOnly: true }); res.json({ message: "Login successful", user }); } catch (err) { res.status(500).json({ message: err.message }); } }); // Logout router.post("/logout", (req, res) => { res.clearCookie("token"); res.json({ message: "Logout successful" }); }); export default router;
Explanation:
- We created a
generateTokenfunction that signs a JWT for 7 days using a secret. - In the
/signuproute, we validate the username/password, create aUser, save to DB, then generate a JWT and send it back in a cookie (res.cookie("token", ...)). - In the
/loginroute, we find the user by username, compare passwords, then do the same JWT + cookie flow if they match. - In
/logout, we simply clear thetokencookie on the client side.
6. Sample API Routes (User, Chat, etc.)
- User route to get current user:
// src/routes/user.ts import express from "express"; import User from "../models/User"; import authMiddleware from "../middleware/auth"; const router = express.Router(); // Protected route to get current user router.get("/me", authMiddleware, async (req, res) => { const userId = (req as any).userId; const user = await User.findById(userId).select("-password"); res.json({ user }); }); export default router; - Chat route (placeholder):
// src/routes/chat.ts import express from "express"; const router = express.Router(); router.get("/my", async (req, res) => { // Return a list of chats for the logged in user res.json({ chats: [] }); }); // more CRUD for chats... export default router;
Explanation:
- We show an example
userroute that returns the current logged-in user if the request is authenticated (authMiddleware). - The
chatroute is a placeholder for more chat logic.
7. Project Structure Recap
chat-app/
├─ src/
│ ├─ index.ts
│ ├─ models/
│ │ └─ User.ts
│ ├─ routes/
│ │ ├─ auth.ts
│ │ ├─ user.ts
│ │ └─ chat.ts
│ ├─ middleware/
│ │ └─ auth.ts
│ ├─ utils/
│ │ └─ jwt.ts
│ └─ ...
└─ package.json
Explanation:
models/folder contains Mongoose models.routes/folder contains Express routers for different functionalities (auth, user, chat).middleware/can hold custom middleware for authentication, etc.utils/is for helpers likejwt.ts.
This completes Part 1 of our tutorial, setting up a basic Node.js + Express + Mongoose app with JWT cookie authentication and minimal routes.
3. Part 2: Socket.io, Real-Time Chat & More Backend
1. Setting up Socket.io on the Server
- Install Socket.io:
npm install socket.io - Modify your
index.ts:import http from "http"; import { Server } from "socket.io"; import app from "./app"; // if you separated Express app in app.ts const server = http.createServer(app); const io = new Server(server, { cors: { origin: ["http://localhost:3000"], credentials: true, }, }); // Socket.io connection io.on("connection", (socket) => { console.log("Socket connected:", socket.id); socket.on("disconnect", () => { console.log("Socket disconnected:", socket.id); }); }); const PORT = process.env.PORT || 5000; server.listen(PORT, () => { console.log(`Server running on port ${PORT}`); });
Explanation:
- We create an
httpserver from our Expressapp. - We bind Socket.io to that server and configure CORS.
- We listen for the
connectionevent and thedisconnectevent to track socket connections. - Finally, we start the server on the desired port.
2. Real-Time Events (Join Room, Send Message, Typing, etc.)
io.on("connection", (socket) => {
console.log("Socket connected:", socket.id);
socket.on("join_room", (roomId) => {
socket.join(roomId);
});
socket.on("send_message", (data) => {
// data = { roomId, message, senderId }
// broadcast to room
io.to(data.roomId).emit("receive_message", data);
});
socket.on("typing", (roomId) => {
socket.to(roomId).emit("typing_notification");
});
});
Explanation:
- When a client emits
"join_room", the server usessocket.join(roomId)to place that socket in a specific “room.” "send_message"event includes data likeroomIdandmessage. We then useio.to(roomId).emit(...)to broadcast to everyone in that room, so they can listen for"receive_message".- A
"typing"event can be sent to other members of the room to show a “typing” indicator.
3. Updating the Chat Model & Controllers
// src/models/Chat.ts
import mongoose, { Schema, Document } from "mongoose";
interface ChatDocument extends Document {
name?: string;
isGroup: boolean;
users: mongoose.Types.ObjectId[];
messages: {
sender: mongoose.Types.ObjectId;
content: string;
createdAt: Date;
}[];
}
const chatSchema = new Schema<ChatDocument>({
name: { type: String },
isGroup: { type: Boolean, default: false },
users: [{ type: Schema.Types.ObjectId, ref: "User" }],
messages: [
{
sender: { type: Schema.Types.ObjectId, ref: "User" },
content: { type: String },
createdAt: { type: Date, default: Date.now },
},
],
});
export default mongoose.model<ChatDocument>("Chat", chatSchema);
Then in your chat routes:
router.post("/create", authMiddleware, async (req, res) => {
// create new chat (one-to-one or group)
});
router.post("/send-message", authMiddleware, async (req, res) => {
// push message to chat model
});
Explanation:
- We define a
Chatmodel with a possiblename(for group chats), a booleanisGroup, an array ofusers(participants), and an array ofmessages. - Each
messageincludes thesender, thecontent, and acreatedAttimestamp. - In the routes, you can handle logic for creating a new chat or adding a new message to the existing chat’s
messagesarray.
4. Group Chats & Invitations
For group chats, you can store a isGroup: true and a name. You can add or remove members if you are the admin of that group.
Explanation:
- Typically, you’d have an endpoint to create a group, invite members, remove them, etc.
- You might also track roles (admin, moderator, etc.) for controlling group members.
5. Integrating Socket.io with Authentication
import cookie from "cookie";
import jwt from "jsonwebtoken";
io.use((socket, next) => {
const { token } = cookie.parse(socket.handshake.headers.cookie || "");
if (!token) return next(new Error("No auth token"));
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET || "secretkey");
(socket as any).userId = (decoded as any).id;
next();
} catch (err) {
next(new Error("Invalid token"));
}
});
Explanation:
- We use the
cookielibrary to parse thecookieheader from the client’s handshake. - We verify that token with
jwt.verify; if successful, we attachuserIdto the socket. - If it fails, we throw an error to block the connection.
6. File Uploads on the Backend
Install multer (or busboy) to handle file uploads (images, etc.). Then store or upload them to a service (e.g., Cloudinary, S3).
// example snippet
import multer from "multer";
const upload = multer({ dest: "uploads/" }); // or custom config
router.post("/message/attachments", authMiddleware, upload.array("files"), async (req, res) => {
// handle uploading
});
Explanation:
- We define a
multerupload instance with a destination folderuploads/. - In the route
POST /message/attachments, we can handle uploading multiple files from the client, store them, and add references to the chat’smessagesif needed.
7. Wrapping Up the Backend
At this point, you have:
- Express for routes
- Mongoose for MongoDB
- Auth with JWT & cookies
- Socket.io for real-time
- Possibly a Chat model and group chat logic
- Potential file upload integration
Part 2 ended here, focusing on the real-time socket integration and more advanced chat features on the backend.
4. Part 3: Redux Toolkit, RTK Query, & Frontend Integration
Below is Part 3 of the chat application tutorial, in English, covering how to integrate the frontend using React, Redux Toolkit, RTK Query (for fetching), Socket.io (client side), and so on.
1. Setting up the Redux Store
- Create a
store.ts(orstore.js) inside aredux/folder// src/redux/store.ts import { configureStore } from "@reduxjs/toolkit"; // Import your reducers here import authReducer from "./reducers/authSlice"; import miscReducer from "./reducers/miscSlice"; // ... Also import your RTK Query API if you're using one import { api } from "./api/api"; // example: RTK Query instance const store = configureStore({ reducer: { auth: authReducer, misc: miscReducer, // If you have RTK Query: [api.reducerPath]: api.reducer, }, middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(api.middleware), // if using RTK Query }); export default store; export type RootState = ReturnType<typeof store.getState>; export type AppDispatch = typeof store.dispatch; - Wrap your main React App with the
<Provider>// src/main.tsx or src/index.tsx import React from "react"; import ReactDOM from "react-dom/client"; import { Provider } from "react-redux"; import store from "./redux/store"; import App from "./App"; // or main router file ReactDOM.createRoot(document.getElementById("root")!).render( <Provider store={store}> <App /> </Provider> );
Explanation:
- We create our Redux store using
configureStore()from Redux Toolkit. - We combine our auth reducer and misc reducer, and optionally an RTK Query
api.reducer. - We then set up our
Providerso every component in our React app can useuseSelector,useDispatch, and other Redux features.
2. Creating the Auth Slice
// src/redux/reducers/authSlice.ts
import { createSlice } from "@reduxjs/toolkit";
interface AuthState {
user: any; // or define type
isAdmin: boolean;
loading: boolean;
}
const initialState: AuthState = {
user: null,
isAdmin: false,
loading: true,
};
const authSlice = createSlice({
name: "auth",
initialState,
reducers: {
// sets the user object if user is logged in
userExists(state, action) {
state.user = action.payload; // or handle more fields
state.loading = false;
},
// sets user to null if not logged in
userNotExists(state) {
state.user = null;
state.loading = false;
},
// if needed, to set isAdmin to true
setAdmin(state, action) {
state.isAdmin = !!action.payload;
},
// handle logging out
logout(state) {
state.user = null;
state.isAdmin = false;
},
},
});
export const {
userExists,
userNotExists,
setAdmin,
logout,
} = authSlice.actions;
export default authSlice.reducer;
Explanation:
- We create an interface
AuthStatewithuser,isAdmin, andloading. - Our
initialStatesetsusertonull,isAdmintofalse, andloadingtotrue(so we can show a loading spinner until we know the user’s status). - Our slice has reducers like
userExists(sets user data when the user is authenticated),userNotExists(clears user if not logged in),setAdmin(optionally sets admin mode), andlogoutto reset auth state. - We export those actions and the reducer.
3. Building a Miscellaneous (UI) Slice
// src/redux/reducers/miscSlice.ts
import { createSlice } from "@reduxjs/toolkit";
interface MiscState {
isNewGroup: boolean;
isMemberDialog: boolean;
isNotification: boolean;
isMobileMenu: boolean;
isSearch: boolean;
isFileMenu: boolean;
isDeleteMenu: boolean;
uploadingLoader: boolean;
selectedDeleteChat: {
chatId: string;
isGroup: boolean;
};
// etc.
}
const initialState: MiscState = {
isNewGroup: false,
isMemberDialog: false,
isNotification: false,
isMobileMenu: false,
isSearch: false,
isFileMenu: false,
isDeleteMenu: false,
uploadingLoader: false,
selectedDeleteChat: {
chatId: "",
isGroup: false,
},
};
const miscSlice = createSlice({
name: "misc",
initialState,
reducers: {
setIsNewGroup(state, action) {
state.isNewGroup = !!action.payload;
},
setIsMemberDialog(state, action) {
state.isMemberDialog = !!action.payload;
},
setIsNotification(state, action) {
state.isNotification = !!action.payload;
},
setIsMobileMenu(state, action) {
state.isMobileMenu = !!action.payload;
},
setIsSearch(state, action) {
state.isSearch = !!action.payload;
},
setIsFileMenu(state, action) {
state.isFileMenu = !!action.payload;
},
setIsDeleteMenu(state, action) {
state.isDeleteMenu = !!action.payload;
},
setUploadingLoader(state, action) {
state.uploadingLoader = !!action.payload;
},
setSelectedDeleteChat(state, action) {
// expecting { chatId: string, isGroup: boolean }
state.selectedDeleteChat = action.payload;
},
},
});
export const {
setIsNewGroup,
setIsMemberDialog,
setIsNotification,
setIsMobileMenu,
setIsSearch,
setIsFileMenu,
setIsDeleteMenu,
setUploadingLoader,
setSelectedDeleteChat,
} = miscSlice.actions;
export default miscSlice.reducer;
Explanation:
- This slice manages “miscellaneous” UI states, such as whether a new group dialog is open, if a user is searching, if a notification panel is open, or if a file upload is in progress (
uploadingLoader). - This helps keep these small states out of more complex slices.
4. Checking Auth on the Frontend (Using Axios or RTK Query)
We want to see if the user is already logged in (has a valid cookie or token). Typically:
- In your
Appor main layout, call an API endpointGET /api/v1/user/me. - If status 200, dispatch
userExists(user). - If status 401, dispatch
userNotExists().
Using Axios inside a useEffect:
// Example: src/pages/Home.tsx or a main layout
import axios from "axios";
import { userExists, userNotExists } from "../redux/reducers/authSlice";
import { useDispatch } from "react-redux";
import { useEffect } from "react";
import { server } from "../config"; // your env config
function Home() {
const dispatch = useDispatch();
useEffect(() => {
axios
.get(`${server}/api/v1/user/me`, { withCredentials: true })
.then((res) => {
// if successful, dispatch user data
dispatch(userExists(res.data.user));
})
.catch(() => {
// not logged in
dispatch(userNotExists());
});
}, []);
return (
<div>
{/* your home layout */}
</div>
);
}
export default Home;
Alternatively, using RTK Query:
// src/redux/api/authApi.ts
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
import { server } from "../../config";
export const authApi = createApi({
reducerPath: "authApi",
baseQuery: fetchBaseQuery({
baseUrl: `${server}/api/v1`,
credentials: "include",
}),
endpoints: (builder) => ({
getUserMe: builder.query({
query: () => `/user/me`,
}),
}),
});
export const { useGetUserMeQuery } = authApi;
function Home() {
const { data, error, isLoading } = useGetUserMeQuery(undefined);
useEffect(() => {
if (!isLoading) {
if (error) dispatch(userNotExists());
else if (data?.user) dispatch(userExists(data.user));
}
}, [data, error, isLoading]);
return <div>Home layout</div>;
}
Explanation:
- We either do a manual Axios call in a
useEffector use RTK Query’s generated hooks to fetch the current user. - If successful, we dispatch
userExists(...). If it fails (401 unauthorized), we dispatchuserNotExists(). - This ensures our Redux store knows whether we’re logged in.
5. Login / Signup (Dispatching Redux actions)
Simple login form example:
// src/pages/Login.tsx
import axios from "axios";
import { useState } from "react";
import { server } from "../config";
import { userExists } from "../redux/reducers/authSlice";
import { useDispatch } from "react-redux";
import { toast } from "react-hot-toast";
function Login() {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const dispatch = useDispatch();
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
try {
// Optionally set loader true
const { data } = await axios.post(
`${server}/api/v1/user/login`,
{ username, password },
{ withCredentials: true }
);
// If successful
dispatch(userExists(data.user));
toast.success(data?.message || "Logged in successfully");
} catch (err: any) {
toast.error(err?.response?.data?.message || "Invalid username or password");
} finally {
// Optionally stop loader
}
};
return (
<form onSubmit={handleLogin}>
<input
type="text"
placeholder="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button type="submit">Log In</button>
</form>
);
}
export default Login;
Explanation:
- We have a login form that captures
usernameandpassword. - On submit, we call our backend
loginendpoint with Axios (including{ withCredentials: true }to send and receive cookies). - If successful, we dispatch
userExists(data.user)and show a success toast. If an error occurs, we show an error toast.
6. Chat List with RTK Query
We want to get the user’s chat list from GET /api/v1/chat/my:
// src/redux/api/chatApi.ts
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
import { server } from "../../config";
export const chatApi = createApi({
reducerPath: "chatApi",
baseQuery: fetchBaseQuery({
baseUrl: `${server}/api/v1`,
credentials: "include",
}),
tagTypes: ["Chat", "User"],
endpoints: (builder) => ({
getMyChats: builder.query<any, void>({
query: () => `/chat/my`,
providesTags: ["Chat"],
}),
// for searching users, sending requests, etc.
}),
});
export const { useGetMyChatsQuery } = chatApi;
Then in your layout:
import { useGetMyChatsQuery } from "../redux/api/chatApi";
import { Skeleton } from "@mui/material";
function Layout() {
const { data, error, isLoading } = useGetMyChatsQuery();
if (isLoading) return <Skeleton variant="rectangular" width="100%" height={50} />;
if (error) {
return <div>Some error occurred</div>;
}
return (
<div>
{data?.chats?.map((chat: any) => (
<div key={chat._id}>{chat.name || "No Name"}</div>
))}
</div>
);
}
export default Layout;
Explanation:
- We define a
chatApiusingcreateApifrom RTK Query. We setbaseUrlandcredentials: "include"to handle cookies. - We create a query endpoint
getMyChatsthat fetches/chat/my. - Then in a component, we use the generated hook
useGetMyChatsQueryto fetch data and handle loading, error, or success states.
7. Search Users
An endpoint GET /api/v1/user/search?name=... returns an array of users. Using RTK Query:
getSearchUsers: builder.query<any, string>({
query: (name) => `/user/search?name=${name}`,
providesTags: ["User"],
}),
Then in your component:
const [triggerSearch] = useLazyGetSearchUsersQuery();
const handleSearch = async (name: string) => {
if (!name) return;
const result = await triggerSearch(name).unwrap();
// do something with result
};
Explanation:
- We create a “lazy” query with RTK Query so we can call
triggerSearch(name)only when the user actually types something in. - The
.unwrap()method helps us handle the raw data or catch errors easily in a try/catch block.
8. Sending Friend Requests (Mutation)
Example of an RTK Query mutation:
sendFriendRequest: builder.mutation<any, { userId: string }>({
query: (body) => ({
url: `/user/send-request`,
method: "POST",
body,
credentials: "include",
}),
invalidatesTags: ["User"],
}),
Usage:
const [sendFriendRequest, { isLoading }] = useSendFriendRequestMutation();
const handleSendRequest = async (userId: string) => {
try {
const result = await sendFriendRequest({ userId }).unwrap();
toast.success(result?.message || "Request sent");
} catch (err: any) {
toast.error(err?.data?.message || "Error sending request");
}
};
Explanation:
- We define a mutation
sendFriendRequestthat POSTs to/user/send-requestwith a JSON body. - We set
invalidatesTags: ["User"]to refetch or invalidate user data if needed. - In the component, we call
sendFriendRequest({ userId }), handle the result, and show toasts accordingly.
9. Sockets and Real-Time Messages
Socket.io client:
- Create a socket:
// src/socket.ts import { io } from "socket.io-client"; import { server } from "./config"; export const socket = io(server, { withCredentials: true, }); - Use it in your main layout or a context:
import { socket } from "../socket"; import { useEffect } from "react"; function ChatRoom({ chatId }) { useEffect(() => { socket.on("NEW_MESSAGE", (data) => { console.log("Received new message:", data); }); return () => { socket.off("NEW_MESSAGE"); }; }, []); const sendMessage = () => { socket.emit("NEW_MESSAGE", { chatId, message: "Hi!" }); }; return <button onClick={sendMessage}>Send Message</button>; } export default ChatRoom;
Explanation:
- We import
iofromsocket.io-clientand connect to our server URL. - We set
withCredentials: trueto include any cookie-based auth tokens. - We listen for a custom event (
"NEW_MESSAGE") and handle sending messages withsocket.emit.
10. Handling File Uploads (Attachments)
- Front-end:
const handleFileUpload = async (files: FileList) => { if (!files.length) return; const formData = new FormData(); formData.append("chatId", chatId); for (let i = 0; i < files.length; i++) { formData.append("files", files[i]); } try { const { data } = await axios.post( `${server}/api/v1/chat/message/attachments`, formData, { withCredentials: true, headers: { "Content-Type": "multipart/form-data" }, } ); toast.success(data?.message || "File(s) sent"); } catch (err: any) { toast.error(err?.response?.data?.message || "Error sending files"); } }; - Server: handle
POST /api/v1/chat/message/attachmentswithmulteror any file parser. Then broadcast with Socket.io to the chat’s members.
Explanation:
- In the frontend, we create a
FormDataobject, append each file, andPOSTto our/attachmentsendpoint. - Make sure to set the
Content-Typetomultipart/form-data. - The server can respond with success, and we can notify the user with a toast.
11. Additional Redux Patterns: createAsyncThunk
Example usage if you prefer a thunk over RTK Query:
// src/redux/thunks/admin.ts
import { createAsyncThunk } from "@reduxjs/toolkit";
import axios from "axios";
import { server } from "../../config";
export const adminLogin = createAsyncThunk(
"admin/login",
async (secretKey: string, { rejectWithValue }) => {
try {
const { data } = await axios.post(
`${server}/api/v1/admin/verify`,
{ secretKey },
{ withCredentials: true }
);
return data.admin;
} catch (err: any) {
return rejectWithValue(err?.response?.data?.message || "Admin Key is invalid");
}
}
);
Then handle in a slice with extraReducers.
Explanation:
createAsyncThunkis a Redux Toolkit method for handling async calls in a more “traditional” Redux approach, giving you pending/fulfilled/rejected states.- In your slice, you’d use
extraReducersto handle those states and update loading flags or store data as needed.
12. Putting it All Together
By now, we have:
- Backend: Node.js, Express, Mongoose, authentication routes, chat routes, file uploads, Socket.io for real-time.
- Frontend: React + Redux Toolkit, slices for auth/UI, RTK Query or Axios for requests, integrated Socket.io client for real-time messages.
- Additional admin or group chat features (friend requests, group management, attachments).
Congratulations! You now have a basic but fully functional real-time chat application. Feel free to expand on the UI/UX with additional styling frameworks, add more advanced features (read receipts, editing messages, push notifications, etc.), and finally deploy your app to platforms like Render, Railway, AWS, or your own Docker server.