React с WebSocket — реалтайм в компонентах

18 сентября 2026
Автор

Антон Ларичев

Что такое WebSocket и зачем он нужен в React

WebSocket — протокол двусторонней связи между клиентом и сервером по одному постоянному TCP-соединению. В отличие от HTTP, где клиент всегда инициирует запрос, WebSocket позволяет серверу самостоятельно отправлять данные клиенту в любой момент.

В React-приложениях WebSocket применяется для чатов и мессенджеров, отображения биржевых котировок в реальном времени, системных уведомлений, совместного редактирования документов и онлайн-дашбордов с живыми метриками.

Курс по теме

Пройти курс React и Redux Toolkit

40 000+ студентов · рейтинг 4.8 · гарантия возврата 30 дней

Нативный WebSocket API

Браузер предоставляет встроенный класс WebSocket. Базовый пример работы:

const socket = new WebSocket('wss://example.com/ws');

socket.onopen = () => {
  console.log('Соединение установлено');
  socket.send(JSON.stringify({ type: 'hello' }));
};

socket.onmessage = (event) => {
  const data = JSON.parse(event.data);
  console.log('Получено:', data);
};

socket.onerror = (error) => {
  console.error('Ошибка:', error);
};

socket.onclose = () => {
  console.log('Соединение закрыто');
};

Прямое использование этого API внутри компонентов ведёт к утечкам памяти и дублированию логики. Правильный подход — вынести WebSocket в кастомный хук.

Создание кастомного хука useWebSocket

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

import { useEffect, useRef, useState, useCallback } from 'react';

type WebSocketStatus = 'connecting' | 'open' | 'closing' | 'closed';

interface UseWebSocketOptions {
  onMessage?: (data: unknown) => void;
  onOpen?: () => void;
  onClose?: () => void;
  onError?: (error: Event) => void;
}

interface UseWebSocketReturn {
  status: WebSocketStatus;
  send: (data: unknown) => void;
  close: () => void;
}

export function useWebSocket(
  url: string,
  options: UseWebSocketOptions = {}
): UseWebSocketReturn {
  const [status, setStatus] = useState<WebSocketStatus>('connecting');
  const socketRef = useRef<WebSocket | null>(null);
  const optionsRef = useRef(options);

  optionsRef.current = options;

  useEffect(() => {
    const socket = new WebSocket(url);
    socketRef.current = socket;
    setStatus('connecting');

    socket.onopen = () => {
      setStatus('open');
      optionsRef.current.onOpen?.();
    };

    socket.onmessage = (event) => {
      try {
        const data = JSON.parse(event.data);
        optionsRef.current.onMessage?.(data);
      } catch {
        optionsRef.current.onMessage?.(event.data);
      }
    };

    socket.onerror = (error) => {
      optionsRef.current.onError?.(error);
    };

    socket.onclose = () => {
      setStatus('closed');
      optionsRef.current.onClose?.();
    };

    return () => {
      socket.close();
    };
  }, [url]);

  const send = useCallback((data: unknown) => {
    const socket = socketRef.current;
    if (socket && socket.readyState === WebSocket.OPEN) {
      socket.send(typeof data === 'string' ? data : JSON.stringify(data));
    }
  }, []);

  const close = useCallback(() => {
    socketRef.current?.close();
  }, []);

  return { status, send, close };
}

Ключевые решения в этом хуке:

  • optionsRef хранит актуальные коллбэки без перезапуска useEffect. Если передавать options напрямую в зависимости, каждый рендер будет пересоздавать соединение.
  • Очистка в return () => socket.close() гарантирует закрытие соединения при размонтировании.
  • send проверяет readyState перед отправкой — это предотвращает ошибки при попытке отправить сообщение до открытия соединения.

Использование хука в компоненте

Пример простого чата с использованием useWebSocket:

import { useState } from 'react';
import { useWebSocket } from './hooks/useWebSocket';

interface ChatMessage {
  id: string;
  author: string;
  text: string;
  timestamp: number;
}

export function ChatRoom({ roomId }: { roomId: string }) {
  const [messages, setMessages] = useState<ChatMessage[]>([]);
  const [inputText, setInputText] = useState('');

  const { status, send } = useWebSocket(
    `wss://api.example.com/chat/${roomId}`,
    {
      onMessage: (data) => {
        const message = data as ChatMessage;
        setMessages((prev) => [...prev, message]);
      },
    }
  );

  const handleSend = () => {
    if (!inputText.trim()) return;
    send({ type: 'message', text: inputText });
    setInputText('');
  };

  return (
    <div>
      <div>Статус: {status}</div>
      <div>
        {messages.map((msg) => (
          <div key={msg.id}>
            <strong>{msg.author}:</strong> {msg.text}
          </div>
        ))}
      </div>
      <input
        value={inputText}
        onChange={(e) => setInputText(e.target.value)}
        onKeyDown={(e) => e.key === 'Enter' && handleSend()}
        disabled={status !== 'open'}
      />
      <button onClick={handleSend} disabled={status !== 'open'}>
        Отправить
      </button>
    </div>
  );
}

Автоматическое переподключение

В продакшн-приложениях соединение может прерваться из-за проблем с сетью. Добавим логику переподключения с экспоненциальной задержкой:

import { useEffect, useRef, useState, useCallback } from 'react';

interface UseReconnectingWebSocketOptions {
  onMessage?: (data: unknown) => void;
  onOpen?: () => void;
  onClose?: () => void;
  maxRetries?: number;
  retryInterval?: number;
}

export function useReconnectingWebSocket(
  url: string,
  options: UseReconnectingWebSocketOptions = {}
) {
  const { maxRetries = 5, retryInterval = 1000 } = options;
  const [status, setStatus] = useState<'connecting' | 'open' | 'closed' | 'failed'>('connecting');
  const [retryCount, setRetryCount] = useState(0);
  const socketRef = useRef<WebSocket | null>(null);
  const optionsRef = useRef(options);
  const retryCountRef = useRef(0);
  const isMountedRef = useRef(true);

  optionsRef.current = options;

  const connect = useCallback(() => {
    if (!isMountedRef.current) return;

    const socket = new WebSocket(url);
    socketRef.current = socket;
    setStatus('connecting');

    socket.onopen = () => {
      if (!isMountedRef.current) return;
      retryCountRef.current = 0;
      setRetryCount(0);
      setStatus('open');
      optionsRef.current.onOpen?.();
    };

    socket.onmessage = (event) => {
      if (!isMountedRef.current) return;
      try {
        optionsRef.current.onMessage?.(JSON.parse(event.data));
      } catch {
        optionsRef.current.onMessage?.(event.data);
      }
    };

    socket.onclose = () => {
      if (!isMountedRef.current) return;
      optionsRef.current.onClose?.();

      if (retryCountRef.current < maxRetries) {
        const delay = retryInterval * Math.pow(2, retryCountRef.current);
        retryCountRef.current += 1;
        setRetryCount(retryCountRef.current);
        setStatus('connecting');
        setTimeout(connect, delay);
      } else {
        setStatus('failed');
      }
    };
  }, [url, maxRetries, retryInterval]);

  useEffect(() => {
    isMountedRef.current = true;
    connect();

    return () => {
      isMountedRef.current = false;
      socketRef.current?.close();
    };
  }, [connect]);

  const send = useCallback((data: unknown) => {
    const socket = socketRef.current;
    if (socket?.readyState === WebSocket.OPEN) {
      socket.send(typeof data === 'string' ? data : JSON.stringify(data));
    }
  }, []);

  return { status, retryCount, send };
}

Экспоненциальная задержка (retryInterval * 2^retryCount) предотвращает шторм запросов при массовом переподключении клиентов после падения сервера. После исчерпания попыток статус переходит в failed, и компонент отображает пользователю соответствующее сообщение.

Паттерн с контекстом для общего соединения

Когда несколько компонентов должны использовать одно WebSocket-соединение, выносим его в React Context:

import { createContext, useContext, useEffect, useRef, useState } from 'react';

interface WebSocketContextValue {
  status: string;
  send: (data: unknown) => void;
  subscribe: (handler: (data: unknown) => void) => () => void;
}

const WebSocketContext = createContext<WebSocketContextValue | null>(null);

export function WebSocketProvider({
  url,
  children,
}: {
  url: string;
  children: React.ReactNode;
}) {
  const [status, setStatus] = useState('connecting');
  const socketRef = useRef<WebSocket | null>(null);
  const handlersRef = useRef<Set<(data: unknown) => void>>(new Set());

  useEffect(() => {
    const socket = new WebSocket(url);
    socketRef.current = socket;

    socket.onopen = () => setStatus('open');
    socket.onclose = () => setStatus('closed');
    socket.onmessage = (event) => {
      try {
        const data = JSON.parse(event.data);
        handlersRef.current.forEach((handler) => handler(data));
      } catch {
        handlersRef.current.forEach((handler) => handler(event.data));
      }
    };

    return () => socket.close();
  }, [url]);

  const send = (data: unknown) => {
    const socket = socketRef.current;
    if (socket?.readyState === WebSocket.OPEN) {
      socket.send(JSON.stringify(data));
    }
  };

  const subscribe = (handler: (data: unknown) => void) => {
    handlersRef.current.add(handler);
    return () => handlersRef.current.delete(handler);
  };

  return (
    <WebSocketContext.Provider value={{ status, send, subscribe }}>
      {children}
    </WebSocketContext.Provider>
  );
}

export function useWebSocketContext() {
  const context = useContext(WebSocketContext);
  if (!context) {
    throw new Error('useWebSocketContext must be used inside WebSocketProvider');
  }
  return context;
}

Подписка через Set позволяет нескольким компонентам независимо обрабатывать сообщения. Каждый вызов subscribe возвращает функцию отписки для использования в useEffect:

function NotificationBadge() {
  const [count, setCount] = useState(0);
  const { subscribe } = useWebSocketContext();

  useEffect(() => {
    const unsubscribe = subscribe((data: unknown) => {
      const msg = data as { type: string };
      if (msg.type === 'notification') {
        setCount((prev) => prev + 1);
      }
    });

    return unsubscribe;
  }, [subscribe]);

  return <span>{count}</span>;
}

Отображение данных в реальном времени: пример с котировками

Практический пример — компонент для отображения биржевых котировок:

interface StockPrice {
  symbol: string;
  price: number;
  change: number;
}

function StockTicker({ symbol }: { symbol: string }) {
  const [price, setPrice] = useState<StockPrice | null>(null);

  const { status } = useWebSocket(
    `wss://market.example.com/stocks/${symbol}`,
    {
      onMessage: (data) => setPrice(data as StockPrice),
    }
  );

  if (status === 'connecting') {
    return <div>Подключение...</div>;
  }

  if (status === 'closed' || !price) {
    return <div>Нет данных</div>;
  }

  return (
    <div>
      <span>{price.symbol}</span>
      <span>{price.price.toFixed(2)} $</span>
      <span style={{ color: price.change >= 0 ? 'green' : 'red' }}>
        {price.change >= 0 ? '+' : ''}{price.change.toFixed(2)}%
      </span>
    </div>
  );
}

Типизация сообщений с дискриминируемыми объединениями

При работе с несколькими типами сообщений TypeScript помогает сохранить типобезопасность через дискриминируемые объединения:

type ServerMessage =
  | { type: 'chat'; payload: { author: string; text: string } }
  | { type: 'notification'; payload: { title: string; body: string } }
  | { type: 'user_joined'; payload: { username: string } }
  | { type: 'error'; payload: { code: number; message: string } };

function handleMessage(data: unknown): void {
  const message = data as ServerMessage;

  switch (message.type) {
    case 'chat':
      addMessageToChat(message.payload.author, message.payload.text);
      break;
    case 'notification':
      showNotification(message.payload.title, message.payload.body);
      break;
    case 'user_joined':
      addUserToList(message.payload.username);
      break;
    case 'error':
      handleError(message.payload.code, message.payload.message);
      break;
  }
}

Компилятор TypeScript гарантирует, что в каждой ветке switch вы обращаетесь только к полям, существующим у данного типа сообщения.

Тестирование компонентов с WebSocket

Для тестирования компонентов, использующих WebSocket, подменяем глобальный конструктор моком:

class MockWebSocket {
  static instances: MockWebSocket[] = [];
  readyState = WebSocket.CONNECTING;
  onopen: (() => void) | null = null;
  onmessage: ((event: MessageEvent) => void) | null = null;
  onclose: (() => void) | null = null;
  onerror: ((event: Event) => void) | null = null;

  constructor(public url: string) {
    MockWebSocket.instances.push(this);
    setTimeout(() => {
      this.readyState = WebSocket.OPEN;
      this.onopen?.();
    }, 0);
  }

  send = jest.fn();

  close = jest.fn(() => {
    this.readyState = WebSocket.CLOSED;
    this.onclose?.();
  });

  simulateMessage(data: unknown) {
    const event = new MessageEvent('message', {
      data: JSON.stringify(data),
    });
    this.onmessage?.(event);
  }
}

beforeEach(() => {
  MockWebSocket.instances = [];
  global.WebSocket = MockWebSocket as unknown as typeof WebSocket;
});

test('отображает входящие сообщения', async () => {
  render(<ChatRoom roomId="test-room" />);

  await waitFor(() => {
    expect(MockWebSocket.instances).toHaveLength(1);
  });

  const socket = MockWebSocket.instances[0];
  socket.simulateMessage({
    id: '1',
    author: 'Alice',
    text: 'Привет!',
    timestamp: Date.now(),
  });

  expect(await screen.findByText('Alice:')).toBeInTheDocument();
  expect(screen.getByText('Привет!')).toBeInTheDocument();
});

Метод simulateMessage имитирует входящие сообщения от сервера, не поднимая реального соединения.

Частые ошибки

Создание соединения вне useEffect. Если написать const socket = new WebSocket(url) на уровне тела компонента, новый экземпляр будет создаваться при каждом рендере. Всегда создавайте WebSocket внутри useEffect.

Бесконечный цикл из-за зависимостей. Передача объекта коллбэков напрямую в массив зависимостей useEffect вызовет переподключение при каждом рендере, поскольку объект создаётся заново. Используйте useRef для хранения актуальных коллбэков.

Отсутствие очистки. Без return () => socket.close() соединение остаётся активным после размонтирования компонента. Это приводит к утечкам памяти и ошибкам «Can't perform a React state update on an unmounted component».

Отправка до открытия соединения. Вызов socket.send() до события onopen вызывает исключение InvalidStateError. Всегда проверяйте socket.readyState === WebSocket.OPEN перед отправкой.

Отсутствие обработки JSON-ошибок. JSON.parse() выбрасывает исключение при невалидном JSON. Оборачивайте парсинг в try/catch, особенно если сервер может присылать текстовые сообщения.

Заключение

WebSocket в React-приложениях лучше всего организовывать через кастомные хуки. Базовый useWebSocket покрывает большинство сценариев. Для продакшн-приложений добавляйте логику переподключения с экспоненциальной задержкой и флагом isMounted, чтобы избежать обновления состояния размонтированного компонента. Когда несколько компонентов должны разделять одно соединение — используйте Context API с Set подписчиков. TypeScript с дискриминируемыми объединениями сделает обработку разнотипных сообщений надёжной и удобной для поддержки.

Чтобы глубже изучить React и научиться строить полноценные реалтайм-приложения, переходите на курс React — полный курс на PurpleSchool.

Стрелочка влевоРефакторинг React-кода: техники и лучшие практикиReact Native приложение с нуля до публикации в сторахСтрелочка вправо

Постройте личный план изучения React до уровня Middle — бесплатно!

React — часть карты развития Frontend

  • step100+ шагов развития
  • lessons30 бесплатных лекций
  • lessons300 бонусных рублей на счет

Бесплатные лекции

Все гайды по React

React тестирование с Vitest
React с TanStack Router
Uncontrolled Components: когда DOM управляет даннымиБезопасность в React: защита от XSS, CSRF и утечек данныхRender Props: гибкое управление рендерингом в ReactРефакторинг React-кода: техники и лучшие практикиReact с WebSocket — реалтайм в компонентахReact Native приложение с нуля до публикации в сторахПрофилирование React: как найти и устранить узкие местаЧастичное применение: как создавать компоненты без лишнего кодаИменование компонентов в React: соглашения и лучшие практикиЛенивая загрузка: как ускорить React-приложение в разыОшибки гидратации в React и способы их устраненияuseMemo: как спасти производительность от тяжелых вычисленийHOC в React: мастерство композиции компонентовError Boundaries: создаем надежные React-приложенияКонтролируемые компоненты в React: полный контроль над формамиCompound Components в React: создаем гибкие компоненты с мощным APIДокументирование компонентов в React: Storybook, JSDoc и READMEКомпозиция компонентов в React: строим гибкие интерфейсыКомментирование кода в React: когда и как писать комментарииCode Splitting в React: как уменьшить бандл и ускорить загрузку приложенияАсинхронные компоненты в React: новый стандарт работы с даннымиДоступность (a11y) в React: ARIA, семантика и клавиатурная навигация
Zustand — управление состоянием в ReactZod - валидация с TypeScriptYup - валидация схемXState - конечные автоматыТемизация в ReactТестирование хуковTailwind CSS с ReactSWR - библиотека для запросовStyled Components — стилизация через JSStorybook - документация компонентовSnapshots тестированиеRTK Query - работа с APIRedux Toolkit - современный ReduxRecoil — библиотека управления состоянием от FacebookВиртуализация списков с react-window: как отображать тысячи элементов без лаговReact Toastify - уведомления в ReactReact Testing LibraryСоздание таблиц в React гайд по react-tableReact Spring - анимацииРабота с формами и селектами в ReactReact Query (TanStack Query) - работа с серверомПлагины в React что это и как их использоватьReact PDF - работа с PDF файламиОбзор популярных библиотек для ReactReact Icons - библиотека иконок для ReactReact Hook Form — валидация форм в ReactReact Dropzone — загрузка файловПодключение Bootstrap к React-приложениюReact Beautiful DnD - перетаскивание элементовАнимация при монтировании компонентов в ReactМокирование APIMobX — реактивное управление состоянием в ReactМикрофронтенды с React (micro-frontends)Загрузка и индикаторыАнимация списков в ReactJotai - атомарное состояниеБесконечная прокруткаFramer Motion - библиотека анимацийEmotion — библиотека CSS-in-JSДинамические стили в ReactE2E тестирование с CypressCSSTransition - переходыCSS-in-JS — плюсы и минусыКонтекст vs Redux — когда что использоватьИспользование Chart.js в ReactAxios с ReactТестирование асинхронных компонентовОбработка ошибок API
useState в React что это и как использоватьuseTransition - плавные переходы между состояниямиuseSyncExternalStore — работа с внешними сторамиuseRef в React — создание ссылок на DOM и значенияuseOptimistic — оптимистичные обновления UIuseLayoutEffect в React — эффект до отрисовкиuseInsertionEffect — внедрение стилей до мутаций DOMuseImperativeHandle в React — настройка ref дочернего компонентаuseId — генерация уникальных идентификаторовuseFormStatus - отслеживание статуса отправки формыuseDeferredValue — отложенное обновление состоянияuseDebugValue — отладка кастомных хуковuseCallback в React — мемоизация функцийuseReducer — альтернатива useState для сложной логикиuseMemo в React: как и когда оптимизировать тяжелые вычисленияХук use() в React 19useEffect в React что это и как использоватьuseContext — работа с контекстом в ReactuseCallback в React — мемоизация функций и оптимизация ре-рендеровuseActionState в React 19Оптимизация рендеринга в React: от теории к глубокой практикеЧто такое useRef и как его применять в ReactКак и зачем использовать React HooksУправление состоянием в React через ContextНовые возможности React 19Как предотвратить лишние ре-рендеры в React: полное руководствоuseMemo vs useCallback: подробное руководство по мемоизации в ReactПравила хуков — правила использованияflushSync — синхронные обновления состояния в ReactuseEffect vs useLayoutEffect: в чём разница и какой хук выбрать?Кастомные хуки в React — создание собственных хуковuseState продвинутое использование в React
Transition API — плавные обновления интерфейса в ReactReact Suspense — приостановка рендераStrictMode в React — как находить ошибки на этапе разработкиСерверные компоненты React (RSC) — подробный разбор и практикаКак работает рендеринг в ReactЧто такое props в React и как их правильно использоватьКак работает JSX связка React и HTMLЧто такое React.js и как его использоватьКак использовать элементы в ReactКак использовать React DOM в проектеЧто такое компоненты в React и как их применятьРабота с children в ReactПорталы в React: рендер компонентов вне иерархии DOMFragment в React: группировка элементов без лишних узлов DOMCSS Modules в ReactConcurrent Mode — конкурентный режим в React
Открыть базу знаний

Лучшие курсы по теме

изображение курса

React и Redux Toolkit

Антон Ларичев
AI-тренажерыAI-тренажеры
Практика в студииПрактика в студии
Гарантия
Бонусы
иконка звёздочки рейтинга4.8
3 999 ₽ 6 990 ₽
Подробнее
изображение курса

TypeScript с нуля

Антон Ларичев
AI-тренажерыAI-тренажеры
Практика в студииПрактика в студии
Гарантия
Бонусы
иконка звёздочки рейтинга4.8
3 999 ₽ 6 990 ₽
Подробнее
изображение курса

Next.js - с нуля

Антон Ларичев
AI-тренажерыAI-тренажеры
Практика в студииПрактика в студии
Гарантия
Бонусы
иконка звёздочки рейтинга4.7
3 999 ₽ 6 990 ₽
Подробнее

Отправить комментарий