Tutoriais

MQTT no React: dashboards em tempo real com hooks

Use MQTT no React com MQTT.js 5 sobre WebSockets seguros: um provider, um hook useMqtt, assinaturas com contagem de referências, cleanup e updates em lote.

Atualizado · 9 min de leitura

MQTT no React: dashboards em tempo real com hooks

Para usar MQTT no React, conecte com o MQTT.js por WebSockets seguros (wss://), mantenha um único cliente em um context provider e deixe os componentes assinarem topics por meio de um hook que limpa o que cria. Os dois erros que quebram a maioria das apps React com MQTT são abrir uma conexão nova a cada render e chamar setState para cada mensagem; os dois são fáceis de evitar com a estrutura certa.

Este tutorial monta essa estrutura passo a passo: um provider, um hook useMqtt, assinaturas com contagem de referências, um hook de "último valor" em lote para dashboards e uma forma de testar tudo com um broker real.

Instale o MQTT.js

Instalação
npm i mqtt

O MQTT.js 5 traz os próprios tipos TypeScript e um build para navegador, então funciona com Vite, webpack 5 e Next.js sem polyfills manuais do Node. No navegador ele fala apenas MQTT sobre WebSockets: páginas web não têm acesso a TCP. Use o listener de WebSocket seguro:

ConfiguraçãoValor
URLwss://broker.connectmq.com:8084/mqtt
ProtocoloMQTT 3.1.1 ou 5 (protocolVersion: 5)
Authusuário / senha de uma conta de navegador somente leitura

Uma página https:// precisa usar wss://; uma URL ws:// é bloqueada como conteúdo misto. Veja MQTT sobre WebSockets para entender o transporte e erros de conteúdo misto se o navegador recusar a conexão.

Uma única conexão: o provider

Cada componente que cria o próprio cliente abre o próprio WebSocket. Crie o cliente uma vez, no topo da árvore, e distribua uma função subscribe estável:

src/mqtt/MqttProvider.tsx
import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import mqtt, { type IClientOptions, type MqttClient } from 'mqtt';

type Handler = (topic: string, payload: Uint8Array) => void;
type Status = 'connecting' | 'connected' | 'reconnecting' | 'offline' | 'error';

interface MqttApi {
  status: Status;
  error: string | null;
  subscribe: (filter: string, handler: Handler) => () => void;
  publish: (topic: string, message: string) => Promise<void>;
}

const MqttContext = createContext<MqttApi | null>(null);

export function matches(filter: string, topic: string): boolean {
  const f = filter.split('/');
  const t = topic.split('/');
  for (let i = 0; i < f.length; i++) {
    if (f[i] === '#') return true;
    if (i >= t.length || (f[i] !== '+' && f[i] !== t[i])) return false;
  }
  return f.length === t.length;
}

export function MqttProvider({ url, options, children }: { url: string; options?: IClientOptions; children: ReactNode }) {
  const clientRef = useRef<MqttClient | null>(null);
  const handlers = useRef(new Map<string, Set<Handler>>());
  const optionsRef = useRef(options);
  const [status, setStatus] = useState<Status>('connecting');
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const client = mqtt.connect(url, { reconnectPeriod: 2000, connectTimeout: 10_000, ...optionsRef.current });
    clientRef.current = client;

    client.on('connect', () => { setStatus('connected'); setError(null); });
    client.on('reconnect', () => setStatus('reconnecting'));
    client.on('offline', () => setStatus('offline'));
    client.on('error', (err) => { setStatus('error'); setError(err.message); });
    client.on('message', (topic, payload) => {
      handlers.current.forEach((set, filter) => {
        if (matches(filter, topic)) set.forEach((h) => h(topic, payload));
      });
    });

    // Restaura as assinaturas que os componentes registraram antes desta conexão.
    for (const filter of handlers.current.keys()) client.subscribe(filter, { qos: 1 });

    return () => {
      client.end(true);
      clientRef.current = null;
    };
  }, [url]);

  const api = useMemo<MqttApi>(() => ({
    status,
    error,
    subscribe(filter, handler) {
      let set = handlers.current.get(filter);
      if (!set) {
        set = new Set();
        handlers.current.set(filter, set);
        clientRef.current?.subscribe(filter, { qos: 1 });
      }
      set.add(handler);
      return () => {
        set!.delete(handler);
        if (set!.size === 0) {
          handlers.current.delete(filter);
          clientRef.current?.unsubscribe(filter);
        }
      };
    },
    async publish(topic, message) {
      await clientRef.current?.publishAsync(topic, message, { qos: 1 });
    },
  }), [status, error]);

  return <MqttContext.Provider value={api}>{children}</MqttContext.Provider>;
}

export function useMqtt(): MqttApi {
  const ctx = useContext(MqttContext);
  if (!ctx) throw new Error('useMqtt must be used inside <MqttProvider>');
  return ctx;
}

Algumas decisões desse código merecem explicação:

  • O effect depende só de url. Um objeto inline options={{ username }} é um objeto novo a cada render; colocá-lo no array de dependências desconectaria e reconectaria sem parar. As opções são lidas uma vez a partir de um ref.
  • O cleanup chama client.end(true). Isso fecha o socket na hora quando o provider desmonta, e em desenvolvimento quando o StrictMode monta duas vezes de propósito. Você verá duas conexões no log do broker em dev; só uma fica aberta.
  • As assinaturas contam referências. Dois widgets que observam o mesmo topic compartilham uma assinatura MQTT, e desmontar um não cancela a do outro.
  • O MQTT.js reassina após reconectar por padrão (resubscribe: true), então o hook não precisa tratar reconexões.

O topic matcher é uma forma rápida de conferir seus filtros com wildcards contra topics reais antes de ligá-los aos componentes.

Atualizações em lote para dashboards

Uma frota de sensores pode enviar facilmente dezenas de mensagens por segundo. Chamar setState para cada uma faz o React renderizar dezenas de vezes por segundo. Em um dashboard você geralmente quer o último valor por topic, renderizado no máximo uma vez por frame de animação:

src/mqtt/useLatest.ts
import { useEffect, useState } from 'react';
import { useMqtt } from './MqttProvider';

export function useLatest<T>(filter: string): Record<string, T> {
  const { subscribe } = useMqtt();
  const [values, setValues] = useState<Record<string, T>>({});

  useEffect(() => {
    let pending: Record<string, T> = {};
    let frame = 0;

    const off = subscribe(filter, (topic, payload) => {
      try {
        pending[topic] = JSON.parse(new TextDecoder().decode(payload)) as T;
      } catch {
        return; // ignora payloads que não são JSON
      }
      if (!frame) {
        frame = requestAnimationFrame(() => {
          const batch = pending;
          pending = {};
          frame = 0;
          setValues((prev) => ({ ...prev, ...batch }));
        });
      }
    });

    return () => {
      off();
      cancelAnimationFrame(frame);
    };
  }, [filter, subscribe]);

  return values;
}

Repare que o lote é copiado para uma variável local antes de setValues. O React pode chamar a função updater depois, então ela não deve ler um buffer que ainda será modificado.

subscribe é recriada quando status muda (ela vive no objeto de API memoizado), o que roda este effect de novo na reconexão. Graças à contagem de referências isso é inofensivo, mas se você quiser uma referência totalmente estável, mova subscribe e publish para um segundo context que nunca muda.

Um pequeno dashboard

src/App.tsx
import { MqttProvider, useMqtt } from './mqtt/MqttProvider';
import { useLatest } from './mqtt/useLatest';

interface Reading { t: number; h?: number }

function StatusBadge() {
  const { status, error } = useMqtt();
  return <p>Broker: {status}{error ? ` (${error})` : ''}</p>;
}

function Sensors() {
  const readings = useLatest<Reading>('testmqtt/react/sensors/+');
  return (
    <table>
      <tbody>
        {Object.entries(readings).map(([topic, r]) => (
          <tr key={topic}>
            <td>{topic.split('/').pop()}</td>
            <td>{r.t.toFixed(1)} °C</td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}

function PingButton() {
  const { publish, status } = useMqtt();
  return (
    <button disabled={status !== 'connected'} onClick={() => publish('testmqtt/react/ping', String(Date.now()))}>
      Ping
    </button>
  );
}

export default function App() {
  return (
    <MqttProvider
      url="wss://broker.connectmq.com:8084/mqtt"
      options={{
        username: import.meta.env.VITE_MQTT_USER,
        password: import.meta.env.VITE_MQTT_PASS,
        clientId: `dashboard-${crypto.randomUUID().slice(0, 8)}`,
      }}
    >
      <StatusBadge />
      <Sensors />
      <PingButton />
    </MqttProvider>
  );
}

Com Next.js, coloque o provider em um arquivo marcado com 'use client' e renderize-o a partir de um layout; o MQTT nunca deve rodar durante o server rendering. O guia de MQTT com Next.js cobre essa configuração.

Testando o dashboard

  1. Rode a app (npm run dev) e confirme que o badge mostra connected. Se mostrar error, a mensagem costuma ser Connection refused: Not authorized (credenciais) ou uma falha de WebSocket (URL, porta ou path; o path precisa ser /mqtt).
  2. Abra o cliente MQTT online em outra aba, conecte ao mesmo broker e publique {"t": 21.5} em testmqtt/react/sensors/dev-1. Uma linha para dev-1 deve aparecer na hora.
  3. Publique em testmqtt/react/sensors/dev-2 e confira que as duas linhas se atualizam de forma independente.
  4. Assine testmqtt/react/ping no cliente online e clique em Ping: o timestamp deve chegar.
  5. Saia do dashboard (ou desmonte o provider) e confira no cliente online ou no log do broker que a conexão fecha. Uma conexão que continua aberta após desmontar indica um cleanup faltando.

Para simular um fluxo, rode um pequeno script Node que publica uma leitura por segundo:

simulate.mjs
import mqtt from 'mqtt';

const client = await mqtt.connectAsync('mqtts://broker.connectmq.com:8883', {
  username: process.env.MQTT_USER,
  password: process.env.MQTT_PASS,
});

setInterval(() => {
  const t = 20 + Math.random() * 5;
  client.publish('testmqtt/react/sensors/sim-1', JSON.stringify({ t }), { qos: 1 });
}, 1000);

Mantenha as simulações leves no endpoint de teste compartilhado. Para ver como o agrupamento se comporta com centenas de mensagens por segundo, aponte o simulador para o seu próprio broker privado.

Publique leituras de teste no dashboard

Solução de problemas

SintomaCausaCorreção
Status alterna entre connecting e reconnectingObjeto de opções nas dependências do effect, ou client ID duplicadoDependa só de url; gere um client ID único por aba
WebSocket connection to 'wss://...' failedPorta ou path errados, ou problema de TLSUse a porta 8084 e o path /mqtt; teste a URL no cliente online
Mensagens chegam mas a UI travasetState por mensagemAgrupe com requestAnimationFrame como em useLatest
Um widget para de atualizar quando outro desmontaUnsubscribe sem contagem de referênciasConte os handlers por filtro antes de chamar unsubscribe

Mais mensagens de erro e suas causas em erros do MQTT.js.

O padrão escala bem: uma conexão por aba, assinaturas nas mãos dos componentes que precisam delas e atualizações de estado limitadas ao que a tela consegue mostrar. Teste com mensagens reais vindas de um segundo cliente e o seu dashboard React vai se comportar igual com dez sensores ou com algumas centenas.

Perguntas frequentes

O React consegue se conectar direto a um broker MQTT?

Sim, via MQTT sobre WebSockets. Navegadores não abrem sockets TCP, então use uma URL wss:// como wss://broker.connectmq.com:8084/mqtt com o MQTT.js. As portas 1883 e 8883 só funcionam a partir do Node.js ou de apps nativos.

Por que minha app React conecta duas vezes ao MQTT?

Em desenvolvimento, o StrictMode monta, desmonta e remonta os componentes para revelar cleanups ausentes. Se o seu effect chama client.end() no cleanup, a primeira conexão fecha e só uma fica aberta. Em produção o effect roda uma vez.

Como evito que mensagens MQTT re-renderizem toda a minha app React?

Mantenha o cliente em um context que não muda, faça cada componente assinar só os topics de que precisa e agrupe as mensagens recebidas em uma única atualização de estado por frame, em vez de chamar setState para cada mensagem.