Автор: Станислав Павенко
GitHub-репозиторий💡 Необходимые навыки до начала изучения!
Уметь писать код на
HTML/CSS;Понимать, что такое шифрование данных и чем отличаются
HTTPvsHTTPS;Уметь писать компоненты на
Reactи работать с хуками:useState,useEffect;Использовать Redux Toolkit Query для REST-запросов.
Представьте, что вы смотрите онлайн-трансляцию матча. Счёт меняется — и вы видите это мгновенно, без перезагрузки страницы. Или вы пишете коллеге в чате — сообщение появляется у него в реальном времени. Это не магия, а технология WebSocket.
В этой статье вы узнаете:
-
Что такое WebSocket и когда его использовать;
-
Как управлять жизненным циклом соединения в браузере;
-
Как интегрировать WebSocket с RTK Query — мощной библиотекой для управления состоянием в React-приложениях.
Вы научитесь:
-
Объяснять, как работает WebSocket;
-
Подключать «живой» канал связи к своему приложению через RTK Query;
-
Управлять жизненным циклом WebSocket, корректно обрабатывая все этапы его работы.
📚 Оглавление
🔌 Что такое WebSocket?
Обычные HTTP-запросы — как отправка письма: вы пишете → ждёте ответа → получаете. Это одноразовое взаимодействие.
WebSocket — это протокол двусторонней связи между клиентом (браузером) и сервером. В отличие от HTTP, где клиент запрашивает данные, а сервер отвечает, WebSocket открывает постоянное соединение, по которому обе стороны могут отправлять сообщения в любое время. Как телефонный звонок: вы подключились один раз — и теперь можете говорить в любое время, в обе стороны, до тех пор, пока соединение не будет разорвано.
❓ Когда стоит использовать WebSocket?
-
Чаты и мессенджеры
-
Онлайн-игры
-
Биржевые котировки
-
Совместное редактирование документов
-
Уведомления в реальном времени
|
Обозначение |
Название протокола |
Толкование |
|---|---|---|
|
HTTP |
HyperText Transfer Protocol |
письмо (отправка → ожидание ответа) |
|
WS |
WebSocket |
телефонный разговор |
Пример: wss://echo.websocket.org — эхо-сервер, отвечает текстом сообщения, которое ему отправить. Будем использовать в учебных целях.
⚠️ Полезные советы!
Проверяйте текущее состояние перед отправкой запроса — иначе будет выброшено исключение.
Проверяйте активность потока — heartbeat (
ping/pong).Разрывайте соединение по завершению работы с ним.
Используйте безопасный протокол WebSocket Secure (
wss://) — аналогичноHTTPS.Организовывайте авторизацию (через URL или первое сообщение).
Защищайте серверы от DoS/DDoS-атак — используйте экспоненциальный backoff при реконнекте.
Управляйте нагрузкой через backpressure — ограничивайте скорость отправки.
🧱 Интерфейс класса WebSocket (браузерный API)
|
Свойство / Метод |
Назначение |
Пример |
|---|---|---|
|
|
Создание соединения |
|
|
|
Состояние: |
|
|
|
Отправка данных |
|
|
|
Закрытие соединения |
|
|
|
Обработчик подключения |
|
|
|
Обработчик входящих сообщений |
|
|
|
Обработчик ошибок |
|
|
|
Обработчик закрытия |
|
🔄 Жизненный цикл WebSocket-соединения
-
Установка соединения
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();
⚠️ Соединение может оборваться. Хорошее приложение умеет переподключаться.
🔌 Что такое Redux Toolkit Query?
RTK Query — часть Redux Toolkit для управления серверным состоянием. Он позволяет получать и синхронизировать данные из API без boilerplate-кода.
Зачем он нужен?
-
Упрощает работу с API: автоматическая загрузка, кэширование, обновление.
-
Убирает ручное управление состоянием (
isLoading,errorи т.д.). -
Предотвращает дубли запросов.
-
Поддерживает мутации и инвалидацию.
-
Работает не только с REST — через
queryFnиonCacheEntryAddedможно интегрировать WebSocket, SSE и другие источники.
🧱 Интерфейс RTK Query (браузерный API)
|
Метод / Свойство |
Назначение |
|---|---|
|
|
Создаёт API-слайс |
|
|
Эндпоинт для чтения данных |
|
|
Эндпоинт для изменения данных |
|
|
Кастомная логика запроса |
|
|
Выполняется при активации эндпоинта; завершается при отписке |
|
|
Обновляет данные в кэше вручную |
|
|
Хук для чтения |
|
|
Хук для записи |
✅ Памятка:
Query = данные «читаются» → подписка + кэш
Mutation = данные «меняются» → вызов + инвалидация
onCacheEntryAdded= ваш «контроллер» для долгоживущих соединений
🔄 Жизненный цикл RTK Query-соединения
|
Этап |
Условие |
Что происходит |
Пример |
|---|---|---|---|
|
🟢 Инициализация |
Первый вызов |
Создаётся кэш, запускается |
Пользователь заходит на страницу чата |
|
🟡 Активное соединение |
Хук используется |
Открывается WebSocket, обновляются данные |
|
|
⏳ Ожидание отписки |
Все компоненты размонтированы |
Срабатывает |
Пользователь ушёл со страницы |
|
🔴 Завершение |
После |
Выполняется |
|
|
🔄 Повторная активация |
Хук вызван снова |
Цикл перезапускается |
Пользователь вернулся на чат |
💡 Преимущества:
Нет утечек: соединение живёт только пока нужно UI.
Декларативность: вы описываете «что», а не «как управлять».
Повторяемость: один и тот же код работает при любом количестве переходов.
🧩 RTK Query и WebSocket: как они работают вместе?
RTK Query по умолчанию ориентирован на REST/HTTP, но его можно адаптировать и для WebSocket.
🛡️ Почему RTK Query — разумный выбор, даже для 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.
🛠 Практика#1! Простой пример для начала
Ура!! Мы изучили теорию и наконец-то добрались до кода.
Создадим проект React c Redux Toolkit
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
📄 Redux-хранилище (src/store/index.js)
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),
});
📄 WebSocket через onCacheEntryAdded (src/api/websocketApi.js)
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;
📄 UI-компонент (src/components/EchoClient.jsx)
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 для наблюдения.
🛠 Практика#2! Пример клиента для чата
Ну штошшЪ... Если вы это читаете, значит вы разобрались как писать простенькое приложение с 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 # Точка входа в приложение
📄 Файл стилей (src/features/chat/components/ConnectionLog/ConnectionLog.css)
.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;
}
📄 Вывод логов подключения (src/features/chat/components/ConnectionLog/ConnectionLog.js)
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
📄 Файл стилей (src/features/chat/components/MessageSent/MessageSent.css)
.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;
}
📄 Отправка нового сообщения (src/features/chat/components/MessageSent/MessageSent.js)
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;
📄 Файл стилей (src/features/chat/components/MessagesHistory/MessagesHistory.css)
.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;
}
📄 История сообщений (src/features/chat/components/MessagesHistory/MessagesHistory.js)
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;
📄 Файл стилей (src/features/chat/components/StatusDot/StatusDot.css)
.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;
}
📄 Статус подключения (src/features/chat/components/StatusDot/StatusDot.js)
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;
📄 Файл стилей (src/features/chat/components/UrlControl/UrlControl.css)
.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;
}
📄 Поле ввода строки подключения и кнопка принудительного переподключения (src/features/chat/components/UrlControl/UrlControl.js)
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;
📄 Константы для подключения (src/features/chat/constants/connection.js)
export const DEFAULT_WS_URL = 'wss://echo.websocket.org';
📄 Хук для создания подключения (src/features/chat/hooks/useChatConnection.js)
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,
};
};
📄 Реестр серверов WebSocket (src/features/chat/utils/connectionRegistry.js)
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);
📄 Обёртка над WebSocket, без зависимостей от Redux и React (src/features/chat/utils/websocketManager.js)
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;
}
}
}
📄 API slice для echo с Redux store под капотом (src/features/chat/utils/echo.js)
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;
📄 Файл стилей (src/features/chat/Chat.css)
.chat-container {
width: 400px;
border: 1px solid #ddd;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
📄 Основной контейнерный компонент чата с поддержкой WebSocket-соединения (src/features/chat/Chat.js)
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;
📄 Redux-хранилище (src/store.js)
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),
});
📄 Файл стилей (src/App.css)
.app {
padding: 20px;
font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
}
📄 Root-компонент (src/App.js)
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;
📄 Точка входа в приложение (src/main.js)
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.
Такой гибридный подход обеспечивает и стабильность данных, и отзывчивость интерфейса, без избыточной сложности.
Автор: pavenko_sv
