Tutoriales

MQTT en React: dashboards en tiempo real con hooks

Usa MQTT en React con MQTT.js 5 sobre WebSockets seguros: un provider, un hook useMqtt, suscripciones con conteo de referencias, cleanup y updates agrupados.

Actualizado · 9 min de lectura

MQTT en React: dashboards en tiempo real con hooks

Para usar MQTT en React, conéctate con MQTT.js por WebSockets seguros (wss://), mantén un único cliente en un context provider y deja que los componentes se suscriban mediante un hook que limpia lo que crea. Los dos errores que rompen la mayoría de apps React con MQTT son abrir una conexión nueva en cada render y llamar a setState por cada mensaje; ambos son fáciles de evitar con la estructura correcta.

Este tutorial construye esa estructura paso a paso: un provider, un hook useMqtt, suscripciones con conteo de referencias, un hook de "último valor" agrupado para dashboards y una forma de probarlo todo con un broker real.

Instalar MQTT.js

Instalación
npm i mqtt

MQTT.js 5 incluye sus propios tipos de TypeScript y un build para navegador, así que funciona con Vite, webpack 5 y Next.js sin polyfills manuales de Node. En el navegador solo habla MQTT sobre WebSockets: las páginas web no tienen acceso a TCP. Usa el listener de WebSocket seguro:

AjusteValor
URLwss://broker.connectmq.com:8084/mqtt
ProtocoloMQTT 3.1.1 o 5 (protocolVersion: 5)
Authusuario / contraseña de una cuenta de navegador de solo lectura

Una página https:// debe usar wss://; una URL ws:// se bloquea como contenido mixto. Consulta MQTT sobre WebSockets para entender el transporte y errores de contenido mixto si el navegador rechaza la conexión.

Una sola conexión: el provider

Cada componente que crea su propio cliente abre su propio WebSocket. Crea el cliente una vez, en la raíz del árbol, y reparte una función subscribe estable:

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 las suscripciones que los componentes registraron antes de esta conexión.
    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;
}

Vale la pena explicar algunas decisiones de este código:

  • El effect solo depende de url. Un objeto inline options={{ username }} es un objeto nuevo en cada render; meterlo en el array de dependencias desconectaría y reconectaría sin parar. Las opciones se leen una vez desde un ref.
  • El cleanup llama a client.end(true). Cierra el socket inmediatamente cuando el provider se desmonta, y en desarrollo cuando StrictMode monta dos veces a propósito. Verás dos conexiones en el log del broker en dev; solo una queda abierta.
  • Las suscripciones cuentan referencias. Dos widgets que observan el mismo topic comparten una suscripción MQTT, y desmontar uno no cancela la del otro.
  • MQTT.js vuelve a suscribirse tras reconectar por defecto (resubscribe: true), así que el hook no tiene que gestionar las reconexiones.

El topic matcher es una forma rápida de comprobar tus filtros con wildcards contra topics reales antes de conectarlos a los componentes.

Agrupar updates para dashboards

Una flota de sensores puede enviar fácilmente decenas de mensajes por segundo. Llamar a setState por cada uno hace que React renderice decenas de veces por segundo. En un dashboard normalmente quieres el último valor por topic, renderizado como mucho una vez por frame de animación:

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 no son JSON
      }
      if (!frame) {
        frame = requestAnimationFrame(() => {
          const batch = pending;
          pending = {};
          frame = 0;
          setValues((prev) => ({ ...prev, ...batch }));
        });
      }
    });

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

  return values;
}

Fíjate en que el lote se copia a una variable local antes de setValues. React puede llamar a la función updater más tarde, así que no debe leer un buffer que se modifica después.

subscribe se recrea cuando cambia status (vive en el objeto API memoizado), lo que vuelve a ejecutar este effect al reconectar. Gracias al conteo de referencias es inofensivo, pero si quieres una referencia totalmente estable, mueve subscribe y publish a un segundo context que nunca cambie.

Un dashboard pequeño

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>
  );
}

Con Next.js, pon el provider en un archivo marcado con 'use client' y renderízalo desde un layout; MQTT nunca debe ejecutarse durante el server rendering. La guía de MQTT con Next.js cubre esa configuración.

Probar el dashboard

  1. Arranca la app (npm run dev) y confirma que el badge dice connected. Si dice error, el mensaje suele ser Connection refused: Not authorized (credenciales) o un fallo de WebSocket (URL, puerto o ruta; la ruta debe ser /mqtt).
  2. Abre el cliente MQTT online en otra pestaña, conéctate al mismo broker y publica {"t": 21.5} en testmqtt/react/sensors/dev-1. Debería aparecer al instante una fila para dev-1.
  3. Publica en testmqtt/react/sensors/dev-2 y comprueba que ambas filas se actualizan de forma independiente.
  4. Suscríbete a testmqtt/react/ping en el cliente online y pulsa Ping: debería llegar el timestamp.
  5. Sal del dashboard (o desmonta el provider) y comprueba en el cliente online o en el log del broker que la conexión se cierra. Una conexión que sigue abierta tras desmontar indica que falta un cleanup.

Para simular un flujo de datos, ejecuta un pequeño script de Node que publique una lectura 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);

Mantén las simulaciones suaves en el endpoint de pruebas compartido. Para ver cómo se comporta el agrupado con cientos de mensajes por segundo, apunta el simulador a tu propio broker privado.

Publica lecturas de prueba en el dashboard

Solución de problemas

SíntomaCausaSolución
El estado alterna entre connecting y reconnectingUn objeto de opciones en las dependencias del effect, o client ID duplicadoDepende solo de url; genera un client ID único por pestaña
WebSocket connection to 'wss://...' failedPuerto o ruta incorrectos, o problema de TLSUsa el puerto 8084 y la ruta /mqtt; prueba la URL en el cliente online
Llegan mensajes pero la UI se congelasetState por mensajeAgrupa con requestAnimationFrame como en useLatest
Un widget deja de actualizarse cuando otro se desmontaUnsubscribe sin conteo de referenciasCuenta los handlers por filtro antes de llamar a unsubscribe

Más mensajes de error y sus causas en errores de MQTT.js.

El patrón escala bien: una conexión por pestaña, suscripciones en manos de los componentes que las necesitan y actualizaciones de estado limitadas a lo que la pantalla puede mostrar. Pruébalo con mensajes reales desde un segundo cliente y tu dashboard React se comportará igual con diez sensores que con unos cientos.

Preguntas frecuentes

¿Puede React conectarse directamente a un broker MQTT?

Sí, mediante MQTT sobre WebSockets. Los navegadores no pueden abrir sockets TCP, así que usa una URL wss:// como wss://broker.connectmq.com:8084/mqtt con MQTT.js. Los puertos 1883 y 8883 solo funcionan desde Node.js o apps nativas.

¿Por qué mi app React se conecta dos veces a MQTT?

En desarrollo, StrictMode monta, desmonta y vuelve a montar los componentes para detectar cleanups que faltan. Si tu effect llama a client.end() en el cleanup, la primera conexión se cierra y solo queda una abierta. En producción el effect se ejecuta una vez.

¿Cómo evito que los mensajes MQTT re-rendericen toda mi app React?

Guarda el cliente en un context que no cambie, haz que cada componente se suscriba solo a los topics que necesita y agrupa los mensajes entrantes en una sola actualización de estado por frame en lugar de llamar a setState por cada mensaje.