Tutorials

MQTT in Vue 3 with MQTT.js: A Composable for Real-Time Data

Use MQTT in Vue 3 with MQTT.js 5: a shared useMqtt composable, reactive topic subscriptions with shallowRef, automatic cleanup and secure WebSockets.

Updated · 8 min read

MQTT in Vue 3 with MQTT.js: A Composable for Real-Time Data

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

Install
npm i mqtt

MQTT.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://:

SettingValue
URLwss://broker.connectmq.com:8084/mqtt
ProtocolMQTT 3.1.1 or 5
Credentialsa 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:

src/composables/mqtt.ts
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 an MqttClient in ref() or reactive() would deep-proxy a large event emitter for no benefit. If you put it in a Pinia store, wrap it with markRaw().
  • shallowRef for 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. onScopeDispose and the watcher's onCleanup run when the component unmounts — or, for onCleanup, 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

src/components/SensorPanel.vue
<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 of getClient() into a parameter).
  • Never ship the account your backend services use.

Authenticating browser clients compares the options in detail.

Testing the composable

  1. Start the dev server and check that the panel shows Broker: connected. If you see error, compare the message: Connection refused: Not authorized points to credentials, a WebSocket failure to the URL, port 8084 or path /mqtt.
  2. Open the online MQTT client, connect to the same broker and publish {"t": 22.1} to testmqtt/vue/lab/sensor-1. The row appears immediately.
  3. Switch the select to office, publish to testmqtt/vue/lab/sensor-1 again — nothing should change. Publish to testmqtt/vue/office/sensor-9 and the new row appears. This proves the old subscription was cleaned up.
  4. Subscribe to testmqtt/vue/# in the online client and click Ping to verify publishing.
  5. 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

SymptomCauseFix
UI does not update although messages arriveMutating a shallowRef array in place (push)Assign a new array, or call triggerRef()
Every tab disconnects the othersFixed clientIdGenerate a random client ID per tab
Messages still handled after leaving the pageHandler not removedUse onScopeDispose / onCleanup as above
WebSocket connection to 'wss://...' failedWrong port, path or blocked networkTest 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.