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
npm i mqttMQTT.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ètre | Valeur |
|---|---|
| URL | wss://broker.connectmq.com:8084/mqtt |
| Protocole | MQTT 3.1.1 ou 5 (protocolVersion: 5) |
| Auth | identifiant / 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 :
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 inlineoptions={{ 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 :
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
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
- Lancez l'app (
npm run dev) et vérifiez que le badge afficheconnected. S'il afficheerror, le message est généralementConnection refused: Not authorized(identifiants) ou un échec WebSocket (URL, port ou chemin ; le chemin doit être/mqtt). - Ouvrez le client MQTT en ligne dans un autre onglet, connectez-vous au même broker et publiez
{"t": 21.5}surtestmqtt/react/sensors/dev-1. Une lignedev-1doit apparaître immédiatement. - Publiez sur
testmqtt/react/sensors/dev-2et vérifiez que les deux lignes se mettent à jour indépendamment. - Abonnez-vous à
testmqtt/react/pingdans le client en ligne et cliquez sur Ping : le timestamp doit arriver. - 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 :
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ôme | Cause | Correction |
|---|---|---|
Le statut alterne entre connecting et reconnecting | Objet 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://...' failed | Mauvais port ou chemin, ou problème TLS | Utiliser le port 8084 et le chemin /mqtt ; tester l'URL dans le client en ligne |
| Les messages arrivent mais l'UI se fige | setState par message | Grouper avec requestAnimationFrame comme dans useLatest |
| Un widget ne se met plus à jour quand un autre est démonté | Unsubscribe sans comptage de références | Compter 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.


