Tutorials

MQTT mit React: Echtzeit-Dashboards mit Hooks

MQTT in React mit MQTT.js 5 über sichere WebSockets: ein Provider, ein useMqtt-Hook, referenzgezählte Subscriptions, sauberes Cleanup und gebündelte Updates.

Aktualisiert · 9 Min. Lesezeit

MQTT mit React: Echtzeit-Dashboards mit Hooks

Für MQTT in React verbindest du dich mit MQTT.js über sichere WebSockets (wss://), hältst einen einzigen Client in einem Context-Provider und lässt Komponenten über einen Hook abonnieren, der hinter sich aufräumt. Die zwei Fehler, an denen die meisten React-MQTT-Apps scheitern, sind eine neue Verbindung bei jedem Render und ein setState für jede einzelne Nachricht – beides lässt sich mit der richtigen Struktur leicht vermeiden.

Dieses Tutorial baut diese Struktur Schritt für Schritt: ein Provider, ein useMqtt-Hook, referenzgezählte Subscriptions, ein gebündelter „Latest Value“-Hook für Dashboards und ein Weg, alles mit einem echten Broker zu testen.

MQTT.js installieren

Installation
npm i mqtt

MQTT.js 5 bringt eigene TypeScript-Typen und einen Browser-Build mit und funktioniert daher mit Vite, webpack 5 und Next.js ohne manuelle Node-Polyfills. Im Browser spricht es nur MQTT über WebSockets – Webseiten haben keinen Zugriff auf rohes TCP. Nutze den sicheren WebSocket-Listener:

EinstellungWert
URLwss://broker.connectmq.com:8084/mqtt
ProtokollMQTT 3.1.1 oder 5 (protocolVersion: 5)
AuthBenutzername / Passwort eines schreibgeschützten Browser-Accounts

Eine https://-Seite muss wss:// verwenden; eine ws://-URL wird als Mixed Content blockiert. Wie der Transport funktioniert, steht unter MQTT über WebSockets; lehnt der Browser die Verbindung ab, hilft Mixed-Content-Fehler.

Eine Verbindung: der Provider

Jede Komponente, die ihren eigenen Client erzeugt, öffnet ihren eigenen WebSocket. Erzeuge den Client einmal oben im Baum und reiche eine stabile subscribe-Funktion weiter:

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

    // Subscriptions wiederherstellen, die Komponenten vor dieser Verbindung registriert haben.
    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;
}

Einige Entscheidungen in diesem Code verdienen eine Erklärung:

  • Der Effect hängt nur von url ab. Ein Inline-Objekt options={{ username }} ist bei jedem Render ein neues Objekt; im Dependency-Array würde es ständig trennen und neu verbinden. Die Optionen werden einmal aus einer Ref gelesen.
  • Das Cleanup ruft client.end(true) auf. Damit wird der Socket sofort geschlossen, wenn der Provider unmountet – und im Development, wenn StrictMode absichtlich doppelt mountet. Du siehst dann im Dev zwei Verbindungen im Broker-Log; nur eine bleibt offen.
  • Subscriptions sind referenzgezählt. Zwei Widgets, die dasselbe Topic beobachten, teilen sich eine MQTT-Subscription, und das Unmounten des einen beendet nicht die des anderen.
  • MQTT.js abonniert nach Reconnects automatisch neu (resubscribe: true ist Standard), der Hook muss sich also nicht selbst um Reconnects kümmern.

Mit dem Topic Matcher prüfst du deine Wildcard-Filter schnell gegen echte Topics, bevor du sie in Komponenten verdrahtest.

Updates für Dashboards bündeln

Eine Sensorflotte sendet schnell Dutzende Nachrichten pro Sekunde. Ein setState pro Nachricht lässt React Dutzende Male pro Sekunde rendern. Für Dashboards willst du meist den letzten Wert pro Topic, höchstens einmal pro Animation Frame gerendert:

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

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

  return values;
}

Beachte, dass der Batch vor setValues in eine lokale Variable kopiert wird. React kann die Updater-Funktion später aufrufen, sie darf also keinen Puffer lesen, der danach noch verändert wird.

subscribe wird neu erzeugt, wenn sich status ändert (es liegt im memoisierten API-Objekt), wodurch dieser Effect beim Reconnect erneut läuft. Dank Referenzzählung ist das harmlos; willst du eine völlig stabile Referenz, verschiebe subscribe und publish in einen zweiten Context, der sich nie ändert.

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

Mit Next.js legst du den Provider in eine Datei mit 'use client' und renderst ihn aus einem Layout; MQTT darf nie beim Server-Rendering laufen. Das Setup beschreibt der Next.js-MQTT-Leitfaden.

Das Dashboard testen

  1. Starte die App (npm run dev) und prüfe, dass das Badge connected zeigt. Steht dort error, lautet die Meldung meist Connection refused: Not authorized (Zugangsdaten) oder es ist ein WebSocket-Fehler (URL, Port oder Pfad – der Pfad muss /mqtt sein).
  2. Öffne den Online-MQTT-Client in einem anderen Tab, verbinde dich mit demselben Broker und publiziere {"t": 21.5} auf testmqtt/react/sensors/dev-1. Sofort sollte eine Zeile für dev-1 erscheinen.
  3. Publiziere auf testmqtt/react/sensors/dev-2 und prüfe, dass beide Zeilen unabhängig aktualisiert werden.
  4. Abonniere im Online-Client testmqtt/react/ping und klicke auf Ping – der Zeitstempel sollte ankommen.
  5. Verlasse das Dashboard (oder unmounte den Provider) und prüfe im Online-Client oder Broker-Log, dass die Verbindung geschlossen wird. Bleibt sie nach dem Unmount offen, fehlt ein Cleanup.

Um einen Datenstrom zu simulieren, starte ein kleines Node-Skript, das jede Sekunde einen Messwert publiziert:

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

Halte Simulationen auf dem geteilten Test-Endpunkt moderat. Um das Bündeln bei Hunderten Nachrichten pro Sekunde zu prüfen, richte den Simulator auf deinen eigenen privaten Broker.

Testwerte an das Dashboard publizieren

Fehlerbehebung

SymptomUrsacheLösung
Status springt zwischen connecting und reconnectingOptionsobjekt in den Effect-Dependencies oder doppelte Client-IDNur von url abhängen; pro Tab eine eindeutige Client-ID erzeugen
WebSocket connection to 'wss://...' failedFalscher Port oder Pfad, oder TLS-ProblemPort 8084 und Pfad /mqtt nutzen; URL im Online-Client testen
Nachrichten kommen an, aber die UI friert einsetState pro NachrichtMit requestAnimationFrame bündeln wie in useLatest
Widget aktualisiert nicht mehr, wenn ein anderes unmountetUnsubscribe ohne ReferenzzählungHandler pro Filter zählen, bevor unsubscribe aufgerufen wird

Weitere Fehlermeldungen und ihre Ursachen findest du unter MQTT.js-Fehler.

Das Muster skaliert gut: eine Verbindung pro Tab, Subscriptions bei den Komponenten, die sie brauchen, und State-Updates gedrosselt auf das, was der Bildschirm zeigen kann. Teste es mit echten Nachrichten aus einem zweiten Client, dann verhält sich dein React-Dashboard mit zehn Sensoren genauso wie mit einigen Hundert.

Häufig gestellte Fragen

Kann sich React direkt mit einem MQTT-Broker verbinden?

Ja, über MQTT auf WebSockets. Browser können keine TCP-Sockets öffnen, also nutzt du mit MQTT.js eine wss://-URL wie wss://broker.connectmq.com:8084/mqtt. Die Ports 1883 und 8883 funktionieren nur aus Node.js oder nativen Apps.

Warum verbindet sich meine React-App zweimal mit MQTT?

Im Development mountet, unmountet und remountet StrictMode Komponenten, um fehlendes Cleanup aufzudecken. Ruft dein Effect im Cleanup client.end() auf, schließt sich die erste Verbindung und nur eine bleibt offen. In Produktion läuft der Effect einmal.

Wie verhindere ich, dass MQTT-Nachrichten meine ganze React-App neu rendern?

Halte den Client in einem Context, der sich nie ändert, lass jede Komponente nur ihre Topics abonnieren und bündle eingehende Nachrichten zu einem State-Update pro Animation Frame, statt für jede Nachricht setState aufzurufen.