WebSocket и RTK Query: живое общение в React-приложении
- четверг, 5 февраля 2026 г. в 00:00:04
Автор: Станислав Павенко
GitHub-репозиторий💡 Необходимые навыки до начала изучения!
Уметь писать код на
HTML/CSS;Понимать, что такое шифрование данных и чем отличаются
HTTPvsHTTPS;Уметь писать компоненты на
Reactи работать с хуками:useState,useEffect;Использовать Redux Toolkit Query для REST-запросов.
Представьте, что вы смотрите онлайн-трансляцию матча. Счёт меняется — и вы видите это мгновенно, без перезагрузки страницы. Или вы пишете коллеге в чате — сообщение появляется у него в реальном времени. Это не магия, а технология WebSocket.
В этой статье вы узнаете:
Что такое WebSocket и когда его использовать;
Как управлять жизненным циклом соединения в браузере;
Как интегрировать WebSocket с RTK Query — мощной библиотекой для управления состоянием в React-приложениях.
Вы научитесь:
Объяснять, как работает WebSocket;
Подключать «живой» канал связи к своему приложению через RTK Query;
Управлять жизненным циклом WebSocket, корректно обрабатывая все этапы его работы.
Обычные HTTP-запросы — как отправка письма: вы пишете → ждёте ответа → получаете. Это одноразовое взаимодействие.
WebSocket — это протокол двусторонней связи между клиентом (браузером) и сервером. В отличие от HTTP, где клиент запрашивает данные, а сервер отвечает, WebSocket открывает постоянное соединение, по которому обе стороны могут отправлять сообщения в любое время. Как телефонный звонок: вы подключились один раз — и теперь можете говорить в любое время, в обе стороны, до тех пор, пока соединение не будет разорвано.
Чаты и мессенджеры
Онлайн-игры
Биржевые котировки
Совместное редактирование документов
Уведомления в реальном времени
Обозначение | Название протокола | Толкование |
|---|---|---|
HTTP | HyperText Transfer Protocol | письмо (отправка → ожидание ответа) |
WS | WebSocket | телефонный разговор |
Пример: wss://echo.websocket.org — эхо-сервер, отвечает текстом сообщения, которое ему отправить. Будем использовать в учебных целях.
⚠️ Полезные советы!
Проверяйте текущее состояние перед отправкой запроса — иначе будет выброшено исключение.
Проверяйте активность потока — heartbeat (
ping/pong).Разрывайте соединение по завершению работы с ним.
Используйте безопасный протокол WebSocket Secure (
wss://) — аналогичноHTTPS.Организовывайте авторизацию (через URL или первое сообщение).
Защищайте серверы от DoS/DDoS-атак — используйте экспоненциальный backoff при реконнекте.
Управляйте нагрузкой через backpressure — ограничивайте скорость отправки.
Свойство / Метод | Назначение | Пример |
|---|---|---|
| Создание соединения |
|
| Состояние: |
|
| Отправка данных |
|
| Закрытие соединения |
|
| Обработчик подключения |
|
| Обработчик входящих сообщений |
|
| Обработчик ошибок |
|
| Обработчик закрытия |
|
Установка соединения
const socket = new WebSocket('wss://echo.websocket.org');
Обмен сообщениями
socket.send('Привет!'); socket.onmessage = (event) => console.log(event.data);
Обработка ошибок
socket. => console.error('Ошибка:', error);
Закрытие соединения
socket.onclose = (event) => console.log('Закрыто:', event.code); socket.close();
⚠️ Соединение может оборваться. Хорошее приложение умеет переподключаться.
RTK Query — часть Redux Toolkit для управления серверным состоянием. Он позволяет получать и синхронизировать данные из API без boilerplate-кода.
Упрощает работу с API: автоматическая загрузка, кэширование, обновление.
Убирает ручное управление состоянием (isLoading, error и т.д.).
Предотвращает дубли запросов.
Поддерживает мутации и инвалидацию.
Работает не только с REST — через queryFn и onCacheEntryAdded можно интегрировать WebSocket, SSE и другие источники.
Метод / Свойство | Назначение |
|---|---|
| Создаёт API-слайс |
| Эндпоинт для чтения данных |
| Эндпоинт для изменения данных |
| Кастомная логика запроса |
| Выполняется при активации эндпоинта; завершается при отписке |
| Обновляет данные в кэше вручную |
| Хук для чтения |
| Хук для записи |
✅ Памятка:
Query = данные «читаются» → подписка + кэш
Mutation = данные «меняются» → вызов + инвалидация
onCacheEntryAdded= ваш «контроллер» для долгоживущих соединений
Этап | Условие | Что происходит | Пример |
|---|---|---|---|
🟢 Инициализация | Первый вызов | Создаётся кэш, запускается | Пользователь заходит на страницу чата |
🟡 Активное соединение | Хук используется | Открывается WebSocket, обновляются данные |
|
⏳ Ожидание отписки | Все компоненты размонтированы | Срабатывает | Пользователь ушёл со страницы |
🔴 Завершение | После | Выполняется |
|
🔄 Повторная активация | Хук вызван снова | Цикл перезапускается | Пользователь вернулся на чат |
💡 Преимущества:
Нет утечек: соединение живёт только пока нужно UI.
Декларативность: вы описываете «что», а не «как управлять».
Повторяемость: один и тот же код работает при любом количестве переходов.
RTK Query по умолчанию ориентирован на REST/HTTP, но его можно адаптировать и для WebSocket.
Подход | Гибрид: RTK Query + WebSocket | Только |
|---|---|---|
Состояние данных | Единый кэш в Redux | Размазано по компонентам |
Согласованность | Все данные — из одного источника | Легко рассинхронизироваться |
Обработка ошибок | Встроенная ( | Всё вручную |
Жизненный цикл | Автоматическая отписка | Риск утечек |
Разработка | Меньше кода, меньше багов | Высокая сложность |
💡 Итог:
Голый WebSocket — это «голый провод».
RTK Query + WebSocket — продуманная архитектура: вы получаете и реалтайм, и стабильность, и масштабируемость.
Критерий | ✅ RTK Query | STOMP.js | useEffect + useState | SSE | |
|---|---|---|---|---|---|
Основное назначение | Управление запросами с кэшированием | Двусторонний реалтайм | Интеграция с брокерами | Простая загрузка данных | Односторонний поток |
Кэширование | ✅ Автоматическое | ❌ | ❌ | ❌ | ❌ |
Авто-повтор при ошибке | ✅ | ✅ | ⚠️ | ❌ | ✅ |
Управление жизненным циклом | ✅ | ✅ | ⚠️ | ❌ | ⚠️ |
Сложность поддержки | 🔸 Низкая | 🔸🔸 Средняя | 🔸🔸🔸 Высокая | 🔸 Низкая (но растёт) | 🔸🔸 Средняя |
Типичный use case | CRUD-приложения | Чаты, игры | Финтех, ERP | MVP | Live-логи |
💡 WebSocket и SSE — специализированные инструменты. Используйте их только когда действительно нужен поток. Для всего остального — RTK Query.
Ура!! Мы изучили теорию и наконец-то добрались до кода.
npx create-react-app example --template react cd example npm install @reduxjs/toolkit react-redux
src/ ├── api/ # RTK Query API ├── components/ # UI-компоненты └── store/ # Redux store mkdir src/api src/components src/store
import { configureStore } from '@reduxjs/toolkit'; import { websocketApi } from '../api/websocketApi'; export const store = configureStore({ reducer: { [websocketApi.reducerPath]: websocketApi.reducer, }, middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(websocketApi.middleware), });
import { createApi } from '@reduxjs/toolkit/query/react'; export const websocketApi = createApi({ reducerPath: 'websocketApi', baseQuery: () => ({ data: null }), endpoints: (builder) => ({ echo: builder.query({ queryFn: () => ({ data: { messages: [], connectionState: 'connecting' }, }), async onCacheEntryAdded(_, { updateCachedData, cacheDataLoaded, cacheEntryRemoved }) { updateCachedData(draft => draft.connectionState = 'connecting'); await new Promise(r => setTimeout(r, 1000)); const socket = new WebSocket('wss://echo.websocket.org'); socket.onopen = () => { updateCachedData(draft => draft.connectionState = 'online'); socket.send('Hello!'); }; socket.onmessage = (event) => { updateCachedData(draft => draft.messages.push(event.data)); }; socket. => { updateCachedData(draft => draft.connectionState = 'error'); }; socket.onclose = () => { updateCachedData(draft => draft.connectionState = 'closed'); }; try { await cacheDataLoaded; await cacheEntryRemoved; } finally { socket.close(); } }, }), }), }); export const { useEchoQuery } = websocketApi;
import React from 'react'; import { useEchoQuery } from '../api/websocketApi'; export default function EchoClient() { const { data } = useEchoQuery(); if (!data) return <p>Загрузка...</p>; const { messages, connectionState } = data; const statusConfig = { connecting: { text: 'Подключаемся…', color: 'orange' }, online: { text: '✅ Онлайн', color: 'green' }, closed: { text: '⚠️ Соединение разорвано', color: 'gray' }, error: { text: '❌ Ошибка подключения', color: 'red' }, }[connectionState]; return ( <div> <div> <strong>Статус:</strong> <span style={{color: statusConfig.color}}>{statusConfig.text}</span> </div> <ul> {messages.map((msg, i) => <li key={i}>«{msg}»</li>)} </ul> </div> ); }
npm install npm run dev
Подключение → статус «Подключаемся…» → «Онлайн».
Ошибка → неверный URL → статус «Ошибка».
Разрыв → ожидание → статус «Соединение разорвано».
Переподключение → кнопка → всё восстанавливается.
💡 Используйте DevTools → Network → WS для наблюдения.
Ну штошшЪ... Если вы это читаете, значит вы разобрались как писать простенькое приложение с WebSocket. Пора добавить нашему приложению презентабельный внешний вид и коммерческую архитектуру которую не засмеют коллеги на ревью.
src/ ├── features/chat/ # Фича "чат" │ ├── utils/ # RTK Query API, WS manager и остальные tools │ ├── hooks/ # Кастомные хуки │ ├── constants/ # Константы (connection) │ ├── components # Вспомогательные react-компоненты │ │ ├── ConnectionLog/ │ │ ├── MessageSent/ │ │ ├── MessagesHistory/ │ │ ├── StatusDot/ │ │ └── UrlControl/ │ ├── Chat.css # Классы стилей chat-компонента │ └── Chat.js # Chat-компонент ├── store.js # Redux store всего приложения ├── App.css # Классы стилей root-компонента ├── App.js # Root-компонент приложения └── main.js # Точка входа в приложение
.logs { padding: 8px 12px; background-color: #f1f8e9; font-size: 12px; color: #555; max-height: 80px; overflow-y: auto; } .logs ul { padding-left: 16px; margin: 4px 0; }
import React from 'react'; import './ConnectionLog.css'; const ConnectionLog = ({ data }) => { const { reconnectLogs = [] } = data || {}; return ( <> {reconnectLogs.length > 0 && ( <div className="logs"> <ul> {reconnectLogs.map((log, i) => (<li key={i}>{log}</li>))} </ul> </div> )} </> ); } export default ConnectionLog
.input-area { display: flex; padding: 8px; background-color: #fff; border-top: 1px solid #eee; } .textarea { flex: 1; border: 1px solid #ddd; border-radius: 12px; padding: 8px 12px; resize: none; font-size: 18px; min-height: 20px; max-height: 80px; outline: none; } .send-button { width: 40px; height: 40px; border-radius: 50%; border: none; margin-left: 8px; cursor: pointer; display: flex; align-items: center; justify-content: center; } .send-button:disabled { background-color: #e0e0e0; cursor: not-allowed; } .send-button:not(:disabled) { background-color: #4caf50; }
import React, { useState } from 'react'; import './MessageSent.css'; const MessageSent = ({ data, sendMessage }) => { const [inputValue, setInputValue] = useState(''); // Обработка отправки по Enter (без Shift) const handleKeyDown = (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); } }; // Отправка сообщения const handleSend = () => { const trimmed = inputValue.trim(); if (!trimmed || !data || data.connectionState !== 'online') return; sendMessage(trimmed); setInputValue(''); }; const { connectionState = 'idle' } = data || {}; return ( <div className="input-area"> <textarea value={inputValue} onChange={(e) => setInputValue(e.target.value)} onKeyDown={handleKeyDown} placeholder="Введите сообщение..." className="textarea" rows="1" /> <button onClick={handleSend} disabled={!inputValue.trim() || connectionState !== 'online'} className="send-button" > <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white"> <line x1="22" y1="2" x2="11" y2="13" /> <polygon points="22,2 15,22 11,13 2,9 22,2" /> </svg> </button> </div> ); } export default MessageSent;
.chat-messages { height: 300px; overflow-y: auto; padding: 12px; background-color: #f9f9f9; display: flex; flex-direction: column; } .empty-chat { color: #999; text-align: center; margin-top: 20px; } .message { max-width: 70%; margin-bottom: 12px; } .message--own { align-self: flex-end; } .message--incoming { align-self: flex-start; } .bubble { padding: 8px 12px; font-size: 14px; box-shadow: 0 1px 1px rgba(0, 0, 0, 0.25); } .bubble--own { background-color: #dcf8c6; border-radius: 12px 12px 0 12px; } .bubble--incoming { background-color: #ffffff; border-radius: 0 12px 12px 12px; }
import React, { useEffect, useRef } from 'react'; import './MessagesHistory.css'; const MessagesHistory = ({ data }) => { const chatContainerRef = useRef(null); // Автопрокрутка чата вниз при новых сообщениях useEffect(() => { if (chatContainerRef.current) { chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight; } }, [data?.messages]); const { messages = [] } = data || {}; return ( <div ref={chatContainerRef} className="chat-messages"> {messages.length === 0 ? ( <div className="empty-chat">Нет сообщений</div> ) : ( messages.map((msg, idx) => ( <div key={msg.id ?? idx} className={`message message--${msg.isOwn ? 'own' : 'incoming'}`} > <div className={`bubble bubble--${msg.isOwn ? 'own' : 'incoming'}`}> {msg.text} </div> </div> )) )} </div> ); } export default MessagesHistory;
.chat-header { background-color: #ffffff; padding: 12px 16px; border-bottom: 1px solid #eee; display: flex; align-items: center; } .status-dot { width: 10px; height: 10px; border-radius: 50%; margin-right: 8px; }
import React from 'react'; import './StatusDot.css'; const CHAT_STATUS = { connecting: { text: 'Подключение…', color: '#ff9800' }, online: { text: 'В сети', color: '#4caf50' }, closed: { text: 'Соединение разорвано', color: '#9e9e9e' }, error: { text: 'Ошибка подключения', color: '#f44336' }, idle: { text: 'Готов к подключению', color: '#757575' }, }; const StatusDot = ({ data }) => { const { connectionState = 'idle' } = data || {}; const status = CHAT_STATUS[connectionState]; return ( <div className="chat-header"> <div className="status-dot" style={{ backgroundColor: status.color }} ></div> <span>{status.text}</span> </div> ); } export default StatusDot;
.url-control { display: flex; padding: 8px; background-color: #f5f5f5; border-bottom: 1px solid #eee; } .url-input { flex: 1; padding: 6px 10px; border: 1px solid #ccc; border-radius: 4px; font-size: 14px; } .connect-button { margin-left: 8px; padding: 6px 12px; background-color: #4caf50; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; } .connect-button:hover { background-color: #45a049; }
import React from 'react'; import './UrlControl.css'; const UrlControl = ({ refetch, wsUrl, setWsUrl }) => { // Обновление URL WebSocket-сервера const handleUrlChange = (e) => { setWsUrl(e.target.value); }; // Принудительное переподключение const handleConnect = () => { refetch(); // Пересоздаёт соединение через RTK Query }; return ( <div className="url-control"> <input type="text" value={wsUrl} onChange={handleUrlChange} placeholder="WebSocket URL" className="url-input" /> <button onClick={handleConnect} className="connect-button" > Переподключиться </button> </div> ); } export default UrlControl;
export const DEFAULT_WS_URL = 'wss://echo.websocket.org';
import { useRef, useCallback } from 'react'; import { useConnectToEchoQuery } from '../utils/echo'; import { getConnection } from '../utils/connectionRegistry'; export const useChatConnection = (url) => { const sendMessageRef = useRef(null); // Подписываемся на RTK Query endpoint const result = useConnectToEchoQuery(url, { refetchOnFocus: false, refetchOnReconnect: false, }); // Регистрация функции отправки изнутри RTK Query const registerSendMessage = useCallback((sendFn) => { sendMessageRef.current = sendFn; }, []); // Публичный метод отправки const sendMessage = useCallback((message) => { const sendFn = getConnection(url); if (typeof sendFn === 'function') { sendFn(message); } }, [url]); return { ...result, sendMessage, registerSendMessage, }; };
const registry = new Map(); export const getConnection = (url) => registry.get(url); export const setConnection = (url, sendFn) => registry.set(url, sendFn); export const removeConnection = (url) => registry.delete(url);
export class WebsocketManager { constructor(url, callbacks) { this.url = url; // Возможность для расширения класса // { onOpen, onMessage, onError, onClose } this.callbacks = callbacks; this.socket = null; this.heartbeatInterval = null; this.isDestroyed = false; } connect() { if (this.isDestroyed) return; this.socket = new WebSocket(this.url); this.socket.onopen = () => { if (!this.isDestroyed) { this.startHeartbeat(); this.callbacks.onOpen?.(); } }; this.socket.onmessage = (event) => { if (!this.isDestroyed) { this.callbacks.onMessage?.(event.data); } }; this.socket.onerror = () => { if (!this.isDestroyed) { this.callbacks.onError?.(); } }; this.socket.onclose = () => { if (!this.isDestroyed) { this.stopHeartbeat(); this.callbacks.onClose?.(); } }; } send(message) { if (this.socket?.readyState === WebSocket.OPEN) { this.socket.send(message); } } startHeartbeat() { this.heartbeatInterval = setInterval(() => { if (this.socket?.readyState === WebSocket.OPEN) { this.socket.send(JSON.stringify({ type: 'ping' })); } }, 30000); } stopHeartbeat() { if (this.heartbeatInterval) { clearInterval(this.heartbeatInterval); this.heartbeatInterval = null; } } destroy() { this.isDestroyed = true; this.stopHeartbeat(); if (this.socket) { this.socket.close(); this.socket = null; } } }
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; import { WebsocketManager } from './websocketManager'; import { setConnection, removeConnection } from './connectionRegistry'; const REDUCE_PATH = 'echo' const MAX_RECONNECT_ATTEMPTS = 3; const DEFAULT_QUERY_OPTION = { data: { messages: [], reconnectLogs: [], connectionState: 'idle', }, }; export const echo = createApi({ reducerPath: REDUCE_PATH, baseQuery: fetchBaseQuery({ baseUrl: '/' }), endpoints: (builder) => ({ connectToEcho: builder.query({ queryFn: () => DEFAULT_QUERY_OPTION, async onCacheEntryAdded(wsUrl, { updateCachedData, cacheDataLoaded, cacheEntryRemoved }) { let reconnectAttempts = 0; let reconnectTimeout = null; let wsManager = null; const logReconnect = (message) => { updateCachedData((draft) => { draft.reconnectLogs.push(message); }); }; const connect = () => { if (reconnectAttempts > 0) { logReconnect(`🔄 Попытка переподключения #${reconnectAttempts}`); } updateCachedData((draft) => { draft.connectionState = 'connecting'; }); // Функция отправки сообщения — будет зарегистрирована при onOpen let sendMessageFn = null; const wsManagerConfig = { onOpen: () => { reconnectAttempts = 0; updateCachedData((draft) => { draft.connectionState = 'online'; }); // Создаём и регистрируем функцию отправки sendMessageFn = (message) => { wsManager.send(message); updateCachedData((draft) => { draft.messages.push({ id: Date.now(), text: message, isOwn: true }); }); }; // Регистрируем в реестре setConnection(wsUrl, sendMessageFn); }, onMessage: (data) => { updateCachedData((draft) => { draft.messages.push({ id: Date.now(), text: data, isOwn: false }); }); }, onError: handleReconnect, onClose: () => { updateCachedData((draft) => { draft.connectionState = reconnectAttempts <= MAX_RECONNECT_ATTEMPTS ? 'closed' : 'error'; }); handleReconnect(); }, }; wsManager = new WebsocketManager(wsUrl, wsManagerConfig); wsManager.connect(); }; const handleReconnect = () => { if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { logReconnect(`❌ Достигнут лимит попыток (${MAX_RECONNECT_ATTEMPTS})`); updateCachedData((draft) => { draft.connectionState = 'error'; }); return; } reconnectAttempts++; logReconnect(`⚠️ Попытка #${reconnectAttempts} не удалась. Повтор через 2 сек...`); updateCachedData((draft) => { draft.connectionState = 'closed'; }); reconnectTimeout = setTimeout(connect, 2000); }; try { connect(); await cacheDataLoaded; await cacheEntryRemoved; } finally { if (reconnectTimeout) clearTimeout(reconnectTimeout); // Удаляем из реестра при завершении removeConnection(wsUrl); wsManager?.destroy(); wsManager = null; } }, }), }), }); export const { useConnectToEchoQuery } = echo;
.chat-container { width: 400px; border: 1px solid #ddd; border-radius: 12px; overflow: hidden; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); }
import React, { useState } from 'react'; import { useChatConnection } from './hooks/useChatConnection'; import { DEFAULT_WS_URL } from './constants/connection'; import UrlControl from './components/UrlControl/UrlControl'; import StatusDot from './components/StatusDot/StatusDot'; import ConnectionLog from './components/ConnectionLog/ConnectionLog'; import MessagesHistory from './components/MessagesHistory/MessagesHistory'; import MessageSent from './components/MessageSent/MessageSent'; import './Chat.css'; /** * Контейнерный компонент чата. * * Использует хук useChatConnection для координации WebSocket-соединения * и управляет взаимодействием между дочерними компонентами: * - Управление URL сервера (UrlControl) * - Статус соединения (StatusDot) * - История сообщений (MessagesHistory) * - Отправка сообщений (MessageSent) * - Лог событий (ConnectionLog) */ const Chat = () => { const [wsUrl, setWsUrl] = useState(DEFAULT_WS_URL); const { data, isLoading, refetch, sendMessage } = useChatConnection(wsUrl); if (isLoading && !data) { return <p className="loading">Загрузка чата...</p>; } return ( <div className="chat-container"> <UrlControl refetch={refetch} wsUrl={wsUrl} setWsUrl={setWsUrl} /> <StatusDot data={data} /> <MessagesHistory data={data} /> <MessageSent data={data} sendMessage={sendMessage} /> <ConnectionLog data={data} /> </div> ); } export default Chat;
import { configureStore } from '@reduxjs/toolkit'; import { echo } from './features/Chat/utils/echo'; export const store = configureStore({ reducer: { [echo.reducerPath]: echo.reducer, }, middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(echo.middleware), });
.app { padding: 20px; font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; }
import React from 'react'; import Chat from './features/Chat/Chat'; import './App.css'; const App = () => { return ( <div className="app"> <h1>WebSocket + RTK Query</h1> <Chat /> </div> ); } export default App;
import React, { StrictMode } from 'react'; import ReactDOM from 'react-dom/client'; import { Provider } from 'react-redux'; import { store } from './store'; import App from './App'; ReactDOM .createRoot(document.getElementById('root')) .render( <StrictMode> <Provider store={store}> <App /> </Provider> </StrictMode> );
💡 Рабочий пример можно найти по ссылке в GitHub — WebSocket-Demo
RTK Query и WebSocket — не конкуренты, а комплементарные инструменты:
Используйте RTK Query для надёжного управления состоянием на основе HTTP-запросов.
Используйте WebSocket только там, где нужен мгновенный канал в реальном времени.
В реальных проектах они часто работают вместе: WebSocket сигнализирует об изменениях, а RTK Query гарантирует согласованность и автоматическое обновление UI.
Такой гибридный подход обеспечивает и стабильность данных, и отзывчивость интерфейса, без избыточной сложности.