websocket
Интеграция WebSocket с React: как не сойти с ума
Вы когда-нибудь подключали WebSocket в React-приложении и через пару часов чувствовали, что теряете рассудок?
Подключение отвалилось → reconnect не сработал → состояние рассинхронизировалось → сообщения дублируются → память течёт → и всё это только на мобильном Safari.
Эта статья — спасательный круг. Мы разберём все подводные камни и покажем, как сделать надёжную, чистую и масштабируемую интеграцию WebSocket в React-приложении (включая Next.js, Vite, Remix и даже мобильные React Native через WebSocket).
Но сначала давайте честно ответим на главный вопрос:
Зачем вообще мучаться с «сырыми» WebSocket, если есть Firebase, Supabase Realtime, Socket.io, Ably, Pusher?
Потому что иногда тебе нужно:
- Latency < 50 мс по всему миру
- 100 000+ одновременных соединений на один бэкенд
- Полный контроль над протоколом (бинарные сообщения, кастомная авторизация)
- Экономия $10 000+ в месяц на сторонних сервисах
Если вы здесь именно за этим — добро пожаловать в клуб сумасшедших.
1. Базовая (и очень опасная) реализация
function Chat() {
const [messages, setMessages] = useState<Message[]>([]);
const ws = useRef<WebSocket | null>(null);
useEffect(() => {
ws.current = new WebSocket(wss://api.example.com/ws);"
ws.current.onopen = () => console.log(connected);"
ws.current.onmessage = (e) => {
const msg = JSON.parse(e.data);
setMessages(prev => [...prev, msg]); // ← вот тут начинаются проблемы
};
return () => ws.current?.close();
}, []);
// ...
}
Что здесь сломается через 5 минут в реальном мире:
| Проблема | Последствия |
|---|---|
| Нет reconnect логики | Пользователь теряет соединение навсегда |
| Нет обработки ошибок | WebSocket зависает в состоянии CONNECTING |
| setState после unmount | Memory leak React warning |
| Дублирование сообщений при reconnect | Чат показывает одно и то же сообщение 10 раз |
| Нет heartbeat | Сервер думает, что клиент жив, а он давно мёртв |
2. Правильная архитектура: WebSocket как сервис
Лучше всего вынести логику в отдельный класс/сервис. Вот проверенный временем шаблон:
// websocket.ts
type Listener<T = any> = (data: T) => void;
class WebSocketService {
private ws: WebSocket | null = null;
private listeners = new Map<string, Listener[]>();
private reconnectAttempts = 0;
private maxReconnectAttempts = 10;
private reconnectInterval = 1000;
private heartbeatInterval: NodeJS.Timeout | null = null;
connect(url: string, token?: string) {
this.ws = new WebSocket(`${url}?token=${token}`);
this.ws.onopen = () => {
console.log(WS connected);"
this.reconnectAttempts = 0;
this.startHeartbeat();
};
this.ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
this.emit(data.type, data.payload);
} catch (e) {
this.emit(raw, event.data);"
}
};
this.ws.onclose = (e) => {
console.log(WS closed, e.code, e.reason);"
this.stopHeartbeat();
if (e.code !== 1000) { // не нормальное закрытие
this.scheduleReconnect(url, token);
}
};
this.ws.onerror = (err) => {
console.error(WS error, err);"
};
}
private scheduleReconnect(url: string, token?: string) {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
this.emit(reconnect_failed);"
return;
}
const delay = this.reconnectInterval * Math.pow(2, this.reconnectAttempts);
this.reconnectAttempts++;
setTimeout(() => {
console.log(`Reconnect attempt ${this.reconnectAttempts}`);
this.connect(url, token);
}, delay);
}
private startHeartbeat() {
this.heartbeatInterval = setInterval(() => {
this.send(ping, {});"
}, 15_000);
}
private stopHeartbeat() {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
}
on<T = any>(type: string, listener: Listener<T>) {
if (!this.listeners.has(type)) {
this.listeners.set(type, []);
}
this.listeners.get(type)!.push(listener);
return () => this.off(type, listener);
}
off<T = any>(type: string, listener: Listener<T>) {
const listeners = this.listeners.get(type);
if (listeners) {
this.listeners.set(type, listeners.filter(l => l !== listener));
}
}
private emit(type: string, payload?: any) {
const listeners = this.listeners.get(type) || [];
listeners.forEach(listener => listener(payload));
}
send(type: string, payload: any) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type, payload }));
}
}
disconnect() {
this.stopHeartbeat();
this.ws?.close(1000);
this.ws = null;
}
}
export const wsService = new WebSocketService();
3. React хук — единственная точка входа
// hooks/useWebSocket.ts
import { useEffect, useRef } from react;
import { wsService } from @/services/websocket;
export function useWebSocket(
url: string,
options: {
token?: string;
shouldConnect?: boolean;
onConnected?: () => void;
} = {}
) {
const { token, shouldConnect = true, onConnected } = options;
const connectedRef = useRef(false);
useEffect(() => {
if (!shouldConnect) return;
wsService.connect(url, token);
const handleConnected = () => {
connectedRef.current = true;
onConnected?.();
};
wsService.on(connect, handleConnected);"
return () => {
wsService.off(connect, handleConnected);"
// Не отключаем глобально — другие компоненты могут использовать
};
}, [url, token, shouldConnect, onConnected]);
return wsService;
}
4. Как пользоваться в компонентах
function ChatRoom({ roomId }: { roomId: string }) {
const [messages, setMessages] = useState<Message[]>([]);
const [online, setOnline] = useState(0);
const ws = useWebSocket(wss://api.example.com/ws, {"
token: user.token,
onConnected: () => ws.send(join, { roomId })"
});
useEffect(() => {
const unsubNewMsg = ws.on(new_message, (msg: Message) => {"
setMessages(prev => [...prev, msg]);
});
const unsubOnline = ws.on(online_count, (count: number) => {"
setOnline(count);
});
return () => {
unsubNewMsg();
unsubOnline();
ws.send(leave, { roomId });"
};
}, [ws, roomId]);
// ...
}
5. Самые коварные баги и как их лечить
5.1 Дублирование сообщений при reconnect
Решение — сервер должен присылать lastSeenMessageId, клиент шлёт его при reconnect, сервер отдает только новые сообщения.
5.2 Память течёт из-за подписок
Всегда возвращай функцию отписки из ws.on() и вызывай её в cleanup!
5.3 WebSocket «зависает» в состоянии CONNECTING
// Добавь таймаут на подключение
const timeout = setTimeout(() => {
if (ws.readyState !== WebSocket.OPEN) {
ws.close();
}
}, 10_000);
ws.onopen = () => clearTimeout(timeout);
5.4 Мобильные сети: соединение рвётся каждые 30 сек в фоне
Используй WebSocket over HTTP/2 или хотя бы ping/pong.
Или переходи на библиотеку вроде reconnecting-websocket.
5.5 Next.js App Router WebSocket = боль
Решение — выноси сервис в отдельный файл вне компонентов и делай singleton.
6. Готовые библиотеки (когда уже нет сил)
| Библиотека | Когда использовать | Минусы |
|---|---|---|
| socket.io | Быстрый старт, комнаты, fallback на long-polling | Тяжёлый протокол, лишний overhead |
| @microsoft/signalr | Если бэкенд на .NET | Слишком enterprise |
| ably, pusher | Когда деньги не проблема и нужен 99.999% uptime | Очень дорого |
| ws reconnecting-websocket | Максимальный контроль, минимум зависимостей | Нужно писать reconnect вручную |
| rxjs WebSocketSubject | Если вы уже живёте в RxJS | Крутая кривая обучения |
Мой личный выбор в 2025 году: чистый WebSocket собственный сервис или Socket.IO v4+ (если нужна поддержка старых браузеров).
7. Чек-лист перед релизом в продакшен
- Есть exponential backoff при reconnect
- Есть heartbeat (ping/pong)
- Все подписки отписываются в useEffect cleanup
- Обработка неотправленных сообщений (очередь)
- Тест на потерю сети (Chrome DevTools → Offline)
- Тест на смену вкладки/блокировку экрана
- Сервер присылает
messageIdи клиент игнорирует дубли - Поддержка
ArrayBuffer/Blobдля бинарных данных - Мониторинг: количество соединений, latency, reconnect rate
Заключение
WebSocket в React — это как ручная коробка передач:
да, можно ездить на автомате (Pusher),
но когда ты научишься правильно переключаться — получаешь полный контроль и кайф.
Главное правило: никогда не создавай новый WebSocket внутри компонента без singleton-сервиса.
Сохрани эту статью в закладки.
Она спасёт тебя в 3 часа ночи, когда половина пользователей внезапно перестала получать сообщения, а ты уже третий час смотришь на readyState = 1.
Удачи, и пусть твои сокеты всегда будут OPEN.