With the Socket.io server running and emitting events, React needs to connect to it, listen for events, and update state in response. Managing a socket connection in React means handling the connection lifecycle — connecting when the component mounts, cleaning up listeners and disconnecting when it unmounts, and re-joining rooms when the relevant prop (postId) changes. Building this as a custom hook — useSocket — keeps the component code clean and makes the socket behaviour reusable across any component that needs real-time features.
The useSocket Custom Hook
// src/hooks/useSocket.js
import { useEffect, useRef, useCallback } from 'react';
import { io } from 'socket.io-client';
const SOCKET_URL = import.meta.env.VITE_API_URL?.replace('/api', '') || 'http://localhost:5000';
// Singleton socket — shared across all hook instances in the same tab
let socketInstance = null;
const getSocket = () => {
if (!socketInstance) {
socketInstance = io(SOCKET_URL, {
autoConnect: true,
reconnection: true,
reconnectionAttempts: 5,
reconnectionDelay: 1000,
transports: ['websocket', 'polling'], // try WebSocket first, fall back to polling
});
}
return socketInstance;
};
export function useSocket() {
const socket = getSocket();
return socket;
}
// ── Specialised hook for post-level real-time events ─────────────────────────
export function usePostSocket(postId) {
const socket = getSocket();
const postRoom = `post:${postId}`;
// Join room when postId changes, leave on cleanup
useEffect(() => {
if (!postId || !socket.connected) return;
const joinRoom = () => {
socket.emit('join-post', { postId });
};
// Join on connect and reconnect
socket.on('connect', joinRoom);
if (socket.connected) joinRoom();
return () => {
socket.off('connect', joinRoom);
socket.emit('leave-post', { postId });
};
}, [postId, socket]);
// ── Listen helper — adds and removes listeners cleanly ───────────────────
const on = useCallback((event, handler) => {
socket.on(event, handler);
return () => socket.off(event, handler);
}, [socket]);
return { socket, on };
}
socketInstance module-level variable) ensures the React app has one socket connection regardless of how many components call useSocket(). Creating a new socket per component would open multiple connections to the server, multiplying bandwidth usage and making it impossible to share the connection state between components. The singleton is created once on first call and reused for the lifetime of the tab.autoConnect: true connects immediately on creation. If you want to delay connection until the user is authenticated, set autoConnect: false and call socket.connect() manually after authentication. You can disconnect with socket.disconnect() on logout and reconnect on next login. Update the auth token on the socket’s auth object before reconnecting: socket.auth = { token }; socket.connect().socket.off(event, handler) cleanup requires passing the exact same function reference that was passed to socket.on(). If you define the handler inside a useEffect, it creates a new reference each render — the cleanup will remove a different function and the original listener will leak. Either define handlers outside the effect, use useCallback, or store the exact reference in a ref.Live Comments on the PostDetailPage
// src/pages/PostDetailPage.jsx — with live comments
import { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { usePostSocket } from '@/hooks/useSocket';
import postService from '@/services/postService';
import api from '@/services/api';
function PostDetailPage() {
const { id } = useParams();
const { on } = usePostSocket(id); // join room, get listener helper
const [post, setPost] = useState(null);
const [comments, setComments] = useState([]);
const [loading, setLoading] = useState(true);
// Fetch post and initial comments
useEffect(() => {
const fetchData = async () => {
const [postRes, commentsRes] = await Promise.all([
postService.getById(id),
api.get(`/posts/${id}/comments`),
]);
setPost(postRes.data.data);
setComments(commentsRes.data.data);
setLoading(false);
};
fetchData();
}, [id]);
// Listen for real-time new comments
useEffect(() => {
const cleanup = on('new-comment', ({ comment }) => {
setComments(prev => {
// Deduplicate — avoid showing same comment twice
if (prev.some(c => c._id === comment._id)) return prev;
return [...prev, comment]; // add to end
});
});
return cleanup; // remove listener on unmount or id change
}, [on, id]);
if (loading) return <Spinner />;
if (!post) return null;
return (
<article>
<h1>{post.title}</h1>
<CommentSection postId={id} comments={comments} setComments={setComments} />
</article>
);
}
Typing Indicator Component
// src/components/posts/TypingIndicator.jsx
import { useState, useEffect, useRef } from 'react';
import { usePostSocket } from '@/hooks/useSocket';
import { useAuth } from '@/context/AuthContext';
function TypingIndicator({ postId }) {
const { on, socket } = usePostSocket(postId);
const { user } = useAuth();
const [typingUsers, setTypingUsers] = useState([]);
const typingTimer = useRef(null);
// Listen for typing events from other users
useEffect(() => {
const cleanup = on('user-typing', ({ userName, isTyping }) => {
setTypingUsers(prev =>
isTyping
? prev.includes(userName) ? prev : [...prev, userName]
: prev.filter(u => u !== userName)
);
});
return cleanup;
}, [on]);
// Emit typing events when this user types
const handleTyping = () => {
socket.emit('typing', { postId, userName: user?.name, isTyping: true });
clearTimeout(typingTimer.current);
typingTimer.current = setTimeout(() => {
socket.emit('typing', { postId, userName: user?.name, isTyping: false });
}, 2000); // stop indicator 2s after last keystroke
};
return (
<>
{typingUsers.length > 0 && (
<p className="typing-indicator">
{typingUsers.join(', ')} {typingUsers.length === 1 ? 'is' : 'are'} typing...
</p>
)}
{/* Attach handleTyping to the comment textarea onChange */}
</>
);
}
Common Mistakes
Mistake 1 — Creating a new socket per component
❌ Wrong — new connection per component:
function PostPage({ postId }) {
const socket = io('http://localhost:5000'); // NEW connection every render!
// 10 components = 10 connections
}
✅ Correct — singleton pattern, one connection per tab:
const socket = getSocket(); // reuses existing connection ✓
Mistake 2 — Forgetting to remove event listeners
❌ Wrong — listener accumulates on re-renders:
useEffect(() => {
socket.on('new-comment', handler); // adds a NEW listener every time effect runs
// With [id] dep: every postId change adds another listener
}, [id]);
✅ Correct — remove before adding, or clean up in return:
useEffect(() => {
socket.on('new-comment', handler);
return () => socket.off('new-comment', handler); // ✓ cleanup
}, [id]);
Mistake 3 — Not deduplicating comments on the socket event
❌ Wrong — comment poster sees it twice (API response + socket event):
socket.on('new-comment', ({ comment }) => {
setComments(prev => [...prev, comment]); // always appends — duplicates!
});
✅ Correct — check for existing ID before appending:
socket.on('new-comment', ({ comment }) => {
setComments(prev =>
prev.some(c => c._id === comment._id) ? prev : [...prev, comment]
); // ✓ no duplicates
});
Quick Reference
| Task | Code |
|---|---|
| Connect | io(SOCKET_URL, { transports: ['websocket'] }) |
| Listen for event | socket.on('new-comment', handler) |
| Remove listener | socket.off('new-comment', handler) |
| Emit event | socket.emit('join-post', { postId }) |
| Check connected | socket.connected |
| Disconnect | socket.disconnect() |
| Reconnect | socket.connect() |