Build a Real-Time Chat App with React and WebSockets
Step-by-step tutorial to build a real-time chat application using React, Express, and WebSockets with typing indicators, reconnection, and message history.
Nothing teaches you WebSockets like building a chat app. It's the "Hello, World" of real-time development, and by the end of this tutorial, you'll have a working chat application with user nicknames, typing indicators, message history, and automatic reconnection. More importantly, you'll understand the patterns that apply to any real-time feature: live notifications, collaborative editing, multiplayer games, or live dashboards.
We're using React for the frontend, Express with the ws library for the backend, and no abstractions like Socket.io. You'll see exactly what's happening at the WebSocket protocol level.
Project Setup
Create the project structure:
mkdir realtime-chat && cd realtime-chat
mkdir server client
Backend Setup
cd server
npm init -y
npm install express ws uuid
Frontend Setup
cd ../client
npm create vite@latest . -- --template react
npm install
Your structure should look like:
realtime-chat/
server/
package.json
index.js
client/
src/
App.jsx
components/
ChatRoom.jsx
MessageList.jsx
MessageInput.jsx
TypingIndicator.jsx
NicknameForm.jsx
hooks/
useWebSocket.js
package.json
The WebSocket Server
Let's start with the backend. The server needs to accept WebSocket connections, broadcast messages to all connected clients, track connected users, and handle typing indicators.
// server/index.js
const express = require('express');
const { WebSocketServer } = require('ws');
const http = require('http');
const { v4: uuidv4 } = require('uuid');
const app = express();
const server = http.createServer(app);
const wss = new WebSocketServer({ server });
// Store connected clients and message history
const clients = new Map(); // ws -> { id, nickname }
const messageHistory = []; // Keep last 50 messages
const MAX_HISTORY = 50;
function broadcast(data, excludeWs = null) {
const message = JSON.stringify(data);
wss.clients.forEach((client) => {
if (client !== excludeWs && client.readyState === 1) {
client.send(message);
}
});
}
function broadcastUserList() {
const users = Array.from(clients.values()).map((c) => ({
id: c.id,
nickname: c.nickname,
}));
broadcast({ type: 'user_list', users });
}
wss.on('connection', (ws) => {
const clientId = uuidv4();
clients.set(ws, { id: clientId, nickname: null });
// Send client their ID and message history
ws.send(
JSON.stringify({
type: 'welcome',
clientId,
history: messageHistory,
})
);
ws.on('message', (raw) => {
let data;
try {
data = JSON.parse(raw);
} catch {
return; // Ignore malformed messages
}
const client = clients.get(ws);
switch (data.type) {
case 'set_nickname': {
client.nickname = data.nickname.trim().slice(0, 30);
broadcastUserList();
broadcast({
type: 'system',
content: ${client.nickname} joined the chat,
timestamp: Date.now(),
});
break;
}
case 'chat_message': {
if (!client.nickname) return;
const message = {
type: 'chat_message',
id: uuidv4(),
senderId: client.id,
nickname: client.nickname,
content: data.content.slice(0, 1000), // Limit message length
timestamp: Date.now(),
};
messageHistory.push(message);
if (messageHistory.length > MAX_HISTORY) {
messageHistory.shift();
}
broadcast(message);
break;
}
case 'typing_start': {
if (!client.nickname) return;
broadcast(
{
type: 'typing_start',
nickname: client.nickname,
senderId: client.id,
},
ws
);
break;
}
case 'typing_stop': {
if (!client.nickname) return;
broadcast(
{
type: 'typing_stop',
senderId: client.id,
},
ws
);
break;
}
}
});
ws.on('close', () => {
const client = clients.get(ws);
if (client?.nickname) {
broadcast({
type: 'system',
content: ${client.nickname} left the chat,
timestamp: Date.now(),
});
}
clients.delete(ws);
broadcastUserList();
});
ws.on('error', (error) => {
console.error('WebSocket error:', error.message);
clients.delete(ws);
});
});
// Health check endpoint
app.get('/health', (req, res) => {
res.json({ status: 'ok', clients: clients.size });
});
const PORT = process.env.PORT || 3001;
server.listen(PORT, () => {
console.log(Server running on port ${PORT});
});
Key design decisions:
- We use a
Mapto associate each WebSocket connection with client metadata (ID, nickname). This makes lookups O(1). - Messages are JSON with a
typefield. This is the standard pattern for multiplexing different message kinds over a single WebSocket connection. - Message history is capped at 50 messages. In production, you'd store this in a database.
- We broadcast user lists after every join/leave, not on every message. This reduces unnecessary traffic.
The WebSocket Hook
On the frontend, we'll create a custom hook that manages the WebSocket connection, handles reconnection, and provides a clean API to the components.
// client/src/hooks/useWebSocket.js
import { useState, useEffect, useRef, useCallback } from 'react';
const WS_URL = 'ws://localhost:3001';
const RECONNECT_DELAY = 2000;
const MAX_RECONNECT_DELAY = 30000;
export function useWebSocket() {
const [messages, setMessages] = useState([]);
const [users, setUsers] = useState([]);
const [isConnected, setIsConnected] = useState(false);
const [clientId, setClientId] = useState(null);
const [typingUsers, setTypingUsers] = useState(new Map());
const wsRef = useRef(null);
const reconnectAttempts = useRef(0);
const reconnectTimeout = useRef(null);
const typingTimeouts = useRef(new Map());
const connect = useCallback(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) return;
const ws = new WebSocket(WS_URL);
wsRef.current = ws;
ws.onopen = () => {
setIsConnected(true);
reconnectAttempts.current = 0;
console.log('Connected to chat server');
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
switch (data.type) {
case 'welcome':
setClientId(data.clientId);
setMessages(data.history || []);
break;
case 'chat_message':
setMessages((prev) => [...prev, data]);
break;
case 'system':
setMessages((prev) => [
...prev,
{ type: 'system', content: data.content, timestamp: data.timestamp },
]);
break;
case 'user_list':
setUsers(data.users);
break;
case 'typing_start':
setTypingUsers((prev) => {
const next = new Map(prev);
next.set(data.senderId, data.nickname);
// Auto-clear typing after 3 seconds
if (typingTimeouts.current.has(data.senderId)) {
clearTimeout(typingTimeouts.current.get(data.senderId));
}
typingTimeouts.current.set(
data.senderId,
setTimeout(() => {
setTypingUsers((p) => {
const n = new Map(p);
n.delete(data.senderId);
return n;
});
}, 3000)
);
return next;
});
break;
case 'typing_stop':
setTypingUsers((prev) => {
const next = new Map(prev);
next.delete(data.senderId);
return next;
});
if (typingTimeouts.current.has(data.senderId)) {
clearTimeout(typingTimeouts.current.get(data.senderId));
}
break;
}
};
ws.onclose = () => {
setIsConnected(false);
wsRef.current = null;
// Exponential backoff reconnection
const delay = Math.min(
RECONNECT_DELAY * Math.pow(2, reconnectAttempts.current),
MAX_RECONNECT_DELAY
);
reconnectAttempts.current += 1;
console.log(Disconnected. Reconnecting in ${delay}ms...);
reconnectTimeout.current = setTimeout(connect, delay);
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
}, []);
useEffect(() => {
connect();
return () => {
if (reconnectTimeout.current) {
clearTimeout(reconnectTimeout.current);
}
if (wsRef.current) {
wsRef.current.close();
}
typingTimeouts.current.forEach((timeout) => clearTimeout(timeout));
};
}, [connect]);
const sendMessage = useCallback((content) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(
JSON.stringify({ type: 'chat_message', content })
);
}
}, []);
const setNickname = useCallback((nickname) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(
JSON.stringify({ type: 'set_nickname', nickname })
);
}
}, []);
const sendTypingStart = useCallback(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'typing_start' }));
}
}, []);
const sendTypingStop = useCallback(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'typing_stop' }));
}
}, []);
return {
messages,
users,
isConnected,
clientId,
typingUsers,
sendMessage,
setNickname,
sendTypingStart,
sendTypingStop,
};
}
The reconnection logic uses exponential backoff: 2 seconds, 4 seconds, 8 seconds, 16 seconds, capping at 30 seconds. This prevents overwhelming the server if it goes down and hundreds of clients try to reconnect simultaneously.
React Components
The Nickname Form
Before chatting, users need to pick a nickname:
// client/src/components/NicknameForm.jsx
import { useState } from 'react';
export function NicknameForm({ onSubmit }) {
const [nickname, setNickname] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
const trimmed = nickname.trim();
if (trimmed.length >= 2 && trimmed.length <= 30) {
onSubmit(trimmed);
}
};
return (
<div className="nickname-form">
<h2>Join the Chat</h2>
<form onSubmit={handleSubmit}>
<input
type="text"
value={nickname}
onChange={(e) => setNickname(e.target.value)}
placeholder="Enter your nickname"
maxLength={30}
autoFocus
/>
<button type="submit" disabled={nickname.trim().length < 2}>
Join
</button>
</form>
</div>
);
}
The Message List
// client/src/components/MessageList.jsx
import { useEffect, useRef } from 'react';
export function MessageList({ messages, clientId }) {
const bottomRef = useRef(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const formatTime = (timestamp) => {
return new Date(timestamp).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
});
};
return (
<div className="message-list">
{messages.map((msg, index) => {
if (msg.type === 'system') {
return (
<div key={index} className="message system">
<span className="system-text">{msg.content}</span>
</div>
);
}
const isOwn = msg.senderId === clientId;
return (
<div
key={msg.id || index}
className={message ${isOwn ? 'own' : 'other'}}
>
{!isOwn && <span className="nickname">{msg.nickname}</span>}
<div className="bubble">
<p>{msg.content}</p>
<span className="time">{formatTime(msg.timestamp)}</span>
</div>
</div>
);
})}
<div ref={bottomRef} />
</div>
);
}
The bottomRef trick is the standard way to auto-scroll to the latest message. When messages changes, we scroll the invisible div at the bottom into view.
The Message Input
// client/src/components/MessageInput.jsx
import { useState, useRef, useCallback } from 'react';
export function MessageInput({ onSend, onTypingStart, onTypingStop }) {
const [content, setContent] = useState('');
const typingTimer = useRef(null);
const isTyping = useRef(false);
const handleTyping = useCallback(() => {
if (!isTyping.current) {
isTyping.current = true;
onTypingStart();
}
// Reset the stop timer
if (typingTimer.current) {
clearTimeout(typingTimer.current);
}
typingTimer.current = setTimeout(() => {
isTyping.current = false;
onTypingStop();
}, 1500);
}, [onTypingStart, onTypingStop]);
const handleSubmit = (e) => {
e.preventDefault();
const trimmed = content.trim();
if (!trimmed) return;
onSend(trimmed);
setContent('');
// Stop typing indicator immediately on send
if (typingTimer.current) {
clearTimeout(typingTimer.current);
}
isTyping.current = false;
onTypingStop();
};
const handleKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
};
return (
<form className="message-input" onSubmit={handleSubmit}>
<textarea
value={content}
onChange={(e) => {
setContent(e.target.value);
handleTyping();
}}
onKeyDown={handleKeyDown}
placeholder="Type a message..."
rows={1}
maxLength={1000}
/>
<button type="submit" disabled={!content.trim()}>
Send
</button>
</form>
);
}
The typing indicator logic: when the user starts typing, we send a typing_start event. We set a 1.5-second timer. If the user keeps typing, we reset the timer. If they stop for 1.5 seconds, we send typing_stop. This debouncing prevents flooding the server with typing events on every keystroke.
The Typing Indicator
// client/src/components/TypingIndicator.jsx
export function TypingIndicator({ typingUsers }) {
const names = Array.from(typingUsers.values());
if (names.length === 0) return null;
let text;
if (names.length === 1) {
text = ${names[0]} is typing...;
} else if (names.length === 2) {
text = ${names[0]} and ${names[1]} are typing...;
} else {
text = ${names.length} people are typing...;
}
return <div className="typing-indicator">{text}</div>;
}
The Chat Room
// client/src/components/ChatRoom.jsx
import { MessageList } from './MessageList';
import { MessageInput } from './MessageInput';
import { TypingIndicator } from './TypingIndicator';
export function ChatRoom({
messages,
users,
clientId,
typingUsers,
isConnected,
onSend,
onTypingStart,
onTypingStop,
}) {
return (
<div className="chat-room">
<div className="sidebar">
<h3>Online ({users.length})</h3>
<ul>
{users.map((user) => (
<li key={user.id} className={user.id === clientId ? 'self' : ''}>
{user.nickname} {user.id === clientId ? '(you)' : ''}
</li>
))}
</ul>
</div>
<div className="chat-main">
<div className="chat-header">
<h2>Chat Room</h2>
<span className={status ${isConnected ? 'online' : 'offline'}}>
{isConnected ? 'Connected' : 'Reconnecting...'}
</span>
</div>
<MessageList messages={messages} clientId={clientId} />
<TypingIndicator typingUsers={typingUsers} />
<MessageInput
onSend={onSend}
onTypingStart={onTypingStart}
onTypingStop={onTypingStop}
/>
</div>
</div>
);
}
Putting It Together
// client/src/App.jsx
import { useState } from 'react';
import { useWebSocket } from './hooks/useWebSocket';
import { NicknameForm } from './components/NicknameForm';
import { ChatRoom } from './components/ChatRoom';
import './App.css';
function App() {
const [nickname, setNickname] = useState(null);
const ws = useWebSocket();
const handleJoin = (name) => {
setNickname(name);
ws.setNickname(name);
};
if (!nickname) {
return <NicknameForm onSubmit={handleJoin} />;
}
return (
<ChatRoom
messages={ws.messages}
users={ws.users}
clientId={ws.clientId}
typingUsers={ws.typingUsers}
isConnected={ws.isConnected}
onSend={ws.sendMessage}
onTypingStart={ws.sendTypingStart}
onTypingStop={ws.sendTypingStop}
/>
);
}
export default App;
Styling
Here's minimal CSS to make it look like a chat app:
/ client/src/App.css /
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #1a1a2e;
color: #e0e0e0;
height: 100vh;
}
#root { height: 100%; }
.nickname-form {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 1rem;
}
.nickname-form form {
display: flex;
gap: 0.5rem;
}
.nickname-form input {
padding: 0.75rem 1rem;
border: 1px solid #333;
border-radius: 8px;
background: #16213e;
color: #e0e0e0;
font-size: 1rem;
width: 250px;
}
.chat-room {
display: flex;
height: 100%;
}
.sidebar {
width: 200px;
background: #16213e;
padding: 1rem;
border-right: 1px solid #333;
overflow-y: auto;
}
.sidebar ul { list-style: none; }
.sidebar li { padding: 0.4rem 0; font-size: 0.9rem; }
.sidebar li.self { color: #4fc3f7; font-weight: bold; }
.chat-main {
flex: 1;
display: flex;
flex-direction: column;
}
.chat-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid #333;
}
.status.online { color: #4caf50; }
.status.offline { color: #f44336; }
.message-list {
flex: 1;
overflow-y: auto;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.message.system {
text-align: center;
color: #888;
font-size: 0.85rem;
padding: 0.25rem;
}
.message.own { align-self: flex-end; }
.message.other { align-self: flex-start; }
.nickname {
font-size: 0.75rem;
color: #4fc3f7;
margin-bottom: 2px;
display: block;
}
.bubble {
background: #16213e;
padding: 0.5rem 0.75rem;
border-radius: 12px;
max-width: 400px;
word-wrap: break-word;
}
.message.own .bubble { background: #0a3d62; }
.time {
font-size: 0.7rem;
color: #888;
margin-left: 0.5rem;
}
.typing-indicator {
padding: 0.25rem 1rem;
font-size: 0.85rem;
color: #888;
font-style: italic;
}
.message-input {
display: flex;
gap: 0.5rem;
padding: 1rem;
border-top: 1px solid #333;
}
.message-input textarea {
flex: 1;
padding: 0.75rem;
border: 1px solid #333;
border-radius: 8px;
background: #16213e;
color: #e0e0e0;
font-size: 1rem;
resize: none;
font-family: inherit;
}
button {
padding: 0.75rem 1.5rem;
background: #0a3d62;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 1rem;
}
button:disabled { opacity: 0.5; cursor: not-allowed; }
button:hover:not(:disabled) { background: #0c4b78; }
Running It
Open two terminal windows:
# Terminal 1 -- server
cd server
node index.js
# Terminal 2 -- client
cd client
npm run dev
Open http://localhost:5173 in two browser tabs. Pick different nicknames in each tab and start chatting. You'll see messages appear in real time, typing indicators when the other user types, and connection status updates.
Reconnection Logic
The reconnection logic in our hook handles the common failure scenarios:
- Server restarts: The client detects the close event and starts reconnecting with exponential backoff. Once the server is back, the connection re-establishes automatically.
- Network drops: Same behavior. The WebSocket closes, the client retries until the network recovers.
- Tab sleep: Some browsers throttle or close WebSocket connections in background tabs. The
onclosehandler catches this and reconnects when the tab becomes active again.
In production, you'd add:
- A
ping/pongheartbeat to detect stale connections - A maximum reconnection attempt limit
- A visible reconnection countdown in the UI
- Message queuing during disconnection (send queued messages after reconnecting)
Deploying
For a simple deployment:
Backend: Deploy the Express server to any Node.js host (Railway, Render, Fly.io). Make sure WebSocket connections are supported -- some reverse proxies and load balancers need explicit WebSocket configuration. Frontend: Build the React app (npm run build) and deploy the static files to any static host (Vercel, Netlify, Cloudflare Pages). Update the WS_URL in the hook to point to your production server. Use wss:// (encrypted) in production, not ws://.
const WS_URL = import.meta.env.PROD
? 'wss://your-server.example.com'
: 'ws://localhost:3001';
Common Mistakes
Not handling connection state. Always checkreadyState === WebSocket.OPEN before sending. Sending on a closed connection throws an error.
Not limiting message size. Without a limit, a malicious client could send gigabytes of data. We cap messages at 1000 characters on the server.
Storing sensitive data in messages. WebSocket messages are plain text unless you use wss://. Always use encrypted connections in production.
Not cleaning up on unmount. The useEffect cleanup function must close the WebSocket and clear all timeouts. Memory leaks from unclosed connections are a common React + WebSocket bug.
What's Next
This chat app covers the fundamentals, but a production chat application would add authentication (JWT tokens passed during the WebSocket handshake), persistent message storage (PostgreSQL or MongoDB), chat rooms and direct messages, file sharing, read receipts, and rate limiting.
The patterns you've learned here -- the message protocol, the reconnection strategy, the typing indicator debouncing, the hook-based state management -- apply directly to all of those features.
Build more real-time projects and explore full-stack development at CodeUp.