Kurs vibe coding z AI · Moduł 7: LinkedIn Clone

Czat i wiadomości w czasie rzeczywistym

4 min czytania
W tej lekcji3

Rekruter pisze do kandydata i oczekuje odpowiedzi od razu, a nie po odświeżeniu strony. W poprzednim module dzwonek pytał serwer co 30 sekund. W czacie to za wolno, a do tego większość pytań wraca z niczym. Potrzebujemy połączenia, przez które serwer sam wyśle wiadomość, gdy tylko się pojawi. Takim połączeniem jest WebSocket.

Połączenie WebSocket

WebSocket to stałe, dwukierunkowe połączenie przeglądarki z serwerem, jak lina rozpięta między dwoma statkami: wiadomości płyną w obie strony bez ponownego cumowania. Zamkniemy je we własnym hooku i użyjemy w czacie:

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>
  );
}

Hook useWebSocket otwiera połączenie przez new WebSocket(url) i zbiera przychodzące wiadomości w stanie. Każda wiadomość przychodzi jako tekst w event.data, więc socket.onmessage zamienia ją na obiekt przez JSON.parse(event.data) i dokleja do listy przez setMessages(prev => [...prev, message]). Funkcja send działa odwrotnie i przed wysłaniem wywołuje JSON.stringify. Funkcja sprzątająca return () => socket.close() zamyka połączenie, gdy komponent znika albo zmienia się adres.

Komponent Chat korzysta z hooka jak z każdego innego: wyświetla messages, a po kliknięciu Send albo naciśnięciu Enter wywołuje send i czyści pole. Wiadomości rozmówcy są po lewej, Twoje po prawej. Kilka uwag na drogę:

  • ws:// to połączenie nieszyfrowane, na stronie z HTTPS używaj wss://,
  • send() przed otwarciem połączenia rzuci błąd, więc sprawdzaj readyState,
  • onKeyPress jest w React przestarzałe, zamiast niego używaj onKeyDown.

Wskaźnik pisania

Napis „Kapitan pisze…” to drobiazg, który bardzo ożywia rozmowę. Wysyłamy sygnał przy każdym naciśnięciu klawisza i odwołujemy go po sekundzie ciszy:

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>
  );
}

Kolejność jest taka: przy pisaniu w polu handleTyping wysyła { type: 'typing' }, czyści poprzedni licznik przez clearTimeout, ustawia nowy setTimeout na 1000 ms, a gdy minie sekunda bez pisania, wysyła { type: 'stop-typing' }. Identyfikator licznika trzymamy w useRef, bo musi przetrwać między renderami, a jego zmiana nie powinna odświeżać widoku. send i currentUser pochodzą z otoczenia komponentu. setTyping wywołasz w onmessage, gdy przyjdzie sygnał od rozmówcy, bo ten fragment kodu jeszcze tego nie robi.

Druga strona liny

Przeglądarka to tylko jeden koniec połączenia. Po drugiej stronie musi działać serwer WebSocket, np. w Node.js z biblioteką ws albo Socket.IO, lub zewnętrzna usługa czasu rzeczywistego. Adres ws://localhost:3001 z przykładu wskazuje właśnie taki lokalny serwer. Vercel od 2026 roku obsługuje WebSockety w swoich funkcjach (na razie w wersji beta), ale zamyka połączenie po maksymalnym czasie działania funkcji. Połączenie może też zerwać słaba sieć, więc w zdarzeniu onclose warto połączyć się ponownie.

Rada Kapitana Redbearda: zanim poprosisz AI o czat, ustal format wiadomości, np. { type, from, to, content }, i trzymaj się go po obu stronach. Połowa błędów w czatach to statki, które mówią różnymi językami.

Zapamiętaj: WebSocket to stała lina między przeglądarką a serwerem, a wiadomości płyną nią jako JSON w obie strony. W edytorze poniżej masz czat z hookiem useWebSocket, wiadomościami w formacie { type, from, to, content } i wskaźnikiem pisania. Socket jest symulowany, bo podgląd nie ma serwera. Zadania sprawdzą hook, odbiór wiadomości i wskaźnik pisania.

Kod do tej lekcji: App.jsx
// Czat w czasie rzeczywistym: hook useWebSocket i wskaźnik pisania
import { useState, useEffect, useRef } from 'react';

// Podgląd nie ma serwera, więc udajemy socket: send() po chwili dostaje odpowiedź drugiej osoby.
// Prawdziwy kod tworzy new WebSocket('wss://...') i wysyła JSON w formacie { 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: 'Odebrano: ' + 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);

  // Sygnał pisania leci tylko raz, a po sekundzie ciszy wskaźnik gaśnie.
  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">Napisz pierwszą wiadomość</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 pisze...</p>}
        </div>
        <form onSubmit={handleSend} className="p-3 border-t flex gap-2">
          <input
            value={text}
            onChange={handleTyping}
            placeholder="Wiadomość"
            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">Wyślij</button>
        </form>
      </div>
    </div>
  );
}

Widzisz błąd w tej lekcji?

Sprawdź się

Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.

  1. 1. Do czego służy hook useWebSocket w czacie?

  2. 2. Jak czat przetwarza dane przychodzące w socket.onmessage?

Zadania praktyczne w grze

  • Klikanie w kolejności

    Ułóż utworzenie połączenia w useWebSocket: new WebSocket(url)

  • Układanie w pionie

    Uporządkuj kroki wskaźnika pisania (handleTyping) w czacie:

Przydatne artykuły