IoT Tutorials

React MQTT Tutorial: A Hooks-Based Real-Time Dashboard

Create a reusable useMqtt hook around MQTT.js, connect React to a broker over secure WebSocket, and render live sensor values without re-render storms, StrictMode surprises or leaked connections.

Browser dashboard with live sensor cards built with react mqtt over a secure WebSocket connection
Run this on your own MQTT brokerGet a private, managed MQTT broker on the Free plan and start from ready-made code samples.
Try it free Code samples No card required

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 setMessages for every message re-renders the tree each time. Collecting them in pendingRef and 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 connect handler 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 ?? "--"} &deg;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

  1. 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.
  2. Show connection state. Users should see reconnecting rather than silently stale numbers. The hook exposes status for this, and each card shows its data age.
  3. 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.
  4. Mind connection limits. Each open browser tab is one MQTT connection. Plan for it when sizing your broker.
  5. Throttle charts. If you add charts, append points on the flush timer rather than per message, and cap the history length.
  6. Pause in background tabs. Browsers throttle timers in hidden tabs. If a dashboard sits in the background for hours, consider ending the connection on visibilitychange and 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.

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.

Related articles

Best MQTT CLI Tools for Testing, Scripting and Debugging

A hands-on comparison of mosquitto_pub/sub, MQTTX CLI, HiveMQ MQTT CLI, the MQTT.js CLI, curl and mqttui, with the same test run against broker.connectmq.com so you can choose the right tool for scripting and debugging.

8 min read

All articles →

Your private MQTT broker is a few clicks away

Create an account, get your broker credentials and connect your first device — no servers to install, patch or scale.