Combining React and MQTT gives you dashboards that update the instant a device publishes, with no polling and no custom WebSocket server. In this tutorial you will build a reusable useMqtt hook around MQTT.js, connect it to a broker over secure WebSocket, and render live sensor cards that stay fast even when messages arrive several times per second. Everything runs against the free public broker, so you can have a working dashboard in under an hour.
How React Talks to an MQTT Broker
Browsers cannot open raw TCP sockets, so a React app cannot use port 1883 or 8883. Instead, MQTT.js connects over WebSocket, and the broker speaks MQTT inside that WebSocket. ConnectMQ brokers expose secure WebSocket on port 8084 with the path /mqtt, so the URL is:
wss://broker.connectmq.com:8084/mqtt
Always use wss:// in apps served over HTTPS; browsers block plain ws:// from secure pages as mixed content. The MQTT over WebSocket guide explains the transport in more depth.
Project Setup
Create a Vite React project and install MQTT.js:
npm create vite@latest react-mqtt-dashboard -- --template react
cd react-mqtt-dashboard
npm install
npm install mqtt
npm run dev
MQTT.js v5 ships a browser build that bundlers like Vite pick up automatically, so no Node.js polyfills are needed.
Designing the useMqtt Hook
A good hook has a small, predictable contract. Ours takes a URL, connection options and a list of topic filters, and returns the connection status, a map of the latest message per topic and a publish function. Design rules:
| Concern | Decision | Reason |
|---|---|---|
| Connection lifetime | Created in useEffect, ended in its cleanup |
No leaked sockets on unmount or hot reload |
| StrictMode double mount | Cleanup ends the first client completely | Development mounts effects twice |
| State shape | Latest message per topic, not a growing array | Memory stays flat, renders stay cheap |
| Render rate | Batch updates every 250 ms | Fast publishers do not freeze the UI |
| Options identity | Serialize options into a dependency key | Inline objects would reconnect on every render |
The useMqtt Hook
Create src/useMqtt.js:
import { useCallback, useEffect, useRef, useState } from "react";
import mqtt from "mqtt";
export function useMqtt(url, options, topics) {
const [status, setStatus] = useState("connecting");
const [messages, setMessages] = useState({});
const clientRef = useRef(null);
const pendingRef = useRef({});
// Stable keys so inline objects/arrays do not trigger reconnects
const optionsKey = JSON.stringify(options);
const topicsKey = JSON.stringify(topics);
useEffect(() => {
const opts = JSON.parse(optionsKey);
const filters = JSON.parse(topicsKey);
const client = mqtt.connect(url, {
protocolVersion: 5,
reconnectPeriod: 3000,
connectTimeout: 10000,
...opts,
});
clientRef.current = client;
client.on("connect", () => {
setStatus("connected");
client.subscribe(filters, { qos: 0 });
});
client.on("reconnect", () => setStatus("reconnecting"));
client.on("offline", () => setStatus("offline"));
client.on("error", (err) => {
console.error("MQTT error", err);
setStatus("error");
});
client.on("message", (topic, payload) => {
let value;
try {
value = JSON.parse(payload.toString());
} catch {
value = payload.toString();
}
pendingRef.current[topic] = { value, receivedAt: Date.now() };
});
// Flush buffered messages into React state at most 4 times per second
const timer = setInterval(() => {
const pending = pendingRef.current;
if (Object.keys(pending).length === 0) return;
pendingRef.current = {};
setMessages((prev) => ({ ...prev, ...pending }));
}, 250);
return () => {
clearInterval(timer);
client.end(true);
clientRef.current = null;
};
}, [url, optionsKey, topicsKey]);
const publish = useCallback((topic, message, publishOptions = { qos: 0 }) => {
const client = clientRef.current;
if (!client || !client.connected) return false;
const body = typeof message === "string" ? message : JSON.stringify(message);
client.publish(topic, body, publishOptions);
return true;
}, []);
return { status, messages, publish };
}
Why it is built this way:
- Messages land in a ref first. Calling
setMessagesfor every message re-renders the tree each time. Collecting them inpendingRefand flushing on a 250 ms timer keeps the UI smooth even if a device publishes 20 times per second. client.end(true)in cleanup closes the socket immediately. In development, React StrictMode runs the effect, the cleanup, and the effect again, so the first client must be fully closed before the second connects with the same client ID. Forcing the end avoids a brief window where two sessions with one ID fight each other.- Subscribing in the
connecthandler restores subscriptions after every reconnect.
Building the Dashboard
Replace src/App.jsx with this first version, which contains one bug we will fix in a moment:
import { useMemo } from "react";
import { useMqtt } from "./useMqtt";
const BROKER_URL = "wss://broker.connectmq.com:8084/mqtt";
const PREFIX = "connectmq/demo/react-58d2";
function SensorCard({ device, reading }) {
const ageSeconds = Math.round((Date.now() - reading.receivedAt) / 1000);
const { temperature, humidity } = reading.value ?? {};
return (
<div className="card">
<h3>{device}</h3>
<p className="big">{temperature ?? "--"} °C</p>
<p>Humidity: {humidity ?? "--"} %</p>
<small>{ageSeconds > 30 ? `stale (${ageSeconds}s)` : "live"}</small>
</div>
);
}
export default function App() {
const { status, messages, publish } = useMqtt(
BROKER_URL,
{ clientId: `react-${Math.random().toString(16).slice(2, 10)}` },
[`${PREFIX}/+/telemetry`]
);
const devices = useMemo(
() =>
Object.entries(messages)
.map(([topic, reading]) => ({ device: topic.split("/").at(-2), reading }))
.sort((a, b) => a.device.localeCompare(b.device)),
[messages]
);
return (
<main>
<header>
<h1>Live Sensors</h1>
<span className={`status ${status}`}>{status}</span>
</header>
<section className="grid">
{devices.map(({ device, reading }) => (
<SensorCard key={device} device={device} reading={reading} />
))}
</section>
<button onClick={() => publish(`${PREFIX}/fan1/cmd`, { action: "toggle" }, { qos: 1 })}>
Toggle fan1
</button>
</main>
);
}
There is a deliberate bug in this file: the clientId is generated inline, so its value changes on every render. Serializing options protects against new object identities, but not against new values, so every render produces a new options key and a brand-new connection. Fix it by generating the ID once:
import { useMemo, useState } from "react";
// inside App()
const [clientId] = useState(() => `react-${Math.random().toString(16).slice(2, 10)}`);
const { status, messages, publish } = useMqtt(BROKER_URL, { clientId }, [`${PREFIX}/+/telemetry`]);
This is the single most common bug in React MQTT code: options that change on each render, causing a connect and disconnect loop that looks like a broker problem.
Add some minimal CSS to src/index.css for the .grid and .card classes, or use your component library of choice.
Feed It Test Data
Publish readings from a terminal to the public MQTT test broker:
for i in $(seq 1 30); do
mosquitto_pub -h broker.connectmq.com -p 1883 -i react-pub-58d2 \
-t connectmq/demo/react-58d2/room1/telemetry \
-m "{\"temperature\": $((20 + RANDOM % 5)), \"humidity\": $((40 + RANDOM % 10))}"
sleep 1
done
Cards appear as soon as the first message for each device arrives. Click the button and watch the command with mosquitto_sub -h broker.connectmq.com -p 1883 -i react-sub-58d2 -v -t "connectmq/demo/react-58d2/+/cmd". Use your own random prefix: the public area is readable by everyone.
Sharing One Connection Across Components
The hook as written opens one connection per component that calls it. That is perfect for a single page, but a larger dashboard with a header status badge, a device list and a detail panel would open three connections to receive overlapping data. Lift the connection into a context provider instead:
import { createContext, useContext, useState } from "react";
import { useMqtt } from "./useMqtt";
const MqttContext = createContext(null);
export function MqttProvider({ url, topics, children }) {
const [clientId] = useState(() => `react-${Math.random().toString(16).slice(2, 10)}`);
const mqttState = useMqtt(url, { clientId }, topics);
return <MqttContext.Provider value={mqttState}>{children}</MqttContext.Provider>;
}
export function useMqttContext() {
const value = useContext(MqttContext);
if (!value) throw new Error("useMqttContext must be used inside MqttProvider");
return value;
}
Wrap your app in <MqttProvider url={BROKER_URL} topics={[...]}> and read status, messages and publish anywhere below it. Every component re-renders when messages changes, which is acceptable at a 250 ms flush interval. If you have hundreds of widgets, keep messages in an external store such as Zustand or use useSyncExternalStore with selectors, so each widget re-renders only when its own topic changes.
Production Considerations for React MQTT Apps
- Credentials in the browser are public. Anything in your JavaScript bundle can be read by users. Issue a dedicated credential for the dashboard, and if users must not publish commands, route commands through a backend API instead of the browser.
- Show connection state. Users should see
reconnectingrather than silently stale numbers. The hook exposesstatusfor this, and each card shows its data age. - Use retained messages for initial state. If devices publish their latest state with the retain flag, a freshly loaded dashboard fills instantly instead of waiting for the next report.
- Mind connection limits. Each open browser tab is one MQTT connection. Plan for it when sizing your broker.
- Throttle charts. If you add charts, append points on the flush timer rather than per message, and cap the history length.
- Pause in background tabs. Browsers throttle timers in hidden tabs. If a dashboard sits in the background for hours, consider ending the connection on
visibilitychangeand reconnecting when the tab becomes visible again, letting retained messages refill the view.
Moving to Your Own Broker
For a real dashboard, create a private broker on ConnectMQ and pass the generated credentials to the hook:
const { status, messages } = useMqtt(
"wss://YOUR_BROKER_HOST:8084/mqtt",
{ clientId, username: import.meta.env.VITE_MQTT_USERNAME, password: import.meta.env.VITE_MQTT_PASSWORD },
["factory1/+/telemetry"]
);
Each tab needs a unique client ID; on a broker where client IDs are tied to credentials, make sure the dashboard credential allows that, or put a small backend between the browser and the broker. A cloud MQTT broker with a Let's Encrypt certificate on the WebSocket port means wss:// works in every browser without certificate warnings.
Related Tutorials
The same hook pattern translates to other frameworks: see the Vue MQTT composable and the Angular MQTT service with RxJS. For server-side code, start with MQTT.js in Node.js, and for a broader comparison of libraries, the MQTT client libraries guide.
Get Started with ConnectMQ
Give your React dashboard a private broker with secure WebSocket in a few minutes. Sign up at app.connectmq.com, choose the free Serverless plan with no credit card, create a broker and plug the credentials into useMqtt. The portal's in-browser MQTT client is perfect for publishing test values while you design your components.
Frequently Asked Questions
Can React connect to MQTT on port 1883?
No. Browsers cannot open TCP sockets, so React must use MQTT over WebSocket. On ConnectMQ that is wss:// on port 8084 with the path /mqtt.
Why does my React app connect twice in development?
React StrictMode mounts effects twice in development to surface cleanup bugs. With a proper cleanup that calls client.end(), the first connection closes immediately; production builds connect once.
Should I put the MQTT client in React context?
If many components need the same connection, yes. Create the client in a provider using the same effect logic, and expose status, messages and publish through context so you still have one connection per tab.
Is there an official React MQTT library?
There is no official one. Small community wrappers exist, but a hook around MQTT.js like the one in this article is short, fully under your control and easy to adapt.



