A Vue 3 plugin keeps its state in module-level variables and starts a timer in install; what goes wrong when several apps use it, and how do you fix it?
answer
- module code runs once
- install runs once per app
- shared state, duplicated timers
- state inside install or a factory
- app.onUnmount in 3.5
basics
~20 sModule state is shared by every app that installs the plugin, while install, and its timer, runs once per app and never stops. Create state per app inside install or a factory, and clear the timer in app.onUnmount (Vue 3.5+).
solid answer
~40 sA module's top-level code runs once, so every app that installs the plugin reads and writes the same state: one app's changes leak into the other. `install` runs once per app, because Vue keeps a separate installed set for each app, so two apps start two timers that both write that shared state. When an app calls `app.unmount()`, nothing stops its timer, so it leaks and keeps holding references. The fix is to make everything per app: build state inside `install`, or export a `createX()` factory that returns a fresh plugin object, expose it with `app.provide`, and register teardown with `app.onUnmount(cb)`, which Vue 3.5 added and runs when the app is unmounted.
code
ts · 25 linesimport { reactive, type App, type InjectionKey } from 'vue'
export interface Health {
online: boolean
}
export const HealthKey: InjectionKey<Health> = Symbol('health')
export function createHealth(options: { url: string; everyMs: number }) {
return {
install(app: App) {
const state = reactive<Health>({ online: true }) // per app
const id = setInterval(async () => {
try {
state.online = (await fetch(options.url)).ok
} catch {
state.online = false
}
}, options.everyMs)
app.provide(HealthKey, state)
app.onUnmount(() => clearInterval(id)) // Vue 3.5+
},
}
}go deeper
Recall that install runs once per app and that timers or listeners a plugin starts do not stop by themselves.
Explain module evaluation versus per-app install, and how app.provide plus state created inside install isolates apps.
Diagnose shared-state and leak symptoms across widgets, tests and remounts, and fix them with a factory plugin and app.onUnmount cleanup.
Set a rule for plugin authors in the codebase: no module-level mutable state, every side effect paired with teardown, and multiple apps assumed by default.
## The setup that fails Imagine a plugin file like this: a `const state = reactive({ online: true })` at module level, and an `install(app)` that provides `state` and starts `setInterval` to poll a health endpoint. It works in a single-app page. It breaks when there is more than one app instance, which happens more often than it seems: - two independent widgets mounted as separate apps on one server-rendered page; - a test suite that creates a fresh app per test; - micro-frontends that mount and unmount apps as the user navigates; - server rendering, which creates an app per request (its state-pollution rules are covered with the SSR APIs). ## Why it breaks 1. **Module state is shared.** JavaScript evaluates a module's top level once per realm. Every app importing the plugin sees the same `state` object, so one app's writes appear in the other, and reactive effects in both apps react to them. 2. **Install runs per app.** Vue's installed-plugin record is created inside each `createApp` call. App A and app B each run `install`, so there are two intervals, both writing the one shared object. 3. **Nothing stops the timer.** `app.unmount()` tears down the component tree, which runs component unmount hooks, but a timer started in `install` is not owned by any component. It keeps firing after the app is gone, and its closure keeps everything it references reachable, so that memory is never reclaimed. ## The fix | Problem | Fix | |---|---| | state shared across apps | create it inside `install`, or in a `createX()` factory | | consumers need the state | `app.provide(Key, state)` per app | | timer or listener outlives the app | `app.onUnmount(() => clearInterval(id))` (3.5+) | | tests leak between cases | a new app per test also means new plugin state | The **factory pattern** is what many ecosystem libraries use: `export function createHealth(options) { return { install(app) { ... } } }`. Each call returns a new plugin object, and the state lives in its closure. It also sidesteps the duplicate-install check deliberately, because each app receives a different plugin object. ## app.onUnmount Vue 3.5 added `app.onUnmount(callback)` for exactly this case. Callbacks registered on an app run when `app.unmount()` is called, **before** Vue unmounts the component tree, and Vue calls them through its error-handling wrapper, so a throwing callback is reported rather than aborting the unmount. Before 3.5 there was no app-level teardown hook, so plugins had to return or expose their own `dispose()` function and rely on the host to call it. Typical things to clean up there: - intervals and timeouts; - `window` or `document` event listeners; - open WebSocket or `BroadcastChannel` connections; - subscriptions to external stores or services. ## How it shows up, and how to confirm it 1. **Values leak between widgets.** A change made in one app appears in another that should be independent. Log the provided object in both apps and compare identity: the same object means module-level state. 2. **Polling doubles.** After a second app mounts, the network panel shows two request streams, one per `install`. 3. **Memory grows across mount cycles.** Heap snapshots taken after several mount and unmount cycles show detached component trees retained by a timer closure, which points at missing teardown. ## Why it matters in production The single-app happy path never shows the bug. It appears as stale data in one widget after another changed it, duplicated network polling, or steadily growing memory in a long-lived page that mounts and unmounts apps. A plugin should behave as if more than one app will install it, because the moment a second app appears, hidden module state stops being a harmless shortcut.
- When exactly do app.onUnmount callbacks run relative to the component tree?In Vue 3.5, `app.unmount()` first calls the registered cleanup callbacks, then renders `null` into the container, which unmounts the component tree and runs component unmount hooks. So a cleanup callback runs while the components still exist, and Vue invokes it through its error-handling wrapper rather than as a bare call.
- How would you clean up a plugin's side effects on Vue versions before 3.5?There was no app-level unmount hook, so the plugin had to expose teardown itself: for example returning a `dispose()` from a factory or providing an object with a `stop()` method, and documenting that the host must call it next to `app.unmount()`.
saying these in an interview costs you the question
- Each Vue app gets its own copy of a plugin module's top-level variables
- app.unmount() automatically stops timers a plugin started in install
- A plugin used by two apps only runs install once for the whole page
- app.onUnmount callbacks run after every component has unmounted
- Module-level state is fine as long as it is wrapped in reactive()