O que Todo Dev Deve Saber sobre Hooks para WebSockets: Conexão Reativa e Reconexão Automática Já leu

Entendendo WebSockets e a Necessidade de Hooks Reativos WebSockets estabelecem uma conexão bidirecional permanente entre cliente e servidor, diferente de HTTP que é requisição-resposta. Essa natureza contínua torna a comunicação em tempo real possível, mas também introduz complexidade no gerenciamento de estado e ciclo de vida da conexão. Em aplicações modernas com frameworks como React ou Vue, precisamos de uma abstração que integre WebSockets ao modelo reativo do framework — é aí que entram os Hooks. Um Hook para WebSocket é, essencialmente, uma função reutilizável que encapsula toda a lógica de conexão, desconexão, envio e recebimento de mensagens, expondo isso de forma declarativa ao componente. Isso elimina boilerplate repetitivo e torna o código mais previsível. Diferente de gerenciar WebSocket diretamente no componente, um Hook cuida dos efeitos colaterais, tratamento de erros e sincronização automática com o ciclo de vida do componente. Arquitetura Fundamental: Estruturando um Hook Reativo Conceitos de Base Um Hook reativo para WebSocket deve ser construído sobre três

Entendendo WebSockets e a Necessidade de Hooks Reativos

WebSockets estabelecem uma conexão bidirecional permanente entre cliente e servidor, diferente de HTTP que é requisição-resposta. Essa natureza contínua torna a comunicação em tempo real possível, mas também introduz complexidade no gerenciamento de estado e ciclo de vida da conexão. Em aplicações modernas com frameworks como React ou Vue, precisamos de uma abstração que integre WebSockets ao modelo reativo do framework — é aí que entram os Hooks.

Um Hook para WebSocket é, essencialmente, uma função reutilizável que encapsula toda a lógica de conexão, desconexão, envio e recebimento de mensagens, expondo isso de forma declarativa ao componente. Isso elimina boilerplate repetitivo e torna o código mais previsível. Diferente de gerenciar WebSocket diretamente no componente, um Hook cuida dos efeitos colaterais, tratamento de erros e sincronização automática com o ciclo de vida do componente.

Arquitetura Fundamental: Estruturando um Hook Reativo

Conceitos de Base

Um Hook reativo para WebSocket deve ser construído sobre três pilares: conexão gerenciada, estado reativo e efeitos colaterais controlados. A conexão não deve ser recriada a cada render, o estado deve refletir mudanças em tempo real, e os efeitos devem ser limpos quando o componente é desmontado. Isso previne vazamento de memória e comportamentos impredizíveis.

Vamos começar com a estrutura fundamental em React, que é o framework mais comum para esse padrão:

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

export const useWebSocket = (url) => {
  const wsRef = useRef(null);
  const [isConnected, setIsConnected] = useState(false);
  const [lastMessage, setLastMessage] = useState(null);
  const [data, setData] = useState([]);

  useEffect(() => {
    // Criar a conexão apenas uma vez
    const ws = new WebSocket(url);

    ws.onopen = () => {
      setIsConnected(true);
      console.log('WebSocket conectado');
    };

    ws.onmessage = (event) => {
      const parsedData = JSON.parse(event.data);
      setLastMessage(parsedData);
      setData((prev) => [...prev, parsedData]);
    };

    ws.onerror = (error) => {
      console.error('Erro WebSocket:', error);
      setIsConnected(false);
    };

    ws.onclose = () => {
      setIsConnected(false);
      console.log('WebSocket desconectado');
    };

    wsRef.current = ws;

    // Cleanup: desconectar quando componente desmonta
    return () => {
      if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
        wsRef.current.close();
      }
    };
  }, [url]);

  const send = useCallback((message) => {
    if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
      wsRef.current.send(JSON.stringify(message));
    } else {
      console.warn('WebSocket não está pronto para enviar mensagens');
    }
  }, []);

  return { isConnected, lastMessage, data, send };
};

Este Hook básico oferece: (1) gerenciamento de conexão com useRef para evitar recriações, (2) estado reativo que reflete o status e mensagens recebidas, e (3) função send encapsulada com validação de estado.

Reconexão Automática e Resiliência

Estratégia de Retry com Backoff Exponencial

Uma aplicação real não pode simplesmente desistir quando a conexão cair. É necessário implementar lógica de reconexão automática, idealmente com backoff exponencial para não sobrecarregar o servidor. Isso significa: primeira tentativa imediata, segunda após 1s, terceira após 2s, e assim por diante até um máximo.

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

export const useWebSocketWithReconnect = (url, options = {}) => {
  const {
    maxRetries = 5,
    initialDelay = 1000,
    maxDelay = 30000,
  } = options;

  const wsRef = useRef(null);
  const reconnectTimerRef = useRef(null);
  const [isConnected, setIsConnected] = useState(false);
  const [lastMessage, setLastMessage] = useState(null);
  const [retryCount, setRetryCount] = useState(0);
  const [data, setData] = useState([]);

  const calculateDelay = useCallback((attempt) => {
    const exponentialDelay = Math.min(
      initialDelay * Math.pow(2, attempt),
      maxDelay
    );
    // Adicionar jitter para evitar thundering herd
    const jitter = Math.random() * 0.1 * exponentialDelay;
    return exponentialDelay + jitter;
  }, [initialDelay, maxDelay]);

  const connect = useCallback(() => {
    try {
      const ws = new WebSocket(url);

      ws.onopen = () => {
        setIsConnected(true);
        setRetryCount(0); // Reset retry count ao conectar
        console.log('WebSocket conectado com sucesso');
      };

      ws.onmessage = (event) => {
        try {
          const parsedData = JSON.parse(event.data);
          setLastMessage(parsedData);
          setData((prev) => [...prev, parsedData]);
        } catch (error) {
          console.error('Erro ao fazer parse da mensagem:', error);
        }
      };

      ws.onerror = (error) => {
        console.error('Erro WebSocket:', error);
      };

      ws.onclose = () => {
        setIsConnected(false);

        // Tentar reconectar se ainda houver tentativas disponíveis
        if (retryCount < maxRetries) {
          const delay = calculateDelay(retryCount);
          console.log(`Reconectando em ${delay.toFixed(0)}ms (tentativa ${retryCount + 1}/${maxRetries})`);

          reconnectTimerRef.current = setTimeout(() => {
            setRetryCount((prev) => prev + 1);
            connect();
          }, delay);
        } else {
          console.error('Máximo de tentativas de reconexão atingido');
        }
      };

      wsRef.current = ws;
    } catch (error) {
      console.error('Erro ao criar WebSocket:', error);
    }
  }, [url, retryCount, maxRetries, calculateDelay]);

  useEffect(() => {
    connect();

    return () => {
      // Cleanup: limpar timer e fechar conexão
      if (reconnectTimerRef.current) {
        clearTimeout(reconnectTimerRef.current);
      }
      if (wsRef.current) {
        wsRef.current.close();
      }
    };
  }, [connect]);

  const send = useCallback((message) => {
    if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
      wsRef.current.send(JSON.stringify(message));
      return true;
    } else {
      console.warn('WebSocket não está pronto. Status:', wsRef.current?.readyState);
      return false;
    }
  }, []);

  const disconnect = useCallback(() => {
    if (reconnectTimerRef.current) {
      clearTimeout(reconnectTimerRef.current);
    }
    if (wsRef.current) {
      wsRef.current.close();
    }
  }, []);

  return {
    isConnected,
    lastMessage,
    data,
    send,
    disconnect,
    retryCount,
    isRetrying: retryCount > 0 && !isConnected,
  };
};

Esta versão melhorada adiciona: (1) cálculo dinâmico de delay com backoff exponencial, (2) jitter para evitar ressincronização simultânea de múltiplos clientes, (3) limite de tentativas configurável, (4) rastreamento de quantas tentativas foram feitas, e (5) funções disconnect para forçar desconexão quando necessário.

Implementação Prática em Componentes

Caso de Uso Real: Chat em Tempo Real

Agora vamos integrar nosso Hook em um componente real. Considere um chat onde mensagens chegam em tempo real:

import React, { useState } from 'react';
import { useWebSocketWithReconnect } from './hooks/useWebSocketWithReconnect';

export const ChatComponent = ({ userId }) => {
  const [inputMessage, setInputMessage] = useState('');
  const { isConnected, data, send, isRetrying } = useWebSocketWithReconnect(
    `wss://api.example.com/chat/${userId}`,
    { maxRetries: 5, initialDelay: 1000 }
  );

  const handleSendMessage = (e) => {
    e.preventDefault();
    if (!inputMessage.trim()) return;

    const success = send({
      type: 'message',
      text: inputMessage,
      timestamp: new Date().toISOString(),
      userId,
    });

    if (success) {
      setInputMessage('');
    } else {
      alert('Falha ao enviar. Reconectando...');
    }
  };

  return (
    <div className="chat-container">
      <div className="status-bar">
        {isConnected ? (
          <span className="status-connected">🟢 Conectado</span>
        ) : isRetrying ? (
          <span className="status-retrying">🟡 Reconectando...</span>
        ) : (
          <span className="status-disconnected">🔴 Desconectado</span>
        )}
      </div>

      <div className="messages">
        {data.map((msg, idx) => (
          <div key={idx} className="message">
            <strong>{msg.userId}:</strong> {msg.text}
            <small>{new Date(msg.timestamp).toLocaleTimeString()}</small>
          </div>
        ))}
      </div>

      <form onSubmit={handleSendMessage} className="input-form">
        <input
          type="text"
          value={inputMessage}
          onChange={(e) => setInputMessage(e.target.value)}
          placeholder="Digite uma mensagem..."
          disabled={!isConnected}
        />
        <button type="submit" disabled={!isConnected || !inputMessage.trim()}>
          Enviar
        </button>
      </form>
    </div>
  );
};

Aqui o Hook simplifica enormemente o componente. Não há necessidade de gerenciar timers, estados de reconexão ou tratamento manual de ciclo de vida — tudo é encapsulado. O componente apenas se preocupa em renderizar a UI baseado no estado fornecido pelo Hook.

Avançado: Hook com Context para Múltiplos Componentes

Em aplicações maiores, pode ser necessário compartilhar a conexão entre vários componentes. Nesse caso, combinamos o Hook com Context:

import React, { createContext, useContext } from 'react';
import { useWebSocketWithReconnect } from './useWebSocketWithReconnect';

const WebSocketContext = createContext(null);

export const WebSocketProvider = ({ url, children, options }) => {
  const wsState = useWebSocketWithReconnect(url, options);

  return (
    <WebSocketContext.Provider value={wsState}>
      {children}
    </WebSocketContext.Provider>
  );
};

export const useWebSocket = () => {
  const context = useContext(WebSocketContext);
  if (!context) {
    throw new Error('useWebSocket deve ser usado dentro de WebSocketProvider');
  }
  return context;
};

E seu uso em App:

function App() {
  return (
    <WebSocketProvider 
      url="wss://api.example.com/notifications"
      options={{ maxRetries: 5, initialDelay: 1000 }}
    >
      <ChatComponent userId="user123" />
      <NotificationPanel />
      <StatusIndicator />
    </WebSocketProvider>
  );
}

Agora qualquer componente dentro da árvore pode usar useWebSocket() sem repeti-lo. A conexão é única e gerenciada centralmente, economizando recursos e simplificando sincronização.

Padrões Avançados e Otimizações

Tratamento de Backpressure e Fila de Mensagens

Em alguns cenários, o servidor pode estar lento para processar mensagens, ou o cliente envia mais rápido que o servidor consegue receber. É prudente implementar uma fila:

const useWebSocketWithQueue = (url, options = {}) => {
  const { queueSize = 100 } = options;
  const messageQueueRef = useRef([]);
  const isProcessingRef = useRef(false);

  const { isConnected, send: baseSend, ...rest } = useWebSocketWithReconnect(url, options);

  const processQueue = useCallback(() => {
    if (isProcessingRef.current || !isConnected || messageQueueRef.current.length === 0) {
      return;
    }

    isProcessingRef.current = true;
    const message = messageQueueRef.current.shift();

    const success = baseSend(message);

    if (success) {
      isProcessingRef.current = false;
      // Processar próxima mensagem após pequeno delay
      setTimeout(processQueue, 50);
    } else {
      // Se falhar, recolocar na fila
      messageQueueRef.current.unshift(message);
      isProcessingRef.current = false;
    }
  }, [isConnected, baseSend]);

  useEffect(() => {
    processQueue();
  }, [isConnected, processQueue]);

  const send = useCallback((message) => {
    if (messageQueueRef.current.length < queueSize) {
      messageQueueRef.current.push(message);
      processQueue();
      return true;
    } else {
      console.warn('Fila de mensagens cheia, descartando mensagem');
      return false;
    }
  }, [processQueue, queueSize]);

  return { isConnected, send, queueLength: messageQueueRef.current.length, ...rest };
};

Este padrão garante que mensagens não se perdem e são processadas de forma ordenada, mesmo sob alta carga.

Heartbeat para Detectar Conexões Mortas

Algumas conexões WebSocket podem "morrer" silenciosamente sem dispara o evento onclose. Um heartbeat ajuda a detectar isso:

const useWebSocketWithHeartbeat = (url, options = {}) => {
  const { heartbeatInterval = 30000 } = options;
  const heartbeatTimerRef = useRef(null);

  const wsHook = useWebSocketWithReconnect(url, options);
  const { isConnected, send } = wsHook;

  useEffect(() => {
    if (!isConnected) return;

    heartbeatTimerRef.current = setInterval(() => {
      const success = send({ type: 'ping', timestamp: Date.now() });

      if (!success) {
        console.warn('Falha ao enviar heartbeat, desconectando...');
        clearInterval(heartbeatTimerRef.current);
      }
    }, heartbeatInterval);

    return () => {
      if (heartbeatTimerRef.current) {
        clearInterval(heartbeatTimerRef.current);
      }
    };
  }, [isConnected, send, heartbeatInterval]);

  return wsHook;
};

O servidor deve responder com pong quando receber ping. Se não responder dentro de um tempo limite, o cliente desconecta e reconecta automaticamente.

Conclusão

Hooks para WebSockets elevam o nível de abstração, permitindo que você trate comunicação em tempo real como um recurso reativo declarativo, similar a qualquer outro estado no seu componente. Os três pontos principais aprendidos foram: (1) encapsulamento de lógica de conexão em uma função reutilizável elimina boilerplate e reduz erros, (2) reconexão automática com backoff exponencial garante resiliência sem intervenção manual, e (3) padrões como fila de mensagens e heartbeat preparam sua aplicação para cenários do mundo real onde conexões são instáveis e carga é imprevisível. Domine esses conceitos e você construirá aplicações em tempo real robustas e escaláveis.

Referências


Artigos relacionados