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
npm i mqttMQTT.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:
| Einstellung | Wert |
|---|---|
| URL | wss://broker.connectmq.com:8084/mqtt |
| Protokoll | MQTT 3.1.1 oder 5 (protocolVersion: 5) |
| Auth | Benutzername / 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:
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
urlab. Ein Inline-Objektoptions={{ 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: trueist 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:
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
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
- Starte die App (
npm run dev) und prüfe, dass das Badgeconnectedzeigt. Steht dorterror, lautet die Meldung meistConnection refused: Not authorized(Zugangsdaten) oder es ist ein WebSocket-Fehler (URL, Port oder Pfad – der Pfad muss/mqttsein). - Öffne den Online-MQTT-Client in einem anderen Tab, verbinde dich mit demselben Broker und publiziere
{"t": 21.5}auftestmqtt/react/sensors/dev-1. Sofort sollte eine Zeile fürdev-1erscheinen. - Publiziere auf
testmqtt/react/sensors/dev-2und prüfe, dass beide Zeilen unabhängig aktualisiert werden. - Abonniere im Online-Client
testmqtt/react/pingund klicke auf Ping – der Zeitstempel sollte ankommen. - 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:
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
| Symptom | Ursache | Lösung |
|---|---|---|
Status springt zwischen connecting und reconnecting | Optionsobjekt in den Effect-Dependencies oder doppelte Client-ID | Nur von url abhängen; pro Tab eine eindeutige Client-ID erzeugen |
WebSocket connection to 'wss://...' failed | Falscher Port oder Pfad, oder TLS-Problem | Port 8084 und Pfad /mqtt nutzen; URL im Online-Client testen |
| Nachrichten kommen an, aber die UI friert ein | setState pro Nachricht | Mit requestAnimationFrame bündeln wie in useLatest |
| Widget aktualisiert nicht mehr, wenn ein anderes unmountet | Unsubscribe ohne Referenzzählung | Handler 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.


