O jeito limpo de usar MQTT no Vue 3 é um composable: um único cliente MQTT.js compartilhado sobre WebSockets seguros, uma função useMqtt() que expõe o status da conexão de forma reativa e um método publish, e uma função useTopic() que assina enquanto o componente está vivo e cancela a assinatura automaticamente quando ele é destruído. As mensagens vão para estado shallowRef, para que o Vue não crie proxies profundos de cada payload.
Este tutorial monta esse composable, usa-o em um componente <script setup> e mostra como testá-lo contra um broker real.
Instalação e configurações de conexão
npm i mqttO MQTT.js 5 inclui tipos TypeScript e um build para navegador que funciona com o Vite sem configuração. Navegadores só falam MQTT sobre WebSockets, então a URL é sempre ws:// ou wss://:
| Configuração | Valor |
|---|---|
| URL | wss://broker.connectmq.com:8084/mqtt |
| Protocolo | MQTT 3.1.1 ou 5 |
| Credenciais | uma conta de navegador com leitura nos topics que a UI exibe |
Use wss:// em qualquer página servida por HTTPS; ws:// é bloqueado como conteúdo misto. MQTT sobre WebSockets explica o transporte, e testar uma conexão WebSocket ajuda quando o handshake falha.
O composable
O cliente vive no escopo do módulo, então todo componente que chama useMqtt() compartilha uma conexão. Um contador simples fecha a conexão quando o último usuário sai:
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 que está escrito assim:
- O cliente nunca é reativo. Ele fica em um
letcomum. Envolver umMqttClientemref()oureactive()criaria um proxy profundo de um event emitter grande sem nenhum benefício. Se for guardá-lo em uma store Pinia, envolva commarkRaw(). shallowRefpara as mensagens. O Vue rastreia só a referência, não cada campo de cada payload. Cada atualização atribui um array ou objeto novo, que é exatamente o que dispara o re-render.- A limpeza é ligada ao effect scope.
onScopeDisposee oonCleanupdo watcher rodam quando o componente desmonta (e, no caso doonCleanup, quando o filtro de topic muda), então handlers e assinaturas no broker nunca vazam. - As assinaturas contam referências por filtro. Dois componentes exibindo o mesmo topic compartilham uma assinatura MQTT. O MQTT.js reassina automaticamente após uma reconexão, e chamadas feitas antes da conexão subir ficam em fila e são enviadas assim que ela conecta.
Como useTopic aceita um ref ou getter, um componente pode trocar de topic de forma reativa: o watcher cancela a assinatura do filtro antigo e assina o novo. Confira seus filtros com o topic matcher quando houver wildcards.
Usando em um 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>Mudar o <select> troca a assinatura de testmqtt/vue/lab/+ para testmqtt/vue/office/+ sem mexer na conexão.
Em topics muito movimentados, cada mensagem ainda causa um render. Se dezenas de mensagens por segundo chegam ao mesmo painel, acumule-as em um array comum e despeje no shallowRef uma vez por requestAnimationFrame, ou limite a frequência com um helper como o useThrottleFn do VueUse.
Credenciais em uma app Vue
Variáveis VITE_* são compiladas no bundle e ficam visíveis para qualquer pessoa que abra o DevTools. Trate credenciais de navegador como públicas:
- Crie um usuário dedicado ao navegador que possa assinar os topics do dashboard e, se necessário, publicar em um único topic de comandos.
- Para acesso por usuário, faça o backend emitir credenciais de curta duração após o login e passe-as ao
mqtt.connect(mova as opções degetClient()para um parâmetro). - Nunca distribua a conta usada pelos seus serviços de backend.
Autenticação de clientes no navegador compara as opções em detalhes.
Testando o composable
- Inicie o servidor de desenvolvimento e confira que o painel mostra
Broker: connected. Se aparecererror, compare a mensagem:Connection refused: Not authorizedaponta para as credenciais; uma falha de WebSocket, para a URL, a porta8084ou o path/mqtt. - Abra o cliente MQTT online, conecte ao mesmo broker e publique
{"t": 22.1}emtestmqtt/vue/lab/sensor-1. A linha aparece na hora. - Mude o select para
officee publique de novo emtestmqtt/vue/lab/sensor-1: nada deve mudar. Publique emtestmqtt/vue/office/sensor-9e a nova linha aparece. Isso prova que a assinatura antiga foi limpa. - Assine
testmqtt/vue/#no cliente online e clique em Ping para verificar a publicação. - Navegue para uma rota sem o painel e confirme no log do broker ou na lista de clientes que a conexão fecha quando nenhum componente a usa.
Envie mensagens de teste para a sua app Vue
Para testes automatizados, mantenha matches() e o parsing como funções puras e teste-as com Vitest; para a conexão em si, um pequeno teste de integração contra um broker real pega muito mais do que um cliente mockado. Se a app reconecta em loop, leia reconexão de MQTT sobre WebSockets em JavaScript e a referência de erros do MQTT.js.
Solução de problemas
| Sintoma | Causa | Correção |
|---|---|---|
| A UI não atualiza mesmo com mensagens chegando | Mutar um array shallowRef no lugar (push) | Atribua um array novo ou chame triggerRef() |
| Cada aba desconecta as outras | clientId fixo | Gere um client ID aleatório por aba |
| Mensagens continuam sendo tratadas após sair da página | Handler não removido | Use onScopeDispose / onCleanup como acima |
WebSocket connection to 'wss://...' failed | Porta ou path errados, ou rede bloqueada | Teste a mesma URL no cliente online |
Um composable deixa o MQTT onde o Vue espera que o estado more: uma conexão, status reativo e assinaturas que seguem o ciclo de vida do componente. Teste com um segundo cliente publicando mensagens reais e o mesmo código vai funcionar sem mudanças quando você apontá-lo para o broker de produção.
Perguntas frequentes
Devo guardar o cliente MQTT em um ref ou objeto reactive do Vue?
Não. O cliente é um event emitter grande com estado interno, e envolvê-lo em um proxy reativo profundo adiciona overhead e pode quebrar comparações de identidade. Guarde-o em uma variável de módulo comum, ou use shallowRef ou markRaw se ele precisar viver em estado reativo.
Como uso MQTT no Nuxt?
Rode o cliente só no navegador: coloque o composable atrás de um plugin .client.ts, chame-o no onMounted ou envolva o componente em <ClientOnly>. Use uma URL wss://, porque o navegador não abre conexões TCP nas portas 1883 ou 8883.
Por que meu componente Vue continua recebendo mensagens MQTT depois de destruído?
O listener ou a assinatura nunca foram removidos. Registre a limpeza com onScopeDispose ou com o onCleanup de um watcher para remover o handler e cancelar a assinatura do topic quando o effect scope do componente parar.



