La forma limpia de usar MQTT en Vue 3 es un composable: un único cliente MQTT.js compartido sobre WebSockets seguros, una función useMqtt() que expone el estado de conexión reactivo y un método publish, y una función useTopic() que se suscribe mientras el componente está vivo y cancela la suscripción automáticamente cuando se destruye. Los mensajes van a estado shallowRef, para que Vue no cree proxies profundos de cada payload.
Este tutorial construye ese composable, lo usa en un componente <script setup> y muestra cómo probarlo contra un broker real.
Instalación y ajustes de conexión
npm i mqttMQTT.js 5 incluye tipos de TypeScript y un build para navegador que funciona con Vite sin configuración. Los navegadores solo hablan MQTT sobre WebSockets, así que la URL siempre es ws:// o wss://:
| Ajuste | Valor |
|---|---|
| URL | wss://broker.connectmq.com:8084/mqtt |
| Protocolo | MQTT 3.1.1 o 5 |
| Credenciales | una cuenta de navegador con acceso de lectura a los topics que muestra la UI |
Usa wss:// en cualquier página servida por HTTPS; ws:// se bloquea como contenido mixto. MQTT sobre WebSockets explica el transporte, y probar una conexión WebSocket te ayuda cuando falla el handshake.
El composable
El cliente vive en el ámbito del módulo, así que todos los componentes que llaman a useMqtt() comparten una conexión. Un contador sencillo cierra la conexión cuando se va el último usuario:
import { onScopeDispose, readonly, ref, shallowRef, toValue, watch, type MaybeRefOrGetter } from 'vue';
import mqtt, { type MqttClient } from 'mqtt';
type Status = 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'offline' | 'error';
type Handler = (topic: string, payload: Uint8Array) => void;
const URL = import.meta.env.VITE_MQTT_URL ?? 'wss://broker.connectmq.com:8084/mqtt';
let client: MqttClient | null = null;
let users = 0;
const status = ref<Status>('idle');
const lastError = ref<string | null>(null);
const handlers = new Map<string, Set<Handler>>();
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;
}
function getClient(): MqttClient {
if (client) return client;
status.value = 'connecting';
client = mqtt.connect(URL, {
username: import.meta.env.VITE_MQTT_USER,
password: import.meta.env.VITE_MQTT_PASS,
clientId: `vue-${crypto.randomUUID().slice(0, 8)}`,
reconnectPeriod: 2000,
connectTimeout: 10_000,
});
client.on('connect', () => { status.value = 'connected'; lastError.value = null; });
client.on('reconnect', () => { status.value = 'reconnecting'; });
client.on('offline', () => { status.value = 'offline'; });
client.on('error', (err) => { status.value = 'error'; lastError.value = err.message; });
client.on('message', (topic, payload) => {
handlers.forEach((set, filter) => {
if (matches(filter, topic)) set.forEach((h) => h(topic, payload));
});
});
return client;
}
export function useMqtt() {
const c = getClient();
users++;
onScopeDispose(() => {
if (--users === 0) {
c.end(true);
client = null;
status.value = 'idle';
}
});
function subscribe(filter: string, handler: Handler): () => void {
let set = handlers.get(filter);
if (!set) {
set = new Set();
handlers.set(filter, set);
c.subscribe(filter, { qos: 1 });
}
set.add(handler);
return () => {
set!.delete(handler);
if (set!.size === 0) {
handlers.delete(filter);
c.unsubscribe(filter);
}
};
}
function publish(topic: string, message: string, retain = false) {
return c.publishAsync(topic, message, { qos: 1, retain });
}
return { status: readonly(status), error: readonly(lastError), subscribe, publish };
}
export interface TopicMessage<T> { topic: string; data: T; at: number }
export function useTopic<T = unknown>(filter: MaybeRefOrGetter<string>, max = 50) {
const { subscribe } = useMqtt();
const messages = shallowRef<TopicMessage<T>[]>([]);
const latest = shallowRef<Record<string, T>>({});
watch(
() => toValue(filter),
(f, _old, onCleanup) => {
messages.value = [];
latest.value = {};
const off = subscribe(f, (topic, payload) => {
const text = new TextDecoder().decode(payload);
let data: T;
try { data = JSON.parse(text) as T; } catch { data = text as T; }
messages.value = [{ topic, data, at: Date.now() }, ...messages.value].slice(0, max);
latest.value = { ...latest.value, [topic]: data };
});
onCleanup(off);
},
{ immediate: true },
);
return { messages, latest };
}Por qué está escrito así:
- El cliente nunca es reactivo. Está en un
letnormal. Envolver unMqttClientenref()oreactive()crearía un proxy profundo de un event emitter grande sin ningún beneficio. Si lo guardas en un store de Pinia, envuélvelo conmarkRaw(). shallowRefpara los mensajes. Vue solo rastrea la referencia, no cada campo de cada payload. Cada actualización asigna un array u objeto nuevo, que es justo lo que dispara el re-render.- La limpieza va ligada al effect scope.
onScopeDisposey elonCleanupdel watcher se ejecutan cuando el componente se desmonta (y, en el caso deonCleanup, cuando cambia el filtro de topic), así que ni los handlers ni las suscripciones del broker se quedan colgando. - Las suscripciones cuentan referencias por filtro. Dos componentes que muestran el mismo topic comparten una suscripción MQTT. MQTT.js se vuelve a suscribir solo tras una reconexión, y las llamadas hechas antes de conectar se encolan y se envían en cuanto conecta.
Como useTopic acepta un ref o un getter, un componente puede cambiar de topic de forma reactiva: el watcher cancela la suscripción al filtro anterior y se suscribe al nuevo. Comprueba tus filtros con el topic matcher cuando haya wildcards.
Uso en un componente
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useMqtt, useTopic } from '@/composables/mqtt';
interface Reading { t: number; h?: number }
const room = ref('lab');
const { status, error, publish } = useMqtt();
const { latest, messages } = useTopic<Reading>(() => `testmqtt/vue/${room.value}/+`);
const rows = computed(() =>
Object.entries(latest.value).map(([topic, r]) => ({ device: topic.split('/').pop(), t: r.t })),
);
function ping() {
publish(`testmqtt/vue/${room.value}/cmd/ping`, String(Date.now()));
}
</script>
<template>
<section>
<p>Broker: {{ status }} <span v-if="error">({{ error }})</span></p>
<select v-model="room">
<option value="lab">lab</option>
<option value="office">office</option>
</select>
<table>
<tr v-for="row in rows" :key="row.device">
<td>{{ row.device }}</td>
<td>{{ row.t.toFixed(1) }} °C</td>
</tr>
</table>
<button :disabled="status !== 'connected'" @click="ping">Ping</button>
<p>{{ messages.length }} messages received</p>
</section>
</template>Cambiar el <select> pasa la suscripción de testmqtt/vue/lab/+ a testmqtt/vue/office/+ sin tocar la conexión.
En topics muy activos, cada mensaje sigue provocando un render. Si llegan decenas de mensajes por segundo al mismo panel, acumúlalos en un array normal y vuélcalos al shallowRef una vez por requestAnimationFrame, o limita la frecuencia con un helper como useThrottleFn de VueUse.
Credenciales en una app Vue
Las variables VITE_* se compilan dentro del bundle y cualquiera que abra DevTools puede verlas. Trata las credenciales del navegador como públicas:
- Crea un usuario dedicado al navegador que pueda suscribirse a los topics del dashboard y, si hace falta, publicar en un único topic de comandos.
- Para acceso por usuario, haz que tu backend emita credenciales de corta duración tras el login y pásalas a
mqtt.connect(saca las opciones degetClient()a un parámetro). - Nunca publiques la cuenta que usan tus servicios backend.
Autenticación de clientes en el navegador compara las opciones en detalle.
Probar el composable
- Arranca el servidor de desarrollo y comprueba que el panel muestra
Broker: connected. Si veserror, compara el mensaje:Connection refused: Not authorizedapunta a las credenciales; un fallo de WebSocket, a la URL, al puerto8084o a la ruta/mqtt. - Abre el cliente MQTT online, conéctate al mismo broker y publica
{"t": 22.1}entestmqtt/vue/lab/sensor-1. La fila aparece al momento. - Cambia el select a
officey vuelve a publicar entestmqtt/vue/lab/sensor-1: no debería cambiar nada. Publica entestmqtt/vue/office/sensor-9y aparece la fila nueva. Esto demuestra que la suscripción anterior se limpió. - Suscríbete a
testmqtt/vue/#en el cliente online y pulsa Ping para verificar la publicación. - Navega a una ruta sin el panel y confirma en el log del broker o en la lista de clientes que la conexión se cierra cuando ningún componente la usa.
Envía mensajes de prueba a tu app Vue
Para tests automatizados, mantén matches() y el parseo como funciones puras y pruébalas con Vitest; para la conexión, un pequeño test de integración contra un broker real detecta mucho más que un cliente mockeado. Si la app reconecta en bucle, lee reconexión de MQTT sobre WebSockets en JavaScript y la referencia de errores de MQTT.js.
Solución de problemas
| Síntoma | Causa | Solución |
|---|---|---|
| La UI no se actualiza aunque llegan mensajes | Mutar un array shallowRef en sitio (push) | Asigna un array nuevo o llama a triggerRef() |
| Cada pestaña desconecta a las demás | clientId fijo | Genera un client ID aleatorio por pestaña |
| Se siguen procesando mensajes al salir de la página | El handler no se eliminó | Usa onScopeDispose / onCleanup como arriba |
WebSocket connection to 'wss://...' failed | Puerto o ruta incorrectos, o red bloqueada | Prueba la misma URL en el cliente online |
Un composable deja MQTT donde Vue espera que viva el estado: una conexión, estado reactivo y suscripciones que siguen el ciclo de vida del componente. Pruébalo con un segundo cliente que publique mensajes reales y el mismo código funcionará sin cambios cuando lo apuntes a tu broker de producción.
Preguntas frecuentes
¿Debo guardar el cliente MQTT en un ref o un objeto reactive de Vue?
No. El cliente es un event emitter grande con estado interno, y envolverlo en un proxy reactivo profundo añade overhead y puede romper comparaciones de identidad. Guárdalo en una variable de módulo normal, o usa shallowRef o markRaw si tiene que vivir en estado reactivo.
¿Cómo uso MQTT en Nuxt?
Ejecuta el cliente solo en el navegador: pon el composable tras un plugin .client.ts, llámalo desde onMounted o envuelve el componente en <ClientOnly>. Usa una URL wss://, porque el navegador no puede abrir conexiones TCP a los puertos 1883 u 8883.
¿Por qué mi componente Vue sigue recibiendo mensajes MQTT después de destruirse?
El listener o la suscripción nunca se eliminaron. Registra la limpieza con onScopeDispose o con el onCleanup de un watcher para quitar el handler y cancelar la suscripción al topic cuando se detenga el effect scope del componente.



