javascript

WebSocket и RTK Query: живое общение в React-приложении

  • четверг, 5 февраля 2026 г. в 00:00:04
https://habr.com/ru/articles/992526/

Автор: Станислав Павенко
GitHub-репозиторий

💡 Необходимые навыки до начала изучения!

  • Уметь писать код на HTML/CSS;

  • Понимать, что такое шифрование данных и чем отличаются HTTP vs HTTPS;

  • Уметь писать компоненты на React и работать с хуками: useState, useEffect;

  • Использовать Redux Toolkit Query для REST-запросов.

Представьте, что вы смотрите онлайн-трансляцию матча. Счёт меняется — и вы видите это мгновенно, без перезагрузки страницы. Или вы пишете коллеге в чате — сообщение появляется у него в реальном времени. Это не магия, а технология WebSocket.

В этой статье вы узнаете:

  • Что такое WebSocket и когда его использовать;

  • Как управлять жизненным циклом соединения в браузере;

  • Как интегрировать WebSocket с RTK Query — мощной библиотекой для управления состоянием в React-приложениях.

Вы научитесь:

  • Объяснять, как работает WebSocket;

  • Подключать «живой» канал связи к своему приложению через RTK Query;

  • Управлять жизненным циклом WebSocket, корректно обрабатывая все этапы его работы.


📚 Оглавление

  1. 🔌 Что такое WebSocket?

  2. 🔌 Что такое Redux Toolkit Query?

  3. 🧩 RTK Query и WebSocket: как они работают вместе?

  4. 🛠 Практика#1! Простой пример для начала

  5. 🛠 Практика#2! Пример клиента для чата

  6. 🎓 Заключение


🔌 Что такое WebSocket?

Обычные HTTP-запросы — как отправка письма: вы пишете → ждёте ответа → получаете. Это одноразовое взаимодействие.

WebSocket — это протокол двусторонней связи между клиентом (браузером) и сервером. В отличие от HTTP, где клиент запрашивает данные, а сервер отвечает, WebSocket открывает постоянное соединение, по которому обе стороны могут отправлять сообщения в любое время. Как телефонный звонок: вы подключились один раз — и теперь можете говорить в любое время, в обе стороны, до тех пор, пока соединение не будет разорвано.

❓ Когда стоит использовать WebSocket?

  • Чаты и мессенджеры

  • Онлайн-игры

  • Биржевые котировки

  • Совместное редактирование документов

  • Уведомления в реальном времени

Обозначение

Название протокола

Толкование

HTTP

HyperText Transfer Protocol

письмо (отправка → ожидание ответа)

WS

WebSocket

телефонный разговор

Пример: wss://echo.websocket.org — эхо-сервер, отвечает текстом сообщения, которое ему отправить. Будем использовать в учебных целях.

⚠️ Полезные советы!

  1. Проверяйте текущее состояние перед отправкой запроса — иначе будет выброшено исключение.

  2. Проверяйте активность потока — heartbeat (ping/pong).

  3. Разрывайте соединение по завершению работы с ним.

  4. Используйте безопасный протокол WebSocket Secure (wss://) — аналогично HTTPS.

  5. Организовывайте авторизацию (через URL или первое сообщение).

  6. Защищайте серверы от DoS/DDoS-атак — используйте экспоненциальный backoff при реконнекте.

  7. Управляйте нагрузкой через backpressure — ограничивайте скорость отправки.

🧱 Интерфейс класса WebSocket (браузерный API)

Свойство / Метод

Назначение

Пример

new WebSocket(url)

Создание соединения

const ws = new WebSocket('wss://echo.websocket.org');

socket.readyState

Состояние: 0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED

if (ws.readyState === WebSocket.OPEN) ws.send('ping');

socket.send(data)

Отправка данных

ws.send(JSON.stringify({ type: 'msg', text: 'Hi!' }));

socket.close()

Закрытие соединения

ws.close(1000, 'User left');

socket.onopen

Обработчик подключения

ws.onopen = () => ws.send('auth:token123');

socket.onmessage

Обработчик входящих сообщений

ws.onmessage = (e) => console.log(e.data);

socket.onerror

Обработчик ошибок

ws. => console.error(err);

socket.onclose

Обработчик закрытия

ws.onclose = (e) => console.log(e.code);

🔄 Жизненный цикл WebSocket-соединения

  1. Установка соединения

    const socket = new WebSocket('wss://echo.websocket.org');
  2. Обмен сообщениями

    socket.send('Привет!');
    socket.onmessage = (event) => console.log(event.data);
  3. Обработка ошибок

    socket. => console.error('Ошибка:', error);
  4. Закрытие соединения

    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)

Метод / Свойство

Назначение

createApi()

Создаёт API-слайс

builder.query()

Эндпоинт для чтения данных

builder.mutation()

Эндпоинт для изменения данных

queryFn

Кастомная логика запроса

onCacheEntryAdded

Выполняется при активации эндпоинта; завершается при отписке

updateCachedData()

Обновляет данные в кэше вручную

useQuery()

Хук для чтения

useMutation()

Хук для записи

Памятка:

  • Query = данные «читаются» → подписка + кэш

  • Mutation = данные «меняются» → вызов + инвалидация

  • onCacheEntryAdded = ваш «контроллер» для долгоживущих соединений

🔄 Жизненный цикл RTK Query-соединения

Этап

Условие

Что происходит

Пример

🟢 Инициализация

Первый вызов useChatQuery()

Создаётся кэш, запускается onCacheEntryAdded

Пользователь заходит на страницу чата

🟡 Активное соединение

Хук используется

Открывается WebSocket, обновляются данные

socket.onmessage → updateCachedData

⏳ Ожидание отписки

Все компоненты размонтированы

Срабатывает cacheEntryRemoved

Пользователь ушёл со страницы

🔴 Завершение

После cacheEntryRemoved

Выполняется finally, закрывается сокет

finally { socket.close(); }

🔄 Повторная активация

Хук вызван снова

Цикл перезапускается

Пользователь вернулся на чат

💡 Преимущества:

  • Нет утечек: соединение живёт только пока нужно UI.

  • Декларативность: вы описываете «что», а не «как управлять».

  • Повторяемость: один и тот же код работает при любом количестве переходов.


🧩 RTK Query и WebSocket: как они работают вместе?

RTK Query по умолчанию ориентирован на REST/HTTP, но его можно адаптировать и для WebSocket.

🛡️ Почему RTK Query — разумный выбор, даже для WebSocket?

Подход

Гибрид: RTK Query + WebSocket

Только new WebSocket()

Состояние данных

Единый кэш в Redux

Размазано по компонентам

Согласованность

Все данные — из одного источника

Легко рассинхронизироваться

Обработка ошибок

Встроенная (isError, retry)

Всё вручную

Жизненный цикл

Автоматическая отписка

Риск утечек

Разработка

Меньше кода, меньше багов

Высокая сложность

💡 Итог:
Голый WebSocket — это «голый провод».
RTK Query + WebSocket — продуманная архитектура: вы получаете и реалтайм, и стабильность, и масштабируемость.

📊 Сравнение подходов

Критерий

✅ RTK Query

Socket.IO

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

🧪 Сценарии для проверки

  1. Подключение → статус «Подключаемся…» → «Онлайн».

  2. Ошибка → неверный URL → статус «Ошибка».

  3. Разрыв → ожидание → статус «Соединение разорвано».

  4. Переподключение → кнопка → всё восстанавливается.

💡 Используйте 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.

Такой гибридный подход обеспечивает и стабильность данных, и отзывчивость интерфейса, без избыточной сложности.