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.

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)