I built a Discord-style chat system with Socket.io, React Query, and infinite scroll. It took me weeks to figure out how to make these technologies play nicely together. Here's what every tutorial skips.
The Problem Nobody Talks About
Socket.io tutorials show you how to send messages. React Query tutorials show you how to cache data. But nobody explains how to combine them without creating a mess of duplicate data, stale caches, and memory leaks.
Here's what we're building:
- Real-time message updates
- Infinite scroll that doesn't break with new messages
- Typing indicators that actually work
- Proper socket connection management (no memory leaks)
What we're NOT building (to keep this focused):
- Online/offline status - adds complexity, minimal value for an MVP
- Read receipts - requires database schema changes
- Session persistence - using regular auth tokens instead of session cookies to avoid the extra session management logic
Prerequisites
Before diving into code, understand how WebSockets actually work. These resources are worth your time:
- Socket.io Fundamentals - Best conceptual overview
- NestJS WebSockets - If you're using NestJS backend
The Architecture
Every chat needs three core features:
- Message history - REST API + React Query for caching
- Real-time updates - Socket.io for new messages
- Infinite scroll - IntersectionObserver + React Query's infinite queries
The trick is making them work together without fighting each other.
Socket Events You Actually Need
If you're coming from REST APIs, Socket.io's event system feels backwards. Here's the minimum viable setup:
Events to listen for (.on):
-
connect
- Socket connected -
disconnect
- Connection lost -
app_error
- Application-level errors (noterror
- that's for socket errors) -
new-message
- Incoming messages -
user-started-typing
- Typing indicators -
user-stopped-typing
- Clear typing indicators
Events to emit:
-
join-chat
- Subscribe to a chat room -
leave-chat
- Unsubscribe from a chat room -
start-typing
- Notify others you're typing -
stop-typing
- Clear your typing indicator
The Implementation
1. Socket Connection Management
The biggest mistake everyone makes: creating multiple socket connections. Here's a context that manages a single connection properly:
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { socket } from "../../../socket";
import { Chat } from "../types/chat";
type TypingData = {
userId: string;
chatId: string;
userName: string;
};
interface ChatContextType {
selectedChat: Chat | null;
selectChat: (chat: Chat) => void;
startTyping: () => void;
stopTyping: () => void;
typingUsers: Omit<TypingData, "chatId">[];
}
export const ChatContext = createContext<ChatContextType | undefined>(
undefined
);
export const ChatProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [selectedChat, setSelectedChat] = useState<Chat | null>(null);
const [typingUsers, setTypingUsers] = useState<
{ userId: string; userName: string }[]
>([]);
const isInitialized = useRef(false);
// Initialize socket connection ONCE
useEffect(() => {
if (isInitialized.current) return;
const handleError = (error: any) => {
console.error("Socket error:", error);
};
// Connect only if not already connected
if (!socket.connected) {
console.log("Connecting socket...");
socket.connect();
}
socket.on("connect", () => {
console.log("Socket connected:", socket.id);
});
socket.on("disconnect", (reason) => {
console.log("Socket disconnected:", reason);
});
socket.on("app_error", handleError);
isInitialized.current = true;
// Cleanup only on app unmount
return () => {
if (socket.connected) {
socket.off("connect");
socket.off("disconnect");
socket.off("app_error", handleError);
socket.disconnect();
isInitialized.current = false;
}
};
}, []);
// Handle typing indicators for selected chat
useEffect(() => {
if (!selectedChat) return;
const handleUserStartedTyping = (data: TypingData) => {
// Only show typing for current chat
if (data.chatId !== selectedChat._id) return;
setTypingUsers((prev) => {
// Prevent duplicates
if (!prev.some((user) => user.userId === data.userId)) {
return [...prev, { userId: data.userId, userName: data.userName }];
}
return prev;
});
};
const handleUserStoppedTyping = (data: Omit<TypingData, "userName">) => {
if (data.chatId !== selectedChat._id) return;
setTypingUsers((prev) =>
prev.filter((user) => user.userId !== data.userId)
);
};
socket.on("user-started-typing", handleUserStartedTyping);
socket.on("user-stopped-typing", handleUserStoppedTyping);
return () => {
socket.off("user-started-typing", handleUserStartedTyping);
socket.off("user-stopped-typing", handleUserStoppedTyping);
setTypingUsers([]); // Clear typing indicators on chat change
};
}, [selectedChat?._id]);
const selectChat = useCallback(
(chat: Chat) => {
if (!socket.connected) {
console.warn("Socket not connected. Cannot select chat.");
return;
}
// Leave previous chat room
if (selectedChat?._id) {
socket.emit("leave-chat", { chatId: selectedChat._id });
}
// Join new chat room
setSelectedChat(chat);
socket.emit("join-chat", { chatId: chat._id });
},
[selectedChat]
);
const startTyping = useCallback(() => {
if (!selectedChat) return;
socket.emit("start-typing", { chatId: selectedChat._id });
}, [selectedChat]);
const stopTyping = useCallback(() => {
if (!selectedChat) return;
socket.emit("stop-typing", { chatId: selectedChat._id });
}, [selectedChat]);
return (
<ChatContext.Provider
value={{ selectedChat, selectChat, startTyping, stopTyping, typingUsers }}
>
{children}
</ChatContext.Provider>
);
};
export const useChat = () => {
const context = useContext(ChatContext);
if (!context) {
throw new Error("useChat must be used within ChatProvider");
}
return context;
};
2. Infinite Scroll with React Query
The key insight: use React Query for data fetching, but invalidate on socket events.
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import apiClient from "../../auth/services/auth";
import { Chat } from "../types/chat";
import { Message } from "../types/message";
interface MessageResponse {
data: {
messages: Message[];
currentPage: number;
hasNextPage: boolean;
totalPages: number;
totalCount: number;
};
}
export const useInfiniteMessages = (chatId: string, limit: number = 20) => {
return useInfiniteQuery<MessageResponse>({
queryKey: ["messages", chatId, "infinite"],
queryFn: ({ pageParam = 1 }) =>
apiClient.get(`/message/chat/${chatId}`, {
params: {
page: pageParam,
limit,
},
}),
getNextPageParam: (lastPage, allPages) => {
// Only fetch next page if current page has messages
const nextPage = lastPage.data.messages.length
? allPages.length + 1
: undefined;
return nextPage;
},
initialPageParam: 1,
enabled: !!chatId, // Don't fetch until chat is selected
});
};
3. Smart Typing Indicators
Most typing indicators suck. They either never clear or clear too quickly. Here's a hook that handles both:
import { useCallback, useEffect, useRef } from "react";
import { useChat } from "../context/ChatContext";
export const useTypingIndicator = (typingTimeout = 3000) => {
const { startTyping, stopTyping } = useChat();
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const isTypingRef = useRef(false);
const clearTypingTimeout = useCallback(() => {
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
typingTimeoutRef.current = null;
}
}, []);
const handleTyping = useCallback(
(value: string) => {
// Start typing indicator on first character
if (value.length > 0 && !isTypingRef.current) {
startTyping();
isTypingRef.current = true;
}
// Reset timeout on each keystroke
clearTypingTimeout();
if (value.length > 0) {
// Stop typing after 3 seconds of inactivity
typingTimeoutRef.current = setTimeout(() => {
stopTyping();
isTypingRef.current = false;
}, typingTimeout);
} else {
// Stop immediately if input is cleared
stopTyping();
isTypingRef.current = false;
}
},
[startTyping, stopTyping, clearTypingTimeout, typingTimeout]
);
const handleStopTyping = useCallback(() => {
clearTypingTimeout();
if (isTypingRef.current) {
stopTyping();
isTypingRef.current = false;
}
}, [stopTyping, clearTypingTimeout]);
// Cleanup on unmount
useEffect(() => {
return () => {
clearTypingTimeout();
if (isTypingRef.current) {
stopTyping();
}
};
}, [clearTypingTimeout, stopTyping]);
return { handleTyping, handleStopTyping };
};
4. Connecting Socket Events to React Query
This is where everything comes together. When a new message arrives via socket, invalidate the React Query cache:
useEffect(() => {
if (!socket || !selectedChat?._id) return;
const handleNewMessage = (data: { message: any; type: string }) => {
// Invalidate cache to trigger refetch
queryClient.invalidateQueries({
queryKey: ["messages", selectedChat._id],
});
};
socket.on("new-message", handleNewMessage);
return () => {
socket.off("new-message", handleNewMessage);
handleStopTyping(); // Clean up typing indicator
};
}, [socket, selectedChat?._id, queryClient, handleStopTyping]);
5. Infinite Scroll That Doesn't Break
The hardest part: maintaining scroll position when loading older messages. Here's an IntersectionObserver implementation that actually works:
useEffect(() => {
const trigger = loadMoreTriggerRef.current;
if (!trigger) return;
const observer = new IntersectionObserver(
(entries) => {
const [entry] = entries;
if (
entry.isIntersecting &&
hasNextPage &&
!isFetchingNextPage &&
!isSmoothScrolling // Prevent loading during auto-scroll
) {
// Store current scroll position
const container = messagesContainerRef.current;
if (container) {
const scrollHeight = container.scrollHeight;
const scrollTop = container.scrollTop;
fetchNextPage().then(() => {
// Maintain scroll position after content loads
requestAnimationFrame(() => {
if (container) {
const newScrollHeight = container.scrollHeight;
const heightDifference = newScrollHeight - scrollHeight;
// Adjust scroll to keep current messages in view
container.scrollTop = scrollTop + heightDifference;
}
});
});
}
}
},
{
root: messagesContainerRef.current,
rootMargin: "50px", // Trigger before reaching the top
threshold: 0,
}
);
observer.observe(trigger);
return () => {
observer.disconnect();
};
}, [hasNextPage, isFetchingNextPage, isSmoothScrolling, fetchNextPage]);
Common Pitfalls and Solutions
1. Multiple socket connections
- Store socket instance in a separate file, import everywhere
- Use React context for connection management
2. Stale closures in socket handlers
- Always clean up listeners in useEffect returns
- Be careful with dependencies
3. Infinite scroll jumping
- Store scroll position before fetch
- Restore after content loads using requestAnimationFrame
4. Typing indicators not clearing
- Use timeouts with refs
- Clear on component unmount
- Clear when switching chats
Performance Optimizations
- Debounce typing events - Don't emit on every keystroke
- Virtualize long message lists - Use react-window for 1000+ messages
- Lazy load images - Use IntersectionObserver for image previews
- Batch socket events - Group rapid updates to reduce re-renders
Resources
- IntersectionObserver Deep Dive - For infinite scroll mastery
- Socket.io Client Options - Connection optimization
Next Steps
This setup handles the basics. To production-ready it:
- Add reconnection logic with exponential backoff
- Implement message queue for offline support
- Add encryption for sensitive messages
- Build message search with Elasticsearch
- Add file upload with progress tracking
The hardest part isn't the individual features - it's making them work together without creating a mess. This architecture scales to thousands of concurrent users without major changes.
Questions or improvements? Drop them in the comments.
Top comments (0)