DEV Community

Michal
Michal

Posted on

Building Real-time Chat with Socket.io and React Query

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:

The Architecture

Every chat needs three core features:

  1. Message history - REST API + React Query for caching
  2. Real-time updates - Socket.io for new messages
  3. 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 (not error - 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;
};
Enter fullscreen mode Exit fullscreen mode

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
    });
};
Enter fullscreen mode Exit fullscreen mode

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 };
};
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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

  1. Debounce typing events - Don't emit on every keystroke
  2. Virtualize long message lists - Use react-window for 1000+ messages
  3. Lazy load images - Use IntersectionObserver for image previews
  4. Batch socket events - Group rapid updates to reduce re-renders

Resources

Next Steps

This setup handles the basics. To production-ready it:

  1. Add reconnection logic with exponential backoff
  2. Implement message queue for offline support
  3. Add encryption for sensitive messages
  4. Build message search with Elasticsearch
  5. 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)