Tutoriels

MQTT avec React : dashboards temps réel avec des hooks

Utilisez MQTT dans React avec MQTT.js 5 sur WebSockets sécurisés : un provider, un hook useMqtt, des abonnements comptés, cleanup et updates groupés.

Mis à jour · 9 min de lecture

MQTT avec React : dashboards temps réel avec des hooks

Pour utiliser MQTT dans React, connectez-vous avec MQTT.js via des WebSockets sécurisés (wss://), gardez un seul client dans un context provider et laissez les composants s'abonner via un hook qui fait son propre ménage. Les deux erreurs qui cassent la plupart des apps React MQTT sont d'ouvrir une nouvelle connexion à chaque render et d'appeler setState pour chaque message ; les deux s'évitent facilement avec la bonne structure.

Ce tutoriel construit cette structure pas à pas : un provider, un hook useMqtt, des abonnements avec comptage de références, un hook « dernière valeur » groupé pour les dashboards, et une façon de tout tester avec un vrai broker.

Installer MQTT.js

Installation
npm i mqtt

MQTT.js 5 embarque ses propres types TypeScript et un build navigateur : il fonctionne avec Vite, webpack 5 et Next.js sans polyfills Node manuels. Dans le navigateur, il ne parle que MQTT sur WebSockets : les pages web n'ont pas accès au TCP brut. Utilisez le listener WebSocket sécurisé :

ParamètreValeur
URLwss://broker.connectmq.com:8084/mqtt
ProtocoleMQTT 3.1.1 ou 5 (protocolVersion: 5)
Authidentifiant / mot de passe d'un compte navigateur en lecture seule

Une page https:// doit utiliser wss:// ; une URL ws:// est bloquée comme contenu mixte. Voir MQTT sur WebSockets pour le fonctionnement du transport et les erreurs de contenu mixte si le navigateur refuse la connexion.

Une seule connexion : le provider

Chaque composant qui crée son propre client ouvre son propre WebSocket. Créez le client une fois, en haut de l'arbre, et distribuez une fonction subscribe stable :

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

    // Restaure les abonnements enregistrés par les composants avant cette connexion.
    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;
}

Quelques choix de ce code méritent d'être expliqués :

  • L'effect ne dépend que de url. Un objet inline options={{ username }} est un nouvel objet à chaque render ; le mettre dans le tableau de dépendances provoquerait des déconnexions/reconnexions en continu. Les options sont lues une fois depuis une ref.
  • Le cleanup appelle client.end(true). Le socket se ferme immédiatement quand le provider est démonté, et en développement quand StrictMode monte deux fois exprès. Vous verrez deux connexions dans le log du broker en dev ; une seule reste ouverte.
  • Les abonnements comptent les références. Deux widgets qui observent le même topic partagent un abonnement MQTT, et démonter l'un ne désabonne pas l'autre.
  • MQTT.js se réabonne après une reconnexion par défaut (resubscribe: true) : le hook n'a pas à gérer les reconnexions.

Le topic matcher permet de vérifier rapidement vos filtres avec wildcards sur de vrais topics avant de les brancher dans les composants.

Grouper les mises à jour pour les dashboards

Une flotte de capteurs peut facilement envoyer des dizaines de messages par seconde. Un setState par message fait rendre React des dizaines de fois par seconde. Pour un dashboard, on veut généralement la dernière valeur par topic, rendue au plus une fois par frame d'animation :

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 les payloads non JSON
      }
      if (!frame) {
        frame = requestAnimationFrame(() => {
          const batch = pending;
          pending = {};
          frame = 0;
          setValues((prev) => ({ ...prev, ...batch }));
        });
      }
    });

    return () => {
      off();
      cancelAnimationFrame(frame);
    };
  }, [filter, subscribe]);

  return values;
}

Notez que le lot est copié dans une variable locale avant setValues. React peut appeler la fonction updater plus tard ; elle ne doit donc pas lire un buffer modifié ensuite.

subscribe est recréée quand status change (elle vit dans l'objet API mémoïsé), ce qui relance cet effect à la reconnexion. Grâce au comptage de références, c'est sans conséquence, mais si vous voulez une référence parfaitement stable, déplacez subscribe et publish dans un second context qui ne change jamais.

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

Avec Next.js, placez le provider dans un fichier marqué 'use client' et rendez-le depuis un layout ; MQTT ne doit jamais s'exécuter pendant le rendu serveur. Le guide MQTT avec Next.js couvre cette configuration.

Tester le dashboard

  1. Lancez l'app (npm run dev) et vérifiez que le badge affiche connected. S'il affiche error, le message est généralement Connection refused: Not authorized (identifiants) ou un échec WebSocket (URL, port ou chemin ; le chemin doit être /mqtt).
  2. Ouvrez le client MQTT en ligne dans un autre onglet, connectez-vous au même broker et publiez {"t": 21.5} sur testmqtt/react/sensors/dev-1. Une ligne dev-1 doit apparaître immédiatement.
  3. Publiez sur testmqtt/react/sensors/dev-2 et vérifiez que les deux lignes se mettent à jour indépendamment.
  4. Abonnez-vous à testmqtt/react/ping dans le client en ligne et cliquez sur Ping : le timestamp doit arriver.
  5. Quittez le dashboard (ou démontez le provider) et vérifiez dans le client en ligne ou le log du broker que la connexion se ferme. Une connexion qui reste ouverte après le démontage signale un cleanup manquant.

Pour simuler un flux, lancez un petit script Node qui publie une mesure par seconde :

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

Restez modéré avec les simulations sur l'endpoint de test partagé. Pour voir comment le groupage se comporte avec des centaines de messages par seconde, pointez le simulateur vers votre propre broker privé.

Publier des mesures de test vers le dashboard

Dépannage

SymptômeCauseCorrection
Le statut alterne entre connecting et reconnectingObjet d'options dans les dépendances de l'effect, ou client ID dupliquéNe dépendre que de url ; générer un client ID unique par onglet
WebSocket connection to 'wss://...' failedMauvais port ou chemin, ou problème TLSUtiliser le port 8084 et le chemin /mqtt ; tester l'URL dans le client en ligne
Les messages arrivent mais l'UI se figesetState par messageGrouper avec requestAnimationFrame comme dans useLatest
Un widget ne se met plus à jour quand un autre est démontéUnsubscribe sans comptage de référencesCompter les handlers par filtre avant d'appeler unsubscribe

D'autres messages d'erreur et leurs causes sont réunis dans les erreurs MQTT.js.

Ce modèle passe bien à l'échelle : une connexion par onglet, des abonnements portés par les composants qui en ont besoin et des mises à jour d'état limitées à ce que l'écran peut afficher. Testez-le avec de vrais messages envoyés depuis un second client, et votre dashboard React se comportera de la même façon avec dix capteurs qu'avec quelques centaines.

Questions fréquentes

React peut-il se connecter directement à un broker MQTT ?

Oui, via MQTT sur WebSockets. Les navigateurs ne peuvent pas ouvrir de sockets TCP : utilisez une URL wss:// comme wss://broker.connectmq.com:8084/mqtt avec MQTT.js. Les ports 1883 et 8883 ne fonctionnent que depuis Node.js ou des apps natives.

Pourquoi mon app React se connecte-t-elle deux fois à MQTT ?

En développement, StrictMode monte, démonte puis remonte les composants pour révéler les cleanups manquants. Si votre effect appelle client.end() dans son cleanup, la première connexion se ferme et une seule reste ouverte. En production, l'effect s'exécute une fois.

Comment éviter que les messages MQTT re-rendent toute mon app React ?

Gardez le client dans un context qui ne change pas, laissez chaque composant s'abonner uniquement aux topics dont il a besoin et regroupez les messages entrants en une seule mise à jour d'état par frame au lieu d'appeler setState à chaque message.