React и хуки

Работа с WebSocket в React чаще всего строится вокруг управления жизненным циклом соединения и подписок через useEffect, а также хранения состояния сообщений в useState или внешних сторах. При использовании STOMP поверх WebSocket ключевая задача заключается в корректной инициализации клиента, контроле переподключений и изоляции подписок от повторных рендеров компонентов.

Основной принцип архитектуры: STOMP-клиент существует вне рендера, а React-хуки управляют его жизненным циклом.


Базовая модель подключения STOMP в функциональном компоненте

Типовой сценарий начинается с создания клиента и установки соединения при монтировании компонента.

import { useEffect, useRef, useState } from "react";
import { Client } from "stompjs";

В современных проектах чаще используется пакет @stomp/stompjs, однако концептуально подход остается одинаковым: клиент инкапсулируется и хранится в useRef.

const Chat = () => {
  const clientRef = useRef(null);
  const [messages, setMessages] = useState([]);
};

Использование useRef критично: оно предотвращает пересоздание STOMP-клиента при каждом рендере, сохраняя единственный экземпляр соединения.


Инициализация соединения через useEffect

Подключение выполняется внутри useEffect с пустым массивом зависимостей, чтобы обеспечить однократное выполнение логики.

useEffect(() => {
  const client = new Client({
    brokerURL: "ws://localhost:8080/ws",
    reconnectDelay: 5000,
    debug: (str) => {
      console.log(str);
    }
  });

  client.onConn ect = () => {
    console.log("connected");

    client.subscribe("/topic/messages", (message) => {
      const body = JSON.parse(message.body);

      setMessages((prev) => [...prev, body]);
    });
  };

  client.onStompEr ror = (frame) => {
    console.error("Broker error:", frame.headers["message"]);
  };

  client.activate();

  clientRef.current = client;

  return () => {
    client.deactivate();
  };
}, []);

Ключевые моменты:

  • activate() инициирует соединение
  • deactivate() гарантирует корректное закрытие сокета
  • subscribe() выполняется только после успешного подключения
  • setMessages использует функциональное обновление для защиты от гонок состояния

Изоляция подписок и предотвращение дублирования

Частая ошибка при использовании STOMP в React — повторная подписка при каждом рендере или повторном подключении.

Для контроля подписок используется хранение subscription-объекта:

const subscriptionRef = useRef(null);

И дальнейшая регистрация:

client.onConn ect = () => {
  if (subscriptionRef.current) {
    subscriptionRef.current.unsubscribe();
  }

  subscriptionRef.current = client.subscribe("/topic/messages", (message) => {
    const body = JSON.parse(message.body);
    setMessages((prev) => [...prev, body]);
  });
};

Такой подход исключает накопление подписок при реконнектах.


Отправка сообщений через STOMP из React

Отправка данных реализуется через метод publish (или send в старых версиях).

const sendMessage = (text) => {
  if (!clientRef.current || !clientRef.current.connected) return;

  clientRef.current.publish({
    destination: "/app/chat",
    body: JSON.stringify({
      text,
      timestamp: Date.now()
    })
  });
};

Особенности:

  • обязательная проверка connected
  • сериализация тела сообщения
  • разделение destination на endpoint отправки и топики подписки

Управление состоянием сообщений через useState

Модель хранения сообщений должна учитывать потоковую природу данных.

const [messages, setMessages] = useState([]);

Обновление состояния через функциональный паттерн:

setMessages((prev) => [...prev, newMessage]);

Этот подход предотвращает потерю данных при конкурентных обновлениях, особенно при высокой частоте входящих сообщений.


Хук-обертка для переиспользования STOMP логики

Вынос логики в кастомный хук позволяет отделить транспортный слой от UI.

import { useEffect, useRef, useState } from "react";
import { Client } from "stompjs";

export const useStompClient = (url, topic) => {
  const clientRef = useRef(null);
  const subscriptionRef = useRef(null);
  const [messages, setMessages] = useState([]);

  useEffect(() => {
    const client = new Client({
      brokerURL: url,
      reconnectDelay: 3000
    });

    client.onConn ect = () => {
      subscriptionRef.current = client.subscribe(topic, (msg) => {
        setMessages((prev) => [...prev, JSON.parse(msg.body)]);
      });
    };

    client.activate();
    clientRef.current = client;

    return () => client.deactivate();
  }, [url, topic]);

  const send = (destination, payload) => {
    if (!clientRef.current?.connected) return;

    clientRef.current.publish({
      destination,
      body: JSON.stringify(payload)
    });
  };

  return { messages, send };
};

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

STOMP-клиент поддерживает автоматическое переподключение, но логика приложения должна учитывать потерю состояния подписок.

При каждом onConnect необходимо:

  • повторно создавать подписки
  • синхронизировать состояние с сервером при необходимости
  • очищать устаревшие subscription-объекты
client.onWebSocketCl ose = () => {
  console.log("socket closed");
};

Дополнительно можно отслеживать:

  • onWebSocketError
  • onStompError
  • beforeConnect

Интеграция с несколькими топиками

При работе с несколькими потоками данных структура расширяется через словарь подписок.

const subscriptions = useRef({});

Регистрация:

subscriptions.current["chat"] = client.subscribe("/topic/chat", handler);
subscriptions.current["notifications"] = client.subscribe("/topic/notifications", handler2);

Очистка:

Object.values(subscriptions.current).forEach((sub) => sub.unsubscribe());
subscriptions.current = {};

Оптимизация перерендеров при частых сообщениях

Высокочастотные сообщения могут приводить к перегрузке React-рендера. Для оптимизации применяется:

  • буферизация сообщений
  • batching через setTimeout
  • использование useReducer

Пример через буфер:

const bufferRef = useRef([]);

useEffect(() => {
  const interval = setInterval(() => {
    if (bufferRef.current.length === 0) return;

    setMessages((prev) => [...prev, ...bufferRef.current]);
    bufferRef.current = [];
  }, 200);

  return () => clearInterval(interval);
}, []);

При получении сообщения:

bufferRef.current.push(newMessage);

Работа с аутентификацией в STOMP-соединении

Аутентификация часто передается через headers при подключении:

const client = new Client({
  brokerURL: "ws://localhost:8080/ws",
  connectHeaders: {
    Authorization: "Bearer token"
  }
});

При обновлении токена требуется:

  • деактивация клиента
  • пересоздание соединения
  • повторная подписка

Разделение UI и транспортного слоя

Корректная архитектура React-приложения с STOMP подразумевает:

  • UI-компоненты не знают о WebSocket
  • хук или сервис инкапсулирует STOMP
  • состояние передается через props или context

Пример использования context:

const StompContext = createContext(null);

Провайдер управляет жизненным циклом клиента, а компоненты только потребляют данные и методы отправки сообщений.


Контроль памяти и утечек соединений

Основные источники утечек:

  • неотписанные subscriptions
  • активные таймеры буферизации
  • не вызванный deactivate()

Корректный cleanup:

return () => {
  Object.values(subscriptions.current).forEach((s) => s.unsubscribe());
  client.deactivate();
};

Такая модель гарантирует освобождение ресурсов при размонтировании компонентов и смене маршрутов в SPA.