React Socket.io: Real-Time Chat Application (2026)
Socket.io wraps WebSockets with automatic reconnection, room-based broadcasting, event acknowledgements and fallbacks for environments where WebSockets aren't available. This guide builds a full real-time chat — client setup, socket context, rooms, typing indicators, read receipts and auth middleware.
Table of Contents
Client Setup
npm install socket.io-client
// src/lib/socket.ts
import { io, Socket } from 'socket.io-client'
// Typed events — define server→client and client→server event maps
interface ServerToClientEvents {
'message:new': (message: Message) => void
'message:read': (data: { messageId: string; userId: string }) => void
'user:typing': (data: { userId: string; username: string; roomId: string }) => void
'user:stop-typing': (data: { userId: string; roomId: string }) => void
'user:online': (userId: string) => void
'user:offline': (userId: string) => void
'room:joined': (room: Room) => void
error: (message: string) => void
}
interface ClientToServerEvents {
'message:send': (data: { roomId: string; content: string }, ack: (msg: Message) => void) => void
'message:read': (messageId: string) => void
'room:join': (roomId: string) => void
'room:leave': (roomId: string) => void
'typing:start': (roomId: string) => void
'typing:stop': (roomId: string) => void
}
export type AppSocket = Socket<ServerToClientEvents, ClientToServerEvents>
// Create socket — don't connect until user is authenticated
export function createSocket(token: string): AppSocket {
return io(import.meta.env.VITE_WS_URL, {
auth: { token },
transports: ['websocket'], // Skip polling — faster initial connection
reconnectionAttempts: 10,
reconnectionDelay: 1000,
reconnectionDelayMax: 10000,
})
}
Socket Context
// src/context/SocketContext.tsx
import { createContext, useContext, useEffect, useState } from 'react'
import { createSocket, AppSocket } from '@/lib/socket'
interface SocketContextValue {
socket: AppSocket | null
isConnected: boolean
}
const SocketContext = createContext<SocketContextValue>({ socket: null, isConnected: false })
export function SocketProvider({ children, token }: { children: React.ReactNode; token: string | null }) {
const [socket, setSocket] = useState<AppSocket | null>(null)
const [isConnected, setIsConnected] = useState(false)
useEffect(() => {
if (!token) return
const s = createSocket(token)
setSocket(s)
s.on('connect', () => setIsConnected(true))
s.on('disconnect', () => setIsConnected(false))
s.on('error', (msg) => console.error('Socket error:', msg))
return () => {
s.disconnect()
setSocket(null)
setIsConnected(false)
}
}, [token])
return (
<SocketContext.Provider value={{ socket, isConnected }}>
{children}
</SocketContext.Provider>
)
}
export const useSocket = () => useContext(SocketContext)
Chat Component
function ChatRoom({ roomId }: { roomId: string }) {
const { socket } = useSocket()
const [messages, setMessages] = useState<Message[]>([])
const [input, setInput] = useState('')
const bottomRef = useRef<HTMLDivElement>(null)
// Join room on mount, leave on unmount
useEffect(() => {
if (!socket) return
socket.emit('room:join', roomId)
return () => { socket.emit('room:leave', roomId) }
}, [socket, roomId])
// Listen for new messages
useEffect(() => {
if (!socket) return
function onNewMessage(msg: Message) {
setMessages(prev => [...prev, msg])
// Scroll to bottom
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
// Mark as read if this room is active
socket.emit('message:read', msg.id)
}
socket.on('message:new', onNewMessage)
return () => { socket.off('message:new', onNewMessage) }
}, [socket])
async function sendMessage(e: React.FormEvent) {
e.preventDefault()
if (!socket || !input.trim()) return
// Emit with acknowledgement — server confirms message was saved
socket.emit('message:send', { roomId, content: input.trim() }, (savedMsg) => {
setMessages(prev => [...prev, savedMsg])
})
setInput('')
}
return (
<div className="chat-room">
<div className="messages">
{messages.map(msg => (
<MessageBubble key={msg.id} message={msg} />
))}
<div ref={bottomRef} />
</div>
<form onSubmit={sendMessage}>
<input value={input} onChange={e => setInput(e.target.value)} placeholder="Type a message..." />
<button type="submit">Send</button>
</form>
</div>
)
}
Rooms
// Server-side rooms — only members receive events
// server/index.ts
io.on('connection', (socket) => {
socket.on('room:join', (roomId) => {
socket.join(roomId)
socket.emit('room:joined', { id: roomId })
})
socket.on('message:send', async ({ roomId, content }, ack) => {
const msg = await db.message.create({
data: { roomId, content, authorId: socket.data.userId }
})
// Broadcast to everyone in the room including sender
io.to(roomId).emit('message:new', msg)
// Acknowledge to sender that message was saved
ack(msg)
})
})
// Client — switch rooms
function useRoom(roomId: string) {
const { socket } = useSocket()
useEffect(() => {
if (!socket) return
socket.emit('room:join', roomId)
return () => { socket.emit('room:leave', roomId) }
}, [socket, roomId])
}
Typing Indicators
// useTypingIndicator hook — debounced stop event
function useTypingIndicator(roomId: string) {
const { socket } = useSocket()
const [typingUsers, setTypingUsers] = useState<Map<string, string>>(new Map())
const stopTypingTimer = useRef<ReturnType<typeof setTimeout>>()
useEffect(() => {
if (!socket) return
socket.on('user:typing', ({ userId, username }) => {
setTypingUsers(prev => new Map(prev).set(userId, username))
})
socket.on('user:stop-typing', ({ userId }) => {
setTypingUsers(prev => {
const next = new Map(prev)
next.delete(userId)
return next
})
})
return () => {
socket.off('user:typing')
socket.off('user:stop-typing')
}
}, [socket])
function onInputChange(value: string) {
if (!socket) return
if (value) {
socket.emit('typing:start', roomId)
clearTimeout(stopTypingTimer.current)
stopTypingTimer.current = setTimeout(() => {
socket.emit('typing:stop', roomId)
}, 1500)
} else {
socket.emit('typing:stop', roomId)
}
}
const typingText = [...typingUsers.values()].join(', ')
const isTyping = typingUsers.size > 0
return { isTyping, typingText: isTyping ? `${typingText} is typing...` : '', onInputChange }
}
Authentication
// Server middleware — verify JWT before allowing connection
io.use(async (socket, next) => {
const token = socket.handshake.auth.token
if (!token) return next(new Error('Authentication required'))
try {
const payload = verifyJwt(token)
socket.data.userId = payload.sub
socket.data.username = payload.username
next()
} catch {
next(new Error('Invalid token'))
}
})
// Client — handle auth errors
socket.on('connect_error', (err) => {
if (err.message === 'Authentication required' || err.message === 'Invalid token') {
// Token expired — refresh and reconnect
refreshToken().then(newToken => {
socket.auth.token = newToken
socket.connect()
})
}
})
Server Reference
// Minimal Express + Socket.io server
import express from 'express'
import { createServer } from 'http'
import { Server } from 'socket.io'
import cors from 'cors'
const app = express()
app.use(cors({ origin: process.env.CLIENT_URL, credentials: true }))
const httpServer = createServer(app)
const io = new Server(httpServer, {
cors: { origin: process.env.CLIENT_URL, methods: ['GET', 'POST'] },
})
io.on('connection', (socket) => {
console.log(`User ${socket.data.userId} connected`)
socket.on('disconnect', () => {
io.emit('user:offline', socket.data.userId)
})
})
httpServer.listen(process.env.PORT ?? 4000)