Tutorials

MQTT in React: Real-Time Dashboards with Hooks

Use MQTT in React with MQTT.js 5 over secure WebSockets: a provider, a useMqtt hook, ref-counted subscriptions, proper cleanup and batched state updates.

Updated · 9 min read

MQTT in React: Real-Time Dashboards with Hooks

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

Install
npm i mqtt

MQTT.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:

SettingValue
URLwss://broker.connectmq.com:8084/mqtt
ProtocolMQTT 3.1.1 or 5 (protocolVersion: 5)
Authusername / 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:

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

    // 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 inline options={{ 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:

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

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

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

  1. Run the app (npm run dev) and confirm the badge says connected. If it says error, the message is usually Connection refused: Not authorized (credentials) or a WebSocket failure (URL, port or path — the path must be /mqtt).
  2. Open the online MQTT client in another tab, connect to the same broker and publish {"t": 21.5} to testmqtt/react/sensors/dev-1. A row for dev-1 should appear instantly.
  3. Publish to testmqtt/react/sensors/dev-2 and check that both rows update independently.
  4. Subscribe to testmqtt/react/ping in the online client and click Ping — the timestamp should arrive.
  5. 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:

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

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

SymptomCauseFix
Status flips between connecting and reconnectingAn options object in the effect dependencies, or a duplicate client IDDepend on url only; generate a unique client ID per tab
WebSocket connection to 'wss://...' failedWrong port or path, or TLS problemUse port 8084 and path /mqtt; test the URL in the online client
Messages arrive but the UI freezessetState per messageBatch with requestAnimationFrame as in useLatest
Widget stops updating after another one unmountsUnsubscribe without ref countingCount 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.