The clean way to use MQTT in Vue 3 is a composable: one shared MQTT.js client over secure WebSockets, a useMqtt() function that exposes reactive connection status and a publish method, and a useTopic() function that subscribes while a component is alive and unsubscribes automatically when it is destroyed. Messages go into shallowRef state so Vue does not deep-proxy every payload.
This tutorial builds that composable, uses it in a <script setup> component and shows how to test it against a live broker.
Install and connection settings
npm i mqttMQTT.js 5 includes TypeScript types and a browser build that works with Vite out of the box. Browsers can only speak MQTT over WebSockets, so the URL is always ws:// or wss://:
| Setting | Value |
|---|---|
| URL | wss://broker.connectmq.com:8084/mqtt |
| Protocol | MQTT 3.1.1 or 5 |
| Credentials | a browser account with read access to the topics the UI shows |
Use wss:// on any page served over HTTPS; ws:// is blocked as mixed content. MQTT over WebSockets explains the transport, and testing a WebSocket connection helps when the handshake fails.
The composable
The client lives in module scope, so every component that calls useMqtt() shares one connection. A simple counter closes the connection when the last user goes away:
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 };
}Why it is written this way:
- The client is never reactive. It sits in a plain
let. Wrapping anMqttClientinref()orreactive()would deep-proxy a large event emitter for no benefit. If you put it in a Pinia store, wrap it withmarkRaw(). shallowReffor messages. Vue only tracks the reference, not every field of every payload. Each update assigns a new array or object, which is exactly what triggers the re-render.- Cleanup is tied to the effect scope.
onScopeDisposeand the watcher'sonCleanuprun when the component unmounts — or, foronCleanup, when the topic filter changes — so handlers and broker subscriptions never leak. - Subscriptions are ref-counted per filter. Two components showing the same topic share one MQTT subscription. MQTT.js re-subscribes automatically after a reconnect, and calls made before the connection is up are queued and sent once it connects.
Because useTopic accepts a ref or getter, a component can switch topics reactively — the watcher unsubscribes from the old filter and subscribes to the new one. Check your filters with the topic matcher when wildcards are involved.
Using it in a component
<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>Changing the <select> switches the subscription from testmqtt/vue/lab/+ to testmqtt/vue/office/+ without touching the connection.
For very busy topics, every message still causes a render. If dozens of messages per second hit the same panel, buffer them in a plain array and flush into the shallowRef once per requestAnimationFrame, or throttle with a helper such as VueUse's useThrottleFn.
Credentials in a Vue app
VITE_* variables are compiled into the bundle and visible to anyone who opens DevTools. Treat browser credentials as public:
- Create a dedicated browser user that can subscribe to the dashboard topics and, if needed, publish to one command topic.
- For per-user access, have your backend issue short-lived credentials after login and pass them to
mqtt.connect(move the options out ofgetClient()into a parameter). - Never ship the account your backend services use.
Authenticating browser clients compares the options in detail.
Testing the composable
- Start the dev server and check that the panel shows
Broker: connected. If you seeerror, compare the message:Connection refused: Not authorizedpoints to credentials, a WebSocket failure to the URL, port8084or path/mqtt. - Open the online MQTT client, connect to the same broker and publish
{"t": 22.1}totestmqtt/vue/lab/sensor-1. The row appears immediately. - Switch the select to
office, publish totestmqtt/vue/lab/sensor-1again — nothing should change. Publish totestmqtt/vue/office/sensor-9and the new row appears. This proves the old subscription was cleaned up. - Subscribe to
testmqtt/vue/#in the online client and click Ping to verify publishing. - Navigate to a route without the panel and confirm in the broker log or client list that the connection closes once no component uses it.
Send test messages to your Vue app
For automated tests, keep matches() and the parsing logic as pure functions and test them with Vitest; for the connection itself, a small integration test against a real broker catches far more than a mocked client. If the app reconnects in a loop, read reconnecting MQTT over WebSockets in JavaScript and the MQTT.js error reference.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| UI does not update although messages arrive | Mutating a shallowRef array in place (push) | Assign a new array, or call triggerRef() |
| Every tab disconnects the others | Fixed clientId | Generate a random client ID per tab |
| Messages still handled after leaving the page | Handler not removed | Use onScopeDispose / onCleanup as above |
WebSocket connection to 'wss://...' failed | Wrong port, path or blocked network | Test the same URL in the online client |
A composable keeps MQTT where Vue expects state to live: one connection, reactive status, subscriptions that follow the component lifecycle. Test it with a second client publishing real messages, and the same code will carry over unchanged when you point it at your production broker.
Frequently asked questions
Should I store the MQTT client in a Vue ref or reactive object?
No. The client is a large event emitter with internal state, and wrapping it in a deep reactive proxy adds overhead and can break identity checks. Keep it in a plain module variable, or use shallowRef or markRaw if it must live in reactive state.
How do I use MQTT in Nuxt?
Run the client only in the browser: put the composable behind a .client.ts plugin, call it from onMounted, or wrap the component in <ClientOnly>. Use a wss:// URL, because the browser cannot open TCP connections to ports 1883 or 8883.
Why does my Vue component keep receiving MQTT messages after it is destroyed?
The message listener or the subscription was never removed. Register cleanup with onScopeDispose or a watcher's onCleanup so the handler is removed and the topic is unsubscribed when the component's effect scope stops.



