Building a Real-time Chat App with WebSocket and React

Why WebSocket?

When building real-time applications like chat, traditional HTTP request-response isn't enough. WebSocket provides a persistent connection between client and server, enabling instant message delivery and better performance. Let's build a chat app that showcases these benefits!

Interactive Demo

Try out this interactive demo to see how the chat app works. You can send messages and see them appear in real-time:

Chat Demo

connecting

Project Setup

First, let's create a new React project and install our dependencies:

npx create-react-app chat-app --template typescript
cd chat-app
npm install ws @types/ws

Building the WebSocket Server

Create a new file server.ts for our WebSocket server:

import { WebSocketServer, WebSocket } from 'ws';
import { v4 as uuidv4 } from 'uuid';

interface Client {
  id: string;
  ws: WebSocket;
}

interface Message {
  type: 'message' | 'status';
  payload: any;
}

const wss = new WebSocketServer({ port: 8080 });
const clients: Client[] = [];

wss.on('connection', (ws) => {
  const client: Client = {
    id: uuidv4(),
    ws,
  };
  clients.push(client);

  // Send welcome message
  ws.send(JSON.stringify({
    type: 'message',
    payload: {
      id: uuidv4(),
      sender: 'System',
      content: 'Welcome to the chat!',
      timestamp: new Date().toISOString(),
    },
  }));

  // Broadcast to others that someone joined
  broadcast({
    type: 'message',
    payload: {
      id: uuidv4(),
      sender: 'System',
      content: 'A new user joined the chat',
      timestamp: new Date().toISOString(),
    },
  }, client.id);

  ws.on('message', (data) => {
    try {
      const message: Message = JSON.parse(data.toString());
      broadcast(message, client.id);
    } catch (error) {
      console.error('Failed to parse message:', error);
    }
  });

  ws.on('close', () => {
    const index = clients.findIndex((c) => c.id === client.id);
    if (index !== -1) {
      clients.splice(index, 1);
      broadcast({
        type: 'message',
        payload: {
          id: uuidv4(),
          sender: 'System',
          content: 'A user left the chat',
          timestamp: new Date().toISOString(),
        },
      });
    }
  });
});

function broadcast(message: Message, senderId?: string) {
  const data = JSON.stringify(message);
  clients.forEach((client) => {
    if (client.id !== senderId && client.ws.readyState === WebSocket.OPEN) {
      client.ws.send(data);
    }
  });
}

console.log('WebSocket server running on ws://localhost:8080');

Creating a Custom Hook

Let's create a custom hook to manage our WebSocket connection. Create src/hooks/useWebSocket.ts:

import { useState, useEffect, useRef } from 'react';

interface Message {
  id: string;
  sender: string;
  content: string;
  timestamp: string;
}

interface WebSocketMessage {
  type: 'message' | 'status';
  payload: Message;
}

export function useWebSocket(url: string) {
  const [messages, setMessages] = useState<Message[]>([]);
  const [status, setStatus] = useState<'connecting' | 'connected' | 'disconnected'>('connecting');
  const wsRef = useRef<WebSocket | null>(null);

  useEffect(() => {
    const ws = new WebSocket(url);
    wsRef.current = ws;

    ws.addEventListener('open', () => {
      setStatus('connected');
    });

    ws.addEventListener('message', (event) => {
      const data: WebSocketMessage = JSON.parse(event.data);
      if (data.type === 'message') {
        setMessages(prev => [...prev, data.payload]);
      }
    });

    ws.addEventListener('close', () => {
      setStatus('disconnected');
    });

    return () => {
      ws.close();
    };
  }, [url]);

  const sendMessage = (content: string) => {
    if (wsRef.current?.readyState === WebSocket.OPEN) {
      const message: Message = {
        id: Math.random().toString(36).substr(2, 9),
        sender: 'You',
        content,
        timestamp: new Date().toISOString(),
      };

      wsRef.current.send(JSON.stringify({
        type: 'message',
        payload: message,
      }));

      setMessages(prev => [...prev, message]);
    }
  };

  return { messages, status, sendMessage };
}

Building the Chat UI

Now let's create our chat component in src/components/Chat.tsx:

import { useState, useEffect, useRef } from 'react';
import { useWebSocket } from '../hooks/useWebSocket';

export default function Chat() {
  const [newMessage, setNewMessage] = useState('');
  const messagesEndRef = useRef<HTMLDivElement>(null);
  const { messages, status, sendMessage } = useWebSocket('ws://localhost:8080');

  useEffect(() => {
    // Scroll to bottom when new messages arrive
    messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
  }, [messages]);

  const handleSend = () => {
    if (!newMessage.trim() || status !== 'connected') return;
    sendMessage(newMessage);
    setNewMessage('');
  };

  const handleKeyPress = (e: React.KeyboardEvent) => {
    if (e.key === 'Enter' && !e.shiftKey) {
      e.preventDefault();
      handleSend();
    }
  };

  return (
    <div className="flex flex-col h-[500px] border rounded-lg">
      {/* Chat header */}
      <div className="p-4 border-b">
        <div className="flex items-center justify-between">
          <h3 className="text-lg font-semibold">Chat</h3>
          <div className="flex items-center gap-2">
            <div className={`w-2 h-2 rounded-full ${
              status === 'connected'
                ? 'bg-green-500'
                : status === 'connecting'
                  ? 'bg-yellow-500'
                  : 'bg-red-500'
            }`} />
            <span className="text-sm text-gray-500 capitalize">{status}</span>
          </div>
        </div>
      </div>

      {/* Messages area */}
      <div className="flex-1 overflow-y-auto p-4 space-y-4">
        {messages.map((message) => (
          <div
            key={message.id}
            className={`flex flex-col ${
              message.sender === 'You' ? 'items-end' : 'items-start'
            }`}
          >
            <div className={`max-w-[80%] rounded-lg p-3 ${
              message.sender === 'You'
                ? 'bg-blue-500 text-white'
                : 'bg-gray-100 text-gray-800'
            }`}>
              <div className="font-medium text-sm mb-1">{message.sender}</div>
              <div className="text-sm whitespace-pre-wrap">{message.content}</div>
              <div className="text-xs mt-1 opacity-75">
                {new Date(message.timestamp).toLocaleTimeString()}
              </div>
            </div>
          </div>
        ))}
        <div ref={messagesEndRef} />
      </div>

      {/* Input area */}
      <div className="p-4 border-t">
        <div className="flex gap-2">
          <textarea
            value={newMessage}
            onChange={(e) => setNewMessage(e.target.value)}
            onKeyPress={handleKeyPress}
            placeholder="Type a message..."
            className="flex-1 resize-none rounded-lg border p-2"
            rows={1}
            disabled={status !== 'connected'}
          />
          <button
            onClick={handleSend}
            disabled={status !== 'connected'}
            className="px-4 py-2 bg-blue-500 text-white rounded-lg
              hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
          >
            Send
          </button>
        </div>
      </div>
    </div>
  );
}

Error Handling

Let's add some error handling to our WebSocket hook:

// Add to useWebSocket.ts
ws.addEventListener('error', (error) => {
  console.error('WebSocket error:', error);
  setStatus('disconnected');
});

// Add reconnection logic
useEffect(() => {
  let reconnectTimeout: NodeJS.Timeout;

  const tryReconnect = () => {
    if (status === 'disconnected') {
      reconnectTimeout = setTimeout(() => {
        console.log('Attempting to reconnect...');
        const ws = new WebSocket(url);
        wsRef.current = ws;
        // ... setup event listeners
      }, 5000);
    }
  };

  if (status === 'disconnected') {
    tryReconnect();
  }

  return () => {
    clearTimeout(reconnectTimeout);
  };
}, [status, url]);

Best Practices

1. Connection Management

  • Always clean up WebSocket connections when components unmount
  • Implement reconnection logic for better reliability
  • Handle connection state properly (connecting, connected, disconnected)
  • Add heartbeat mechanism to detect stale connections

2. Message Handling

  • Validate message format on both client and server
  • Implement proper error handling for malformed messages
  • Consider message queuing for offline support
  • Add message delivery confirmation

3. Performance

  • Implement message batching for bulk operations
  • Use message compression for large payloads
  • Implement pagination for message history
  • Optimize re-renders with proper React patterns

Security Considerations

  • Implement proper authentication
  • Use secure WebSocket connections (wss://)
  • Validate and sanitize all messages
  • Implement rate limiting
  • Add CSRF protection

Next Steps

To make this chat app production-ready, consider adding:

  • User authentication and private messages
  • Message persistence with a database
  • File sharing capabilities
  • Read receipts
  • Typing indicators
  • Message reactions
  • Group chat support

Pro Tip: Consider using libraries like Socket.IO for more advanced features and better browser compatibility. It provides automatic reconnection, room support, and fallback to long polling when WebSocket isn't available.