Kurs vibe coding z AI · Moduł 7: LinkedIn Clone
Czat i wiadomości w czasie rzeczywistym
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żywajwss://,send()przed otwarciem połączenia rzuci błąd, więc sprawdzajreadyState,onKeyPressjest w React przestarzałe, zamiast niego używajonKeyDown.
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. Do czego służy hook useWebSocket w czacie?
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: