Vibe coding with AI course · Module 7: LinkedIn Clone

Real-time Chat and Messaging

5 min read
In this lesson3

A recruiter writes to a candidate and expects an answer right away, not after a page refresh. In the previous module the bell asked the server every 30 seconds. For a chat that's too slow, and most of those requests come back empty anyway. We need a connection through which the server itself sends a message as soon as it appears. That connection is a WebSocket.

The WebSocket connection

A WebSocket is a persistent, two-way connection between the browser and the server, like a rope stretched between two ships: messages travel in both directions without mooring again. We'll wrap it in our own hook and use it in the chat:

import { useEffect, useState } from 'react';

function useWebSocket(url: string) {
  const [ws, setWs] = useState<WebSocket | null>(null);
  const [messages, setMessages] = useState<any[]>([]);

  useEffect(() => {
    const socket = new WebSocket(url);

    socket.onmessage = (event) => {
      const message = JSON.parse(event.data);
      setMessages(prev => [...prev, message]);
    };

    setWs(socket);
    return () => socket.close();
  }, [url]);

  const send = (message: any) => {
    ws?.send(JSON.stringify(message));
  };

  return { messages, send };
}

function Chat({ recipientId }: { recipientId: number }) {
  const { messages, send } = useWebSocket('ws://localhost:3001');
  const [input, setInput] = useState("");

  const handleSend = () => {
    send({ to: recipientId, content: input });
    setInput("");
  };

  return (
    <div className="flex flex-col h-96">
      <div className="flex-1 overflow-auto p-4 space-y-2">
        {messages.map((msg, i) => (
          <div key={i} className={msg.from === recipientId ? "text-left" : "text-right"}>
            <span className="inline-block bg-blue-600 text-white px-4 py-2 rounded">
              {msg.content}
            </span>
          </div>
        ))}
      </div>
      <div className="flex gap-2 p-4 border-t">
        <input
          value={input}
          onChange={(e) => setInput(e.target.value)}
          onKeyPress={(e) => e.key === 'Enter' && handleSend()}
          className="flex-1 border rounded px-3 py-2"
        />
        <button onClick={handleSend} className="bg-blue-600 text-white px-4 py-2 rounded">
          Send
        </button>
      </div>
    </div>
  );
}

The useWebSocket hook opens the connection with new WebSocket(url) and collects incoming messages in state. Each message arrives as text in event.data, so socket.onmessage turns it into an object with JSON.parse(event.data) and appends it to the list with setMessages(prev => [...prev, message]). The send function works the other way round and calls JSON.stringify before sending. The cleanup function return () => socket.close() closes the connection when the component disappears or the address changes.

The Chat component uses the hook like any other: it displays messages, and after a click on Send or a press of Enter it calls send and clears the field. The other person's messages are on the left, yours on the right. A few notes for the voyage:

  • ws:// is an unencrypted connection, on a page served over HTTPS use wss://,
  • send() before the connection is open throws an error, so check readyState,
  • onKeyPress is deprecated in React, use onKeyDown instead.

The typing indicator

A line like "Captain is typing…" is a small detail that brings a conversation to life. We send a signal on every keystroke and cancel it after a second of silence:

function Chat() {
  const [typing, setTyping] = useState(false);
  const typingTimeout = useRef<NodeJS.Timeout>();

  const handleTyping = () => {
    send({ type: 'typing', userId: currentUser.id });

    if (typingTimeout.current) {
      clearTimeout(typingTimeout.current);
    }

    typingTimeout.current = setTimeout(() => {
      send({ type: 'stop-typing', userId: currentUser.id });
    }, 1000);
  };

  return (
    <div>
      {/* Messages */}
      {typing && <p className="text-sm text-gray-500">User is typing...</p>}
      <input onChange={handleTyping} />
    </div>
  );
}

The order is this: while you type in the field, handleTyping sends { type: 'typing' }, clears the previous timer with clearTimeout, sets a new setTimeout for 1000 ms, and once a second passes without typing, it sends { type: 'stop-typing' }. We keep the timer id in useRef, because it has to survive between renders and changing it shouldn't refresh the view. send and currentUser come from the component's surroundings. You'll call setTyping in onmessage when a signal arrives from the other person, because this piece of code doesn't do that yet.

The other end of the rope

The browser is only one end of the connection. On the other end a WebSocket server must be running, e.g. in Node.js with the ws library or Socket.IO, or an external real-time service. The ws://localhost:3001 address in the example points to exactly such a local server. Since 2026, Vercel supports WebSockets in its functions (in beta for now), but it closes the connection when the function reaches its maximum duration. A weak network can drop the connection too, so it's worth reconnecting in the onclose event.

Captain Redbeard's tip: before you ask AI for a chat, agree on a message format, e.g. { type, from, to, content }, and stick to it on both sides. Half of all chat bugs are ships speaking different languages.

Remember: a WebSocket is a persistent rope between the browser and the server, and messages travel along it as JSON in both directions. In the editor below you have a chat with the useWebSocket hook, messages shaped like { type, from, to, content } and a typing indicator. The socket is simulated because the preview has no server. The tasks will check the hook, receiving messages and the typing indicator.

Code for this lesson: App.jsx
// Real-time chat: the useWebSocket hook and a typing indicator
import { useState, useEffect, useRef } from 'react';

// The preview has no server, so we fake the socket: send() gets a reply from the other person after a moment.
// Real code creates new WebSocket('wss://...') and sends JSON shaped like { type, from, to, content }.
function createFakeSocket(onMessage) {
  return {
    send(raw) {
      const msg = JSON.parse(raw);
      if (msg.type === 'typing') return;
      setTimeout(() => onMessage({ type: 'typing', from: 'redbeard' }), 300);
      setTimeout(
        () => onMessage({ type: 'message', from: 'redbeard', to: msg.from, content: 'Received: ' + msg.content }),
        1500
      );
    },
    close() {},
  };
}

function useWebSocket() {
  const [messages, setMessages] = useState([]);
  const [othersTyping, setOthersTyping] = useState(false);
  const socketRef = useRef(null);

  useEffect(() => {
    const socket = createFakeSocket((msg) => {
      if (msg.type === 'typing') {
        setOthersTyping(true);
        return;
      }
      setOthersTyping(false);
      setMessages((prev) => [...prev, msg]);
    });
    socketRef.current = socket;
    return () => socket.close();
  }, []);

  const send = (msg) => socketRef.current?.send(JSON.stringify(msg));
  return { messages, setMessages, send, othersTyping };
}

export default function App() {
  const user = 'me';
  const { messages, setMessages, send, othersTyping } = useWebSocket();
  const [text, setText] = useState('');
  const [typing, setTyping] = useState(false);
  const typingTimeout = useRef(null);

  // The typing signal is sent once, and the indicator goes off after a second of silence.
  const handleTyping = (e) => {
    setText(e.target.value);
    if (!typing) {
      setTyping(true);
      send({ type: 'typing', from: user });
    }
    clearTimeout(typingTimeout.current);
    typingTimeout.current = setTimeout(() => setTyping(false), 1000);
  };

  const handleSend = (e) => {
    e.preventDefault();
    if (!text.trim()) return;
    const message = { type: 'message', from: user, to: 'redbeard', content: text.trim() };
    setMessages((prev) => [...prev, message]);
    send(message);
    setText('');
  };

  return (
    <div className="min-h-screen bg-gray-100 flex items-center justify-center p-6">
      <div className="w-full max-w-md bg-white rounded-xl shadow flex flex-col h-[28rem]">
        <div className="px-4 py-3 border-b font-semibold">Kapitan Redbeard</div>
        <div className="flex-1 overflow-y-auto p-4 space-y-2">
          {messages.length === 0 && <p className="text-sm text-gray-400 text-center">Write the first message</p>}
          {messages.map((m, i) => (
            <div key={i} className={`flex ${m.from === user ? 'justify-end' : 'justify-start'}`}>
              <span
                className={`px-3 py-2 rounded-2xl text-sm max-w-[75%] ${
                  m.from === user ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-900'
                }`}
              >
                {m.content}
              </span>
            </div>
          ))}
          {othersTyping && <p className="text-xs text-gray-400">Kapitan Redbeard is typing...</p>}
        </div>
        <form onSubmit={handleSend} className="p-3 border-t flex gap-2">
          <input
            value={text}
            onChange={handleTyping}
            placeholder="Message"
            className="flex-1 border rounded-full px-4 py-2 text-sm"
          />
          <button className="px-4 rounded-full bg-blue-600 text-white text-sm font-semibold">Send</button>
        </form>
      </div>
    </div>
  );
}

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. What is the useWebSocket hook used for in the chat?

  2. 2. How does the chat process incoming data in socket.onmessage?

Hands-on tasks in the game

  • Click in order

    Arrange creating the connection in useWebSocket: new WebSocket(url)

  • Vertical ordering

    Order the steps of the typing indicator (handleTyping) in the chat:

Useful articles