To use MQTT in React, connect with MQTT.js over secure WebSockets (wss://), keep a single client in a context provider, and let components subscribe through a hook that cleans up after itself. The two mistakes that break most React MQTT apps are opening a new connection on every render and calling setState for every single message; both are easy to avoid once the structure is right.
This tutorial builds that structure step by step: a provider, a useMqtt hook, ref-counted subscriptions, a batched "latest value" hook for dashboards, and a way to test it all with a live broker.
Install MQTT.js
npm i mqttMQTT.js 5 ships its own TypeScript types and a browser build, so it works with Vite, webpack 5 and Next.js without manual Node polyfills. In the browser it speaks MQTT over WebSockets only — raw TCP is not available to web pages. Use the secure WebSocket listener:
| Setting | Value |
|---|---|
| URL | wss://broker.connectmq.com:8084/mqtt |
| Protocol | MQTT 3.1.1 or 5 (protocolVersion: 5) |
| Auth | username / password of a read-only browser account |
An https:// page must use wss://; a plain ws:// URL is blocked as mixed content. See MQTT over WebSockets for how the transport works and mixed content errors if the browser refuses the connection.
One connection: the provider
Every component that creates its own client opens its own WebSocket. Create the client once, at the top of the tree, and hand out a stable subscribe function:
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));
});
});
// Restore subscriptions that components registered before this connection.
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;
}A few decisions in this code are worth spelling out:
- The effect depends only on
url. An inlineoptions={{ username }}object is a new object on every render; putting it in the dependency array would disconnect and reconnect constantly. Options are read once from a ref. - Cleanup calls
client.end(true). That closes the socket immediately when the provider unmounts — and in development, when React StrictMode deliberately mounts twice. You will see two connections in the broker log in dev; only one stays open. - Subscriptions are ref-counted. Two widgets that watch the same topic share one MQTT subscription, and unmounting one of them does not unsubscribe the other.
- MQTT.js re-subscribes after reconnects by default (
resubscribe: true), so the hook does not need to handle reconnects itself.
The topic matcher is a quick way to check your wildcard filters against real topics before you wire them into components.
Batching updates for dashboards
A sensor fleet can easily send dozens of messages per second. Calling setState for each one makes React render dozens of times per second. For dashboards you usually want the latest value per topic, rendered at most once per animation frame:
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; // ignore non-JSON payloads
}
if (!frame) {
frame = requestAnimationFrame(() => {
const batch = pending;
pending = {};
frame = 0;
setValues((prev) => ({ ...prev, ...batch }));
});
}
});
return () => {
off();
cancelAnimationFrame(frame);
};
}, [filter, subscribe]);
return values;
}Note that the batch is copied into a local variable before setValues. React may call the updater function later, so it must not read a buffer that is mutated afterwards.
subscribe is recreated when status changes (it lives in the memoized API object), which re-runs this effect on reconnect. That is harmless thanks to ref counting, but if you want a perfectly stable reference, move subscribe and publish into a second context that never changes.
A small 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>
);
}With Next.js, put the provider in a file marked 'use client' and render it from a layout; MQTT must never run during server rendering. The Next.js MQTT guide covers that setup.
Testing the dashboard
- Run the app (
npm run dev) and confirm the badge saysconnected. If it sayserror, the message is usuallyConnection refused: Not authorized(credentials) or a WebSocket failure (URL, port or path — the path must be/mqtt). - Open the online MQTT client in another tab, connect to the same broker and publish
{"t": 21.5}totestmqtt/react/sensors/dev-1. A row fordev-1should appear instantly. - Publish to
testmqtt/react/sensors/dev-2and check that both rows update independently. - Subscribe to
testmqtt/react/pingin the online client and click Ping — the timestamp should arrive. - Navigate away from the dashboard (or unmount the provider) and check in the online client or broker log that the connection closes. A connection that stays open after unmount is a missing cleanup.
To simulate a stream, run a small Node script that publishes a reading every second:
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);Keep simulations gentle on the shared test endpoint. To check how the batching behaves under hundreds of messages per second, point the simulator at your own private broker instead.
Publish test readings to the dashboard
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
Status flips between connecting and reconnecting | An options object in the effect dependencies, or a duplicate client ID | Depend on url only; generate a unique client ID per tab |
WebSocket connection to 'wss://...' failed | Wrong port or path, or TLS problem | Use port 8084 and path /mqtt; test the URL in the online client |
| Messages arrive but the UI freezes | setState per message | Batch with requestAnimationFrame as in useLatest |
| Widget stops updating after another one unmounts | Unsubscribe without ref counting | Count handlers per filter before calling unsubscribe |
More error strings and their causes are collected in MQTT.js errors.
The pattern scales well: one connection per tab, subscriptions owned by the components that need them, and state updates throttled to what the screen can show. Test it with real messages from a second client, and your React dashboard will behave the same with ten sensors as with a few hundred.
Frequently asked questions
Can React connect to an MQTT broker directly?
Yes, over MQTT on WebSockets. Browsers cannot open raw TCP sockets, so use a wss:// URL such as wss://broker.connectmq.com:8084/mqtt with MQTT.js. Ports 1883 and 8883 only work from Node.js or native apps.
Why does my React app connect to MQTT twice?
In development, React StrictMode mounts, unmounts and remounts components to surface missing cleanup. If your effect calls client.end() in its cleanup, the first connection closes and only one stays open. In production the effect runs once.
How do I stop MQTT messages from re-rendering my whole React app?
Keep the client in a context that never changes, let each component subscribe only to the topics it needs, and batch incoming messages into one state update per animation frame instead of calling setState for every message.


